SLOPSHOPPER

cc-over

Claude Code mod: cross-session handoff bundles

panecommandmodelprocesstimer
v0.2.0Apache-2.0updated 2026-10-25numikel/cc-over/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-over
│ ┃ Hand-off ✕ › fix the failing auth test and add an audit log call │ ┃ Hand-off │ ┃ ⏺ Read(src/auth.ts) │ ┃ Intent:: What should the other session do? ⏎ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Type: hand-off (y) ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ From the platform's session list; details ⎿ 3 pass, 1 fail │ ┃ only from sessions running cc-over. Some │ ┃ sessions are not listed (see README). ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ ▼ This project: (t) ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ no other session in this project › /over │ ┃ │ ┃ [ ▶ Other sessions: 0 (o) ] │ ┃ │ ┃ [ Refresh list (r) ] │ ┃ │ ┃ [ ▶ History (h) ] │ ┃ │ ┃ [ Cancel ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hand-off
Hand-off Intent:: What should the other session do? ⏎ compose [ Type: hand-off (y) ] From the platform's session list; details only from sessions running cc-over. Some sessions are not listed (see README). [ ▼ This project: (t) ] no other session in this project [ ▶ Other sessions: 0 (o) ] [ Refresh list (r) ] [ ▶ History (h) ] [ Cancel ]
README

cc-over

Hand off work between parallel Claude Code sessions with one explicit gesture: /over <intent>.

CI Claude Code Pinned Claude Code Platform

Status: first slices (S-01 to S-07). One hand-off to one live local session, or a short broadcast to several, with an acknowledgement coming back, picked from a session list grouped by project, a status per target that survives a mod reload, and a history of the bundles you sent. The sender runs in the CLI and, from a marketplace install, in the Desktop app (permission mode Manual; see "Limits"); any local session, Desktop included, can receive.

Sections: About · Planned · Requirements · Installation · Usage · The session list · Composing and cost · What leaves your session · Sent history · Bundle format · What "passed on" and "received" mean · Known platform behaviour · Limits · Project structure · Development · Continuous integration · Types first · About this repository · License


About

When you run several Claude Code sessions on one task, for example an implementation session and an implementation-review session, you keep passing work between them. Claude Code has cross-session messaging, but the agent rarely uses it on its own (the author's observation: roughly 2–3 out of 10 hand-off requests), and you cannot easily point at the target session.

The usual workarounds are tedious:

  • Generate a file and formally instruct the agent to send it, with no easy way to say where.
  • Copy and paste the reviewer's last answer into the other session.

Both are disorganized and routinely need more workarounds.

The idea: the initiative has to sit with the human, as an explicit, cheap gesture. In the session that has the context, type /over <intent>. The mod composes a hand-off bundle with a predictable structure, shows a preview, lets you pick one live session, and sends it after you confirm. No copy-paste between terminals. The same gesture sends a short broadcast notice to several sessions at once.

cc-over is a Claude Code mod: a plugin built on function hooks. It has no runtime dependencies.

Planned

Not shipped yet:

  • Sending again from the history, and a history of the bundles you received.

Requirements

  • Claude Code with mods support. The repository pins 2.1.310 in plugin/.claude-code-version; the mods API itself needs 2.1.287 or later.
  • Node.js 22 (the version CI uses) for the dev tooling.
  • OS: Linux or Windows (both run in CI).

Installation

From a clone:

git clone https://github.com/numikel/cc-over
cd cc-over
claude --plugin-dir plugin

--plugin-dir is relative to the current directory, so start it from the repo root. The mod has no dependencies.

From the marketplace

This repository is also a plugin marketplace named cc-over, with one plugin, also named cc-over. In Claude Code run:

/plugin marketplace add numikel/cc-over
/plugin install cc-over@cc-over
/reload-plugins

The Desktop app has no --plugin-dir, so it uses the marketplace install. Desktop as a sender was verified on 2026-10-25 (Claude Code 2.1.310) from this install, with the session in permission mode Manual: the preview, the send, the status and the acknowledgement all worked. In mode Auto the platform refused the send (see "Limits"). A Desktop session as a recipient needs no install and was verified on 2026-10-19: it picked the hand-off up and acknowledged it.

Usage

CommandWhat it does
/overOpens the pane with the live sessions and an intent field. Type the intent and press Enter to compose; from there it works as /over <intent>. To change the intent, cancel and run /over again. The Type (y) switch is next to the intent field. Under the sessions, a folded History (h) section lists the bundles sent from this project (see "Sent history"); h opens it and a click on a row opens that bundle's summary, instructions and files.
/over <intent>Composes a hand-off from this conversation and the repo, and opens a preview pane with the live sessions. Pick one with 1-9 (or click), press s to send, y to switch the type to a broadcast, e to expand the full text, r to refresh the list, t and o to fold "This project" and "Other sessions", Esc or Cancel to drop it.
/over --no-fork <intent>The same, without asking a model for a summary (see "Composing and cost").
/over --broadcast <intent>Composes a broadcast: a short notice that holds your intent word for word and its source, with no summary, no files and no model call. The preview opens with the type already set to broadcast (see "Hand-off or broadcast").
/over --listPrints the sessions a hand-off can go to, in the same groups as the pane, all unfolded.
/over --send <session>Sends the bundle composed last, hand-off or broadcast, to one more session. Run it once per target to send one bundle to several sessions. The name may contain spaces; when two sessions share a name, use name [ref] as --list prints it. The bundle lives in the mod's memory: after a mod reload or in a new process, compose it again.
/over --send-repoSends the broadcast composed last to every other session of this repository (the "This project" group). A session that already has a send of this bundle is skipped (already sent to <name>). It refuses a hand-off: compose a broadcast with --broadcast first.
/over --statusPrints the sends of this session, grouped by bundle: a HH:MM <intent> heading (HH:MM broadcast: <intent> for a broadcast) and one status line per target under it (see "What passed on and received mean").
/over --history [n]Prints the bundles sent from this project in the last 14 days, newest first, one numbered row each. With a number from 1 to 50 it prints that entry in full: intent, place, targets with their status, summary, instructions and files. It is the same list as the pane's History section (see "Sent history").

Where nothing can be drawn (claude -p, the SDK) the pane does not open: /over <intent> and /over --broadcast <intent> log the preview, and --list, --send, --send-repo, --status and --history do what the pane shows and its buttons do. A bare /over prints the usage line.

Hand-off or broadcast

A bundle has a type. A hand-off asks one session to pick up work and is the default. A broadcast is a notice for several sessions: it says what you said, in your words, and asks the recipients to take note and carry on, not to start work because of it. The type is never guessed from your intent: it is a hand-off until you change it, either with the Type: hand-off (y) button next to the intent field (press y) or by starting with /over --broadcast <intent>. The switch works while you type the intent, while the bundle is composing and in the preview. A broadcast is composed at once from your intent: it does not call the model and it does not attach a git summary. The From line still reads the branch name with one git call.

In broadcast mode the session rows are toggles (● selected, ○ not) instead of a single choice, and the send button names what it will do, for example Send to 2 sessions: impl, repo-a | review (s) (it lists at most three names and then …). The sessions of this project are ticked once, when you switch to a broadcast (or open one with --broadcast) and the list is first loaded; a refresh afterwards only removes sessions that have closed and never ticks a new one. Sessions of other projects, and sessions without the mod, are never ticked for you. Before sending, each ticked session is checked again by its reference: if one has closed, or another session has taken its name, nothing is sent and the pane says which. Switching back to a hand-off keeps the selection only when it holds one session; otherwise you pick one again. The sends go one after the other, and the toast reports broadcast passed on to <k> of <n> sessions.

The recipient gets a message that starts like this:

[claude-over hand-off ovr-...]
Send: 3fa9c21
From: <session name> · repo <repo> · branch <branch> · session <id>
Approved by the user: they reviewed this hand-off in a preview and chose to send it to you. It is not a prompt typed by the user in this session.
Handling: finish your current task first, then pick up the work below. If you do not pick it up, tell your user why. Leave any step that needs your user's permission (such as a commit) for them to confirm, and do the rest.
Acknowledge now, before anything else: call the SendMessage tool with `to` set to the exact value of the `from` attribute of the cross-session-message envelope around this text, and `message` set to `over-ack ovr-... 3fa9c21`. This single call does not interrupt your current task; carry on with it afterwards.

followed by Summary, Instructions, Changed files and Details (see "Bundle format"). An idle recipient starts working on its own; a busy one reads the message between tool calls, acknowledges, finishes its task and then picks the work up. The platform also shows the message in the recipient's history as coming from your session. The recipient needs no mod: everything it has to do is in the text.

The session list

The pane groups the live local sessions:

From the platform's session list; details only from sessions running cc-over. Some sessions are not listed (see README). hidden: 1 (not local)
▼ This project: my-repo (t)
  ├─ ○ impl · main · idle · 2m ago (1)
  └─ ○ review · my-worktree (feat/foo) · busy · 5s ago (2)
▶ Other sessions: 6 (o)
  • This project holds the sessions that run in the same repository as the sender, worktrees included. Each row shows the session name, the branch (or worktree (branch)), the status and the time of its last turn.
  • Other sessions starts folded on every new preview. Unfolded, it lists the other repositories alphabetically, each with its sessions, and at the end no details (mod not running there).
  • t and o fold the groups, 1-9 number the rows that are visible, so the numbers shift when a group is folded. The selected session stays selected when its group is folded.
  • The list refreshes when the pane opens, on r, and every 10 seconds while the pane is open.

The platform's session list says only who is alive, with a name and a status. The repository, the branch and the last activity come from a small registry: every session that runs cc-over writes an entry about itself to the plugin's shared store (session:<session id>: root directory, repository, branch, name and the time of its last turn; no conversation text and no bundle: what you sent is kept apart, see "Sent history"). A session without the mod has no entry, so it lands under no details with the started … ago time from the platform's listing. The mod does not read the platform's own session files or transcripts. Entries untouched for 15 days, and the oldest ones above 3 MiB of store, are pruned, never the current session's.

The "last activity" is the end of the session's last turn: an idle session writes nothing in the background.

Composing and cost

By default the summary comes from $.model.fork: one extra model call on this session's conversation, no tool use, no turn in the conversation. It uses the session's model. When the prompt cache has expired, the whole conversation prefix is billed again, so on a long conversation a fork can cost as much as a normal turn.

  • /over --no-fork <intent> skips the model for one call.
  • /config → cc-over.compose = none skips it for good (fork is the default). The bundle then holds your intent and the git data, and says "no conversation summary: none mode".
  • A fork that cannot run (nothing to fork in a fresh session, API error, empty reply) falls back to the same bundle, with the reason in the Note.

The changed files are read from git, not from the model: uncommitted changes plus the branch diff against origin/HEAD (or main), or the commits of this session when you are on the base branch. The list stops at 200 entries. Where the mod cannot run git (the Desktop app), the model lists the files and the bundle marks them "unverified".

What leaves your session

A hand-off contains your intent, a model-written summary of this conversation, the repo name, the branch, the changed file names (not their contents), and the session id. A broadcast contains only your intent, the repo name, the branch and the session id. A hand-off goes to one other local Claude Code session of yours, a broadcast to as many as you tick, and the model of each recipient reads it. Review the preview before pressing send: the recipients are different conversations, possibly in different projects. A bundle is capped below 1,000,000 characters (the platform limit); an oversized one is trimmed and says so. A preview of each bundle you send also stays in the plugin's store on this machine: see "Sent history", which says what is kept and how to turn it off.

Sent history

Every bundle you send leaves a preview in the plugin's shared store, under history:<session id> (the session the bundle was sent from; after /clear that key keeps the old id). The history is read per project: bare /over shows it under History (h), and /over --history [n] prints it where nothing can be drawn.

  • Kept: the intent (cut to 500 characters), the summary and the instructions (1,000 each), the names of up to 30 changed files (a path longer than 120 characters loses its beginning, so the file name stays), the repository name, the branch, the project's directory, up to 20 targets with their status, its reason and times, and the time of the send.
  • Not kept: the Details section, diffs, file contents, the source session's id and the full text of the bundle.
  • Limits: 50 bundles per session and 14 days (counted from the newest send of the bundle), and at most 8,000 characters per entry: a field that does not fit is shortened, files first, then the instructions and the summary. The oldest entries go first. The store as a whole is pruned as described above (15 days untouched, 3 MiB), never the current session's.
  • The view: the bundles of this project from all of its sessions, so a bundle sent before /clear or from another session of the same repository is there too, newest first, at most 50. A bundle sent again, also after /clear, is one row with all its targets. A target's status follows the same warnings and acknowledgements as /over --status, as long as the sending session still tracks the send; after /clear it keeps the status it had.
  • Turning it off: /config → cc-over.history = off. Nothing new is saved or listed, and a status change is not written. Entries that are already saved are not deleted: they age out with the pruning above.

Bundle format

Every bundle starts with the same six lines. A broadcast differs from a hand-off in line 1 (broadcast instead of hand-off), in one word of line 4 (the type it names), and in line 5, the Handling text: a hand-off asks the recipient to pick up the work, a broadcast says it is a notice, not a task.

  1. [claude-over <hand-off|broadcast> <bundle-id>]
  2. Send: <send-id>: new for every send; the bundle id stays the same
  3. From: <name> · repo <repo> · branch <branch> · session <session-id>
  4. Approved by the user: …
  5. Handling: …: what the recipient does with the work; the text depends on the type
  6. the acknowledgement line: asks the recipient to send over-ack <bundle-id> <send-id> back to the from address of the envelope

After the header, a hand-off has the sections Summary, Instructions, Changed files and Details. A broadcast has one section instead, ## Message, which holds your intent word for word.

What "passed on" and "received" mean

passed on to X (queued) means the platform accepted the message for X. It does not mean X has read it. The mod says received by X (acknowledged) only when X has sent the acknowledgement from line 6 back, and an acknowledgement means "received", not "done". It is best-effort: a recipient may not send it (for example when its own instructions say to ignore incoming messages), and its absence is not an error. The mod takes an exact acknowledgement for its own send without a model turn; a reply with a note added, and every other reply, still reaches your session's model as an ordinary message.

Right after a send the platform may follow up with a warning in your conversation. The held, refused, expired and dropped texts were measured on Claude Code 2.1.302 in the CLI, the released and denied texts on 2.1.310:

WarningMeaningStatus
Cross-session message held by the receiving session (recipient: @X)X holds incoming messages for its userheld
Cross-session message released after approval (recipient: @X)X's user approved a held messagepassed on to X (approved by the recipient, queued)
Cross-session message denied (recipient: @X)X's user declined a held messagenot passed on to X: the recipient's user declined it
Cross-session message refused (recipient: @X)X does not accept messagesnot passed on
Cross-session message expired without approval (recipient: @X)nobody approved it, nothing is waiting in Xexpired
Cross-session message was dropped at the recipient session's inbox (recipient: @X) ...the same text was sent twicenot passed on
an over-ack <bundle-id> <send-id> reply from X (not a platform warning)X has acknowledged this sendreceived by X (acknowledged)

The platform may also report several messages in one line, for example Cross-session messages (2) expired without approval (recipients: @X, @Y); each named target is updated. When two sends to one name are still open, the newest is updated and the line says that it is unclear which one is meant.

The delay ranges from under a second to minutes. The status follows the platform warnings only in an interactive CLI session, only for these texts, and only for sends made by the mod. Text from another version is ignored and the status stays passed on (queued). If the target is a Desktop or Remote Control session, no warning may come back; an acknowledgement can (verified from a Desktop recipient), and the mod takes it in any session, -p included.

A held send can be settled much later, and the outcome may never reach your session (for example when the recipient closes). After 10 minutes without news the status line says so: held: X has not seen it yet, waiting for the recipient's approval (no news since HH:MM; a later outcome may not reach this session). The mod never guesses expired from the time that has passed.

Where to see it. After a send the pane shows one line per send of this bundle, updated on every change, and each change also shows as a toast and a line in your conversation. /over --status prints every send of this session, also in claude -p.

Kept across reloads. Each send is saved in the plugin's shared store under sends:<session id>: the bundle id, the send id, the target's name, the intent cut to 60 characters, the status, its reason and two times; never the bundle text (a preview of the bundle is kept separately, see "Sent history"). The last 50 sends of a session are kept. After the mod reloads, a late warning or acknowledgement still updates the saved send, and /over --status still lists it. Entries are pruned together with the session registry (15 days, 3 MiB), never the current session's.

Known platform behaviour

  • The sender's model wakes up. For each such warning the platform also wakes the sender's model for one turn (about 8 s measured; the cost depends on the model). That model does not know the bundle's text and may offer to send again. The mod cannot turn this off.
  • Every send is unique. The platform silently drops a second identical message to the same recipient. Each send therefore carries a new Send: <id>, while the bundle id stays the same, so sending one bundle again works.
  • Two kinds of "held". A recipient whose permission mode differs from the sender's asks its user to deliver or deny the message; unanswered, it expires after the recipient's dialogExpiry (5 minutes by default). A recipient set to hold every message (crossSessionInbound: hold) asks nothing and keeps it until it exits. The sender cannot tell the two apart, which is why the mod never turns a long held into expired.
  • A busy or waiting recipient. A session that waits for its user (waiting) can be sent to, but may not read the message for a long time.
  • The sending session needs the SendMessage tool. Without it the platform refuses every send and the mod says so.

Limits

  • Only live local sessions of type interactive are listed. Not listed (measured): --bare sessions. Not listed, per the documentation: WSL sessions in the Desktop app, cloud sessions, Remote Control sessions and sessions on other machines.
  • Details (repository, branch, last activity) come only from sessions running cc-over. Other sessions are listed under no details.
  • Sessions without a name show the directory name; /rename a session to recognise it.
  • A hand-off goes to one session; a broadcast goes to as many as you tick.
  • The history lists what you sent, not what you received, and a bundle cannot be sent again from it yet. It holds at most 50 bundles per session, 14 days and 8,000 characters per entry (see "Sent history").
  • The delivery warning may list only some names. When the platform reports a warning for several recipients at once, it can name only the first few of them, so after a large broadcast some recipients may keep the status passed on (queued) although a warning concerned them.
  • Several sends to the same session name share one warning match. A warning counts the messages but names the session once, so only one of those sends changes status. /over --send-repo also skips by session name, so a second session with the same name as one already sent to is skipped too.
  • Switching a draft from a hand-off to a broadcast after /over --send keeps the same bundle. /over --send-repo then skips a session that already received it, and /over --status titles the group from that first send.
  • The Desktop app works as a sender from a marketplace install (verified 2026-10-25, Claude Code 2.1.310) in permission mode Manual. In mode Auto the platform refused the send: The server-side auto mode classifier gave no verdict for SendMessage, and the mod shows not passed on with that reason; switch the session to Manual and send again. Other modes were not tried, and the same behaviour in the CLI was not measured. As a recipient the Desktop app works (verified 2026-10-19): the acknowledgement came back to a CLI sender, while the warnings of the table above may not.

Project structure

cc-over/
├── .claude-plugin/
│   └── marketplace.json           # plugin marketplace "cc-over", points at ./plugin
├── plugin/                  
Source 14 files
hooks/register.ts 1766 lines
1// All `$` use of the mod lives in this file (static analysis: `$` is passed only to
2// top-level functions here). The pure logic is in args.ts, git.ts, sessions.ts, bundle.ts,
3// notice.ts, sendlog.ts, history.ts and report.ts, each with its own unit tests.
4
5import type { Args, EngineInterface, Register, Timer } from "claude-code";
6import { matchAck, parseAck } from "./ack.js";
7import { intentFromField, parseOverArgs } from "./args.js";
8import {
9	type Bundle,
10	type BundleKind,
11	broadcastBundle,
12	forkPrompt,
13	newBundleId,
14	newSendId,
15	noModelContent,
16	parseForkReply,
17	previewModel,
18	renderBundle,
19} from "./bundle.js";
20import {
21	GIT_BRANCH,
22	GIT_HEAD,
23	GIT_ORIGIN_HEAD,
24	GIT_SHORT_HEAD,
25	GIT_STATUS,
26	type GitRun,
27	type GitSummary,
28	gitBranchDiffArgv,
29	gitMergeBaseArgv,
30	gitSessionLogArgv,
31	gitSummary,
32	resolveBaseRef,
33} from "./git.js";
34import {
35	type Grouped,
36	groupHeading,
37	groupSessions,
38	joinRegistry,
39	NO_DETAILS_HEADING,
40	rowLabel,
41	rowParts,
42	type SessionRow,
43	type VisibleRow,
44	visibleRows,
45} from "./groups.js";
46import {
47	applyStatus,
48	entryFromBundle,
49	HISTORY_PREFIX,
50	type HistoryLog,
51	type HistoryView,
52	historyDetail,
53	historyKey,
54	historyPruneItem,
55	historyRow,
56	parseHistoryLog,
57	projectHistory,
58	recordSend,
59	trimLog,
60} from "./history.js";
61import { historySection } from "./history-pane.js";
62import {
63	applyAck,
64	applyNotice,
65	applySendResult,
66	matchNotice,
67	parseDeliveryNotice,
68	type SendRecord,
69	statusLine,
70	truncateIntent,
71} from "./notice.js";
72import {
73	nextEntry,
74	PRUNE_MAX_AGE_MS,
75	PRUNE_MAX_BYTES,
76	PRUNE_SCAN_LIMIT,
77	type PruneItem,
78	parseEntry,
79	projectKey,
80	projectLabel,
81	REGISTRY_PREFIX,
82	type RegistryEntry,
83	type RegistryPatch,
84	registryKey,
85	selectPrunable,
86} from "./registry.js";
87import { bundleLines, statusReport } from "./report.js";
88import {
89	keepListed,
90	type Pick,
91	pickHint,
92	pickOf,
93	preselect,
94	resolvePicks,
95	sendLabel,
96	switchKind,
97	togglePick,
98} from "./selection.js";
99import {
100	mergeRecords,
101	parseSendLog,
102	SENDLOG_PREFIX,
103	SENDS_PER_SESSION,
104	sendLogKey,
105	sendLogPruneItem,
106	upsertSend,
107} from "./sendlog.js";
108import {
109	type ListAgentsResult,
110	type Target,
111	type TargetList,
112	targetsFromListAgents,
113} from "./sessions.js";
114
115type Mode = "fork" | "none";
116
117const COMMAND = "over";
118const COMPOSE_KEY = "cc-over.compose";
119const HISTORY_CONFIG_KEY = "cc-over.history";
120const GIT_TIMEOUT_MS = 10_000;
121const PANE_ID = "over";
122const NOTHING_TO_SEND = "nothing to send: compose a bundle first with /over <intent>";
123
124// Module state, reset in `register` (a hot reload starts clean).
125let currentSessionId: string | undefined;
126let sessionStartedAt: number | undefined;
127let lastComposed: Bundle | undefined;
128/** Newest first; the saved copy of each lives under `sends:<ownerSessionId>` in the store. */
129let sends: SendRecord[] = [];
130/** Settles when the saved sends of the starting session are back in `sends`. */
131let sendsLoaded: Promise<void> = Promise.resolve();
132
133// `intent`: the bare `/over` pane, the list is live and the intent is still to be typed.
134type Phase = "intent" | "composing" | "ready" | "sending" | "sent" | "failed";
135
136// The phases in which a session can be picked and the list refreshed.
137const canChoose = (phase: Phase): boolean =>
138	phase === "intent" || phase === "composing" || phase === "ready";
139
140// What the pane shows. Kept apart from `lastComposed`: that is only the last bundle the user
141// can still send with `--send`, this is the state of one open preview.
142type Draft = {
143	phase: Phase;
144	intent: string;
145	/** What the intent field holds: a redraw hands it back as `value`, so it is not wiped. */
146	typed: string;
147	mode: Mode;
148	bundle?: Bundle;
149	targets: Target[];
150	hiddenCount: number;
151	/** The same sessions as `targets`, joined with the registry and sorted into the two groups. */
152	grouped: Grouped;
153	/** Which groups are folded; 'Other sessions' and the history start folded on every new pane. */
154	collapsed: { this: boolean; other: boolean; history: boolean };
155	/** The project's sent bundles, read when the bare pane opens and on Refresh; unset until then. */
156	history?: HistoryView;
157	/** The `bundleId` whose history entry is open. */
158	historyOpen?: string;
159	/** When the list was read: the ages on the rows are counted from it (the render is synchronous). */
160	now: number;
161	/** The sessions the user picked: one at most while the type is hand-off. */
162	picked: Pick[];
163	/** The type of the bundle this pane previews and sends. */
164	kind: BundleKind;
165	/** One id for the draft, whatever the type: a switch back to hand-off keeps it. */
166	bundleId: string;
167	/** This session's name from the last listing: the `From:` line of a broadcast. */
168	selfName?: string;
169	/** A `runCompose` of this draft is running: a second one must not start beside it. */
170	isInFlight: boolean;
171	/** The same-repo sessions were already ticked once for this draft. */
172	isPreselected: boolean;
173	/** The session list was read at least once: an empty list is then a real answer. */
174	hasListing: boolean;
175	/** Going down to hand-off cleared several picks, so the pane asks for exactly one. */
176	needsOnePick: boolean;
177	/** The hand-off bundle, kept while the type is broadcast so a switch back needs no new fork. */
178	handoff?: Bundle;
179	isExpanded: boolean;
180	error?: string;
181};
182
183let draft: Draft | undefined;
184
185const reasonOf = (error: unknown): string =>
186	error instanceof Error ? error.message : String(error);
187
188// One line per `$.ui.log` call: a newline inside one draws as a glyph.
189function logLines($: EngineInterface, text: string): void {
190	for (const line of text.split("\n")) {
191		if (line.trim() !== "") $.ui.log(line);
192	}
193}
194
195async function configuredMode($: EngineInterface): Promise<Mode> {
196	try {
197		const rows = await $.config.list();
198		return rows.find((row) => row.key === COMPOSE_KEY)?.value === "none" ? "none" : "fork";
199	} catch {
200		return "fork";
201	}
202}
203
204async function listTargets($: EngineInterface): Promise<TargetList> {
205	const called = await $.tool.call({ tool: "ListAgents" });
206	// A refused or failed call still resolves: read as an empty list it would pose as "no sessions".
207	if (called.deny !== undefined) throw new Error(called.deny);
208	if (called.isError) throw new Error(called.text ?? "ListAgents failed");
209	return targetsFromListAgents((called.result ?? {}) as ListAgentsResult);
210}
211
212// The entries other sessions (and this one) keep in the shared store. A store that cannot be read
213// costs the rows their details, not the list: they fall back to 'no details'.
214async function readRegistry($: EngineInterface): Promise<RegistryEntry[]> {
215	try {
216		const keys = (await $.store.keys())
217			.filter((key) => key.startsWith(REGISTRY_PREFIX))
218			.slice(0, PRUNE_SCAN_LIMIT);
219		const entries: RegistryEntry[] = [];
220		for (const key of keys) {
221			const parsed = parseEntry(await $.store.get(key));
222			if (parsed !== null && registryKey(parsed.sessionId) === key) entries.push(parsed);
223		}
224		return entries;
225	} catch (error) {
226		reportRegistryError($, "read", error);
227		return [];
228	}
229}
230
231type Listing = { list: TargetList; grouped: Grouped; now: number };
232
233// The platform's list stays the only source of who is alive; the registry only adds details to
234// its rows, so an entry of a closed session never reaches the pane. `known` is a list the caller
235// already fetched.
236async function listGrouped($: EngineInterface, known?: TargetList): Promise<Listing> {
237	const list = known ?? (await listTargets($));
238	const entries = await readRegistry($);
239	const place = await selfPlace($);
240	const rows = joinRegistry(list.targets, entries);
241	return {
242		list,
243		grouped: groupSessions(rows, { key: projectKey(place), label: projectLabel(place) }),
244		now: await $.clock.now(),
245	};
246}
247
248// A rejection means git could not start (or timed out): the caller treats it as "no git".
249async function runGit(
250	$: EngineInterface,
251	argv: readonly string[],
252	cwd: string,
253): Promise<GitRun | undefined> {
254	try {
255		const run = await $.process.run(argv, { cwd, timeoutMs: GIT_TIMEOUT_MS });
256		return { exitCode: run.exitCode, stdout: run.stdout };
257	} catch {
258		return undefined;
259	}
260}
261
262async function gatherGit($: EngineInterface, cwd: string): Promise<GitSummary | undefined> {
263	const [status, branch, shortHead, head, originHead] = await Promise.all([
264		runGit($, GIT_STATUS, cwd),
265		runGit($, GIT_BRANCH, cwd),
266		runGit($, GIT_SHORT_HEAD, cwd),
267		runGit($, GIT_HEAD, cwd),
268		runGit($, GIT_ORIGIN_HEAD, cwd),
269	]);
270	if (status === undefined || branch === undefined) return undefined;
271	const baseRef = resolveBaseRef(originHead);
272	const [mergeBase, branchDiff, sessionLog] = await Promise.all([
273		runGit($, gitMergeBaseArgv(baseRef), cwd),
274		runGit($, gitBranchDiffArgv(baseRef), cwd),
275		sessionStartedAt === undefined
276			? Promise.resolve(undefined)
277			: runGit($, gitSessionLogArgv(Math.floor(sessionStartedAt / 1000)), cwd),
278	]);
279	return gitSummary({
280		status,
281		branch,
282		baseRef,
283		...(shortHead ? { shortHead } : {}),
284		...(head ? { head } : {}),
285		...(mergeBase ? { mergeBase } : {}),
286		...(branchDiff ? { branchDiff } : {}),
287		...(sessionLog ? { sessionLog } : {}),
288	});
289}
290
291// ---- the registry of sessions running this mod ----
292
293// Each session keeps one `session:<id>` entry about itself in the shared `$.store`; the pane joins
294// them to the platform's list. `$.store` get+set is not atomic and every session of every project
295// writes the same file, so each read-modify-write of ours goes through one chain (pattern of
296// compaction-keeper's `updateRecord`): a stale save could otherwise undo a newer one.
297const REGISTRY_WAIT_MS = 2_000;
298let registryWrites: Promise<void> = Promise.resolve();
299let registryUnitsInFlight = 0;
300/** The store subjects ("session registry", "send status") whose first failure was already shown. */
301const reportedStoreErrors = new Set<string>();
302
303// Per subject, the first failure of the module's life is visible, the later ones go to the debug log.
304function reportStoreError($: EngineInterface, subject: string, what: string, error: unknown): void {
305	const text = `${subject}: ${what} failed (${reasonOf(error)})`;
306	if (reportedStoreErrors.has(subject)) $.ui.log(text, { to: "debug" });
307	else $.ui.log(text);
308	reportedStoreErrors.add(subject);
309}
310
311function reportRegistryError($: EngineInterface, what: string, error: unknown): void {
312	reportStoreError($, "session registry", what, error);
313}
314
315// Resolves when `predecessor` settles, or after REGISTRY_WAIT_MS: a store write that never settles
316// must not hold up every later unit.
317async function waitForRegistryWrites(
318	$: EngineInterface,
319	predecessor: Promise<unknown>,
320): Promise<void> {
321	const held: { timer?: Timer } = {};
322	const timedOut = new Promise<void>((resolve) => {
323		held.timer = $.clock.after(REGISTRY_WAIT_MS, () => resolve());
324	});
325	await Promise.race([predecessor, timedOut]);
326	held.timer?.cancel();
327}
328
329// A unit is exactly one load-change-save; no ask, toast or other unit goes inside (that would
330// deadlock the chain). A failed unit rejects to its caller and does not break the chain.
331async function chained<T>($: EngineInterface, unit: () => Promise<T>): Promise<T> {
332	const queued = registryUnitsInFlight > 0;
333	const predecessor = registryWrites;
334	registryUnitsInFlight += 1;
335	const run = async (): Promise<T> => {
336		try {
337			if (queued) await waitForRegistryWrites($, predecessor);
338			return await unit();
339		} finally {
340			registryUnitsInFlight -= 1;
341		}
342	};
343	const result = run();
344	registryWrites = result.then(
345		() => undefined,
346		() => undefined,
347	);
348	return result;
349}
350
351// `touch` stamps the entry's last activity with the time of the write.
352async function updateRegistry(
353	$: EngineInterface,
354	sessionId: string,
355	patch: Omit<RegistryPatch, "sessionId">,
356	o: { touch: boolean },
357): Promise<void> {
358	await chained($, async () => {
359		const key = registryKey(sessionId);
360		const previous = parseEntry(await $.store.get(key));
361		const now = await $.clock.now();
362		const next = nextEntry(
363			previous,
364			{ ...patch, sessionId, ...(o.touch ? { lastActivityAt: now } : {}) },
365			now,
366		);
367		if (next !== null) await $.store.set(key, next);
368	});
369}
370
371// A branch name, or undefined on a detached HEAD or where git cannot say.
372async function currentBranch($: EngineInterface, cwd: string): Promise<string | undefined> {
373	const run = await runGit($, GIT_BRANCH, cwd);
374	const name = run?.exitCode === 0 ? run.stdout.trim() : "";
375	return name === "" || name === "HEAD" ? undefined : name;
376}
377
378async function selfSessionId($: EngineInterface): Promise<string> {
379	return currentSessionId ?? (await $.session.id());
380}
381
382async function selfPlace(
383	$: EngineInterface,
384): Promise<{ root: string; repoRoot: string | undefined }> {
385	const root = await $.session.root();
386	const repo = await $.session.repo();
387	return { root, repoRoot: repo?.root };
388}
389
390// The full entry: where the session is and who the platform's list says it is. The name and `ref`
391// come from the list header; where ListAgents cannot be called (a `-p` run at `classic.SessionStart`)
392// the entry is written without them and the next full write fills them in.
393async function writeSelfEntry(
394	$: EngineInterface,
395	sessionId: string,
396	known?: TargetList,
397): Promise<void> {
398	const place = await selfPlace($);
399	const branch = await currentBranch($, place.root);
400	let identity: { name?: string; ref?: string } = {};
401	try {
402		const list = known ?? (await listTargets($));
403		identity = {
404			...(list.selfName ? { name: list.selfName } : {}),
405			...(list.selfRef ? { ref: list.selfRef } : {}),
406		};
407	} catch (error) {
408		$.ui.log(`session registry: no list identity (${reasonOf(error)})`, { to: "debug" });
409	}
410	await updateRegistry($, sessionId, { ...place, branch, ...identity }, { touch: true });
411}
412
413// Opening the pane refreshes this session's entry with the list it has just fetched.
414async function refreshSelfEntry($: EngineInterface, list: TargetList): Promise<void> {
415	try {
416		await writeSelfEntry($, await selfSessionId($), list);
417	} catch (error) {
418		reportRegistryError($, "write", error);
419	}
420}
421
422// Up to PRUNE_SCAN_LIMIT keys with this prefix, as prune items; a value `toItem` cannot read
423// (junk, another version, a log filed under another session's key) gives none.
424async function scanPrefix(
425	$: EngineInterface,
426	keys: readonly string[],
427	prefix: string,
428	toItem: (key: string, value: unknown, size: number) => PruneItem | null,
429): Promise<PruneItem[]> {
430	const items: PruneItem[] = [];
431	for (const key of keys
432		.filter((candidate) => candidate.startsWith(prefix))
433		.slice(0, PRUNE_SCAN_LIMIT)) {
434		const value = await $.store.get(key);
435		if (value === undefined) continue;
436		const item = toItem(key, value, JSON.stringify(value).length);
437		if (item !== null) items.push(item);
438	}
439	return items;
440}
441
442const registryPruneItem = (key: string, value: unknown, size: number): PruneItem | null => {
443	const parsed = parseEntry(value);
444	if (parsed === null || registryKey(parsed.sessionId) !== key) return null;
445	return { key, sessionId: parsed.sessionId, updatedAt: parsed.updatedAt, size };
446};
447
448const storedSendLogPruneItem = (key: string, value: unknown, size: number): PruneItem | null => {
449	const log = parseSendLog(value);
450	return log === null || sendLogKey(log.sessionId) !== key
451		? null
452		: sendLogPruneItem(key, log, size);
453};
454
455const storedHistoryPruneItem = (key: string, value: unknown, size: number): PruneItem | null => {
456	const log = parseHistoryLog(value);
457	return log === null || historyKey(log.sessionId) !== key
458		? null
459		: historyPruneItem(key, log, size);
460};
461
462// One pass over the registry, the send logs and the history, so the size limit counts all of this
463// mod's data. Drops what was untouched for long and, past the size limit, the oldest; never the
464// current session and never a key whose value this mod cannot read (other data is not ours to delete).
465async function pruneStore($: EngineInterface, keepSessionId: string): Promise<void> {
466	await chained($, async () => {
467		const keys = await $.store.keys();
468		const items = [
469			...(await scanPrefix($, keys, REGISTRY_PREFIX, registryPruneItem)),
470			...(await scanPrefix($, keys, SENDLOG_PREFIX, storedSendLogPruneItem)),
471			...(await scanPrefix($, keys, HISTORY_PREFIX, storedHistoryPruneItem)),
472		];
473		const doomed = selectPrunable(items, await $.clock.now(), {
474			maxAgeMs: PRUNE_MAX_AGE_MS,
475			maxBytes: PRUNE_MAX_BYTES,
476			keepSessionId,
477		});
478		for (const key of doomed) await $.store.delete(key);
479	});
480}
481
482// Never throws: a registry that cannot be written costs the pane its details, not the session.
483async function registerSession(
484	$: EngineInterface,
485	sessionId: string,
486	o: { prune: boolean },
487): Promise<void> {
488	currentSessionId = sessionId;
489	try {
490		await writeSelfEntry($, sessionId);
491		if (o.prune) await pruneStore($, sessionId);
492	} catch (error) {
493		reportRegistryError($, "write", error);
494	}
495}
496
497// ---- the saved sends ----
498
499// One status change saved to the log of the session the send left from (after /clear that is not
500// the current one). Detached by its callers: a send's result and its toast never wait on the store.
501// Never throws.
502async function persistSend($: EngineInterface, record: SendRecord): Promise<void> {
503	try {
504		await chained($, async () => {
505			const key = sendLogKey(record.ownerSessionId);
506			const previous = parseSendLog(await $.store.get(key));
507			await $.store.set(key, upsertSend(previous, record, await $.clock.now()));
508		});
509	} catch (error) {
510		reportStoreError($, "send status", "save", error);
511	}
512}
513
514// Brings back what an earlier process, or this mod before a reload, saved for the session. The
515// copy in memory wins: it can hold a change whose save is still on the chain. Never throws.
516async function loadSends($: EngineInterface, sessionId: string): Promise<void> {
517	try {
518		const log = parseSendLog(await $.store.get(sendLogKey(sessionId)));
519		if (log !== null && log.sessionId === sessionId) sends = mergeRecords(sends, log.sends);
520	} catch (error) {
521		reportStoreError($, "send status", "load", error);
522	}
523}
524
525// ---- the history of sent bundles ----
526
527// Off only when the `/config` row says so. A config that cannot be read keeps the default (on).
528async function historyEnabled($: EngineInterface): Promise<boolean> {
529	try {
530		const rows = await $.config.list();
531		const value = rows.find((row) => row.key === HISTORY_CONFIG_KEY)?.value;
532		return value !== false && value !== "false" && value !== "off";
533	} catch {
534		return true;
535	}
536}
537
538// Each of the three writers enters `chained` before its first await, so in one process a status
539// update always queues behind the save that creates its entry. Detached by the callers, never throws.
540async function persistHistory(
541	$: EngineInterface,
542	bundle: Bundle,
543	record: SendRecord,
544): Promise<void> {
545	try {
546		await chained($, async () => {
547			if (!(await historyEnabled($))) return;
548			const project = projectKey(await selfPlace($));
549			const key = historyKey(record.ownerSessionId);
550			const previous = parseHistoryLog(await $.store.get(key));
551			const now = await $.clock.now();
552			const entry = entryFromBundle(bundle, project, record);
553			await $.store.set(key, trimLog(recordSend(previous, entry, record, now), now));
554		});
555	} catch (error) {
556		reportStoreError($, "history", "save", error);
557	}
558}
559
560// A status change for a bundle that has no entry (sent with the history off, or already trimmed)
561// writes nothing. The same 14-day cut as a send: a late warning must not leave an old entry in
562// the key, and it must not keep that key looking freshly touched with the old row still inside.
563async function persistHistoryStatus($: EngineInterface, record: SendRecord): Promise<void> {
564	try {
565		await chained($, async () => {
566			if (!(await historyEnabled($))) return;
567			const key = historyKey(record.ownerSessionId);
568			const previous = parseHistoryLog(await $.store.get(key));
569			const now = await $.clock.now();
570			const next = applyStatus(previous, record, now);
571			if (next !== null && next !== previous) await $.store.set(key, trimLog(next, now));
572		});
573	} catch (error) {
574		reportStoreError($, "history", "save", error);
575	}
576}
577
578// The rows of this project from every session's key, behind the pending writes, so a history read
579// right after a send sees it. Never throws: the pane and the command show the reason instead.
580async function readHistory($: EngineInterface): Promise<HistoryView> {
581	try {
582		return await chained($, async (): Promise<HistoryView> => {
583			if (!(await historyEnabled($))) return { state: "off" };
584			const place = await selfPlace($);
585			const keys = (await $.store.keys())
586				.filter((key) => key.startsWith(HISTORY_PREFIX))
587				.slice(0, PRUNE_SCAN_LIMIT);
588			const logs: HistoryLog[] = [];
589			for (const key of keys) {
590				const log = parseHistoryLog(await $.store.get(key));
591				if (log !== null && historyKey(log.sessionId) === key) logs.push(log);
592			}
593			const now = await $.clock.now();
594			return {
595				state: "ready",
596				entries: projectHistory(logs, projectKey(place), now),
597				project: projectLabel(place),
598				now,
599			};
600		});
601	} catch (error) {
602		return { state: "error", reason: reasonOf(error) };
603	}
604}
605
606// A turn only moves the last-activity stamp: no git, no ListAgents on the turn's path.
607async function touchSession($: EngineInterface): Promise<void> {
608	try {
609		await updateRegistry($, await selfSessionId($), {}, { touch: true });
610	} catch (error) {
611		reportRegistryError($, "update", error);
612	}
613}
614
615type Content = {
616	summary: string;
617	instructions: string;
618	details: string;
619	files?: string;
620	note?: string;
621};
622
623// The conversation part of the bundle: the fork's reply, or the intent alone with the reason.
624async function composeContent(
625	$: EngineInterface,
626	intent: string,
627	mode: Mode,
628	hasGit: boolean,
629): Promise<Content> {
630	if (mode === "none") return noModelContent(intent, "none mode");
631	const forked = await $.model.fork({ prompt: forkPrompt(intent, { filesFromModel: !hasGit }) });
632	if (!forked.isAnswered) {
633		const reason =
634			forked.reason === "api-error" ? `api-error ${forked.status ?? "?"}` : forked.reason;
635		return noModelContent(intent, reason);
636	}
637	const reply = parseForkReply(forked.text);
638	return {
639		summary: reply.summary,
640		instructions: reply.instructions,
641		details: reply.details,
642		...(reply.files ? { files: reply.files } : {}),
643	};
644}
645
646const repoNameOf = (root: string): string => root.split(/[\\/]/).filter(Boolean).pop() ?? root;
647
648const freshBundleId = async ($: EngineInterface): Promise<string> =>
649	newBundleId(await $.clock.now(), Math.random());
650
651// `bundleId` keeps the id of a draft that returns to hand-off; without it the bundle gets a new one.
652async function composeBundle(
653	$: EngineInterface,
654	intent: string,
655	mode: Mode,
656	selfName: string | undefined,
657	bundleId?: string,
658): Promise<Bundle> {
659	const root = await $.session.root();
660	const git = await gatherGit($, root);
661	const content = await composeContent($, intent, mode, git !== undefined);
662	const sessionId = await selfSessionId($);
663	return {
664		bundleId: bundleId ?? (await freshBundleId($)),
665		kind: "hand-off",
666		intent,
667		...content,
668		...(git ? { git } : {}),
669		source: {
670			...(selfName ? { name: selfName } : {}),
671			repo: repoNameOf(root),
672			branch: git?.branch ?? "unknown",
673			sessionId,
674		},
675	};
676}
677
678// A broadcast is the intent and its source: no model, no files, so only the branch is read, with
679// one git call (a failure says `unknown`). It is ready as fast as the list.
680async function composeBroadcast($: EngineInterface, d: Draft): Promise<Bundle> {
681	const root = await $.session.root();
682	const run = await runGit($, GIT_BRANCH, root);
683	const name = run?.exitCode === 0 ? run.stdout.trim() : "";
684	return broadcastBundle({
685		bundleId: d.bundleId,
686		intent: d.intent,
687		source: {
688			...(d.selfName ? { name: d.selfName } : {}),
689			repo: repoNameOf(root),
690			branch: name === "" ? "unknown" : name === "HEAD" ? "detached HEAD" : name,
691			sessionId: await selfSessionId($),
692		},
693	});
694}
695
696function logPreview($: EngineInterface, bundle: Bundle): void {
697	const preview = previewModel(bundle);
698	logLines($, preview.meta);
699	logLines($, preview.full);
700}
701
702// Reads the session list into the draft and redraws, so the pane has the sessions early.
703async function loadListing($: EngineInterface, d: Draft): Promise<TargetList> {
704	const list = await listTargets($);
705	applyListing(d, await listGrouped($, list));
706	$.ui.invalidate("ui.render");
707	await refreshSelfEntry($, list);
708	return list;
709}
710
711// Lists the sessions first (about 100 ms, so the pane has them early), then composes the bundle of
712// the draft's type: a broadcast at once, a hand-off through the fork. The type can change while
713// this runs (the pane's `y`), so each step reads `d.kind` again after its await, and a result is
714// written to the pane only while the draft is still `composing`: a broadcast the user switched to
715// is already `ready` (and may be sent), and a late fork then lands in `d.handoff` only.
716// Never throws: a failure of a draft that is `composing` ends in the `failed` phase. A draft that
717// was cancelled meanwhile no longer owns `lastComposed`, so a bundle the user dismissed cannot be
718// sent with `--send`.
719async function runCompose($: EngineInterface, d: Draft): Promise<void> {
720	d.isInFlight = true;
721	try {
722		const list = await loadListing($, d);
723		for (;;) {
724			if (d.kind === "broadcast") {
725				const broadcast = await composeBroadcast($, d);
726				// Switched to hand-off meanwhile: the loop goes on to the fork.
727				if (d.kind !== "broadcast") continue;
728				if (d.phase === "composing") {
729					d.bundle = broadcast;
730					d.phase = "ready";
731				}
732			} else {
733				const handoff =
734					d.handoff ?? (await composeBundle($, d.intent, d.mode, list.selfName, d.bundleId));
735				d.handoff = handoff;
736				if (d.kind === "hand-off" && d.phase === "composing") {
737					d.bundle = handoff;
738					d.phase = "ready";
739				}
740			}
741			break;
742		}
743		if (draft === d) lastComposed = d.bundle;
744	} catch (error) {
745		if (d.phase === "composing") {
746			d.phase = "failed";
747			d.error = `could not compose the bundle: ${reasonOf(error)}`;
748			if (draft === d) stopRefresh();
749		} else {
750			$.ui.log(`could not compose the bundle: ${reasonOf(error)}`, { to: "debug" });
751		}
752	} finally {
753		d.isInFlight = false;
754	}
755	if (draft === d) $.ui.invalidate("ui.render");
756}
757
758// Takes a fresh listing into the draft. A pick that is no longer on the list is dropped, so
759// `send` cannot target a session that is gone. The same-repo sessions are ticked once, with the
760// first list a broadcast draft sees; every later list only removes.
761function applyListing(d: Draft, listing: Listing): void {
762	d.targets = listing.list.targets;
763	d.hiddenCount = listing.list.hiddenCount;
764	d.grouped = listing.grouped;
765	d.now = listing.now;
766	d.selfName = listing.list.selfName;
767	d.hasListing = true;
768	d.picked = keepListed(d.picked, d.targets);
769	if (d.kind === "broadcast" && !d.isPreselected) {
770		const next = switchKind(d.picked, "broadcast", {
771			preselected: preselect(d.grouped),
772			isPreselected: d.isPreselected,
773		});
774		d.picked = next.picked;
775		d.isPreselected = next.isPreselected;
776		d.needsOnePick = next.needsOnePick;
777	}
778}
779
780// ---- the refresh timer ----
781
782const REFRESH_MS = 10_000;
783let refreshTimer: Timer | undefined;
784let isRefreshing = false;
785
786function stopRefresh(): void {
787	refreshTimer?.cancel();
788	refreshTimer = undefined;
789}
790
791// Runs only while the preview this timer was started for is the open one and can still choose a
792// session; any other state ends the timer, so a closed pane costs no ListAgents call.
793async function refreshTick($: EngineInterface, d: Draft): Promise<void> {
794	if (draft !== d || d.phase === "sent" || d.phase === "failed") {
795		stopRefresh();
796		return;
797	}
798	try {
799		await refreshTargets($, d);
800		if (draft === d) $.ui.invalidate("ui.render");
801	} catch (error) {
802		$.ui.log(`list refresh failed (${reasonOf(error)})`, { to: "debug" });
803	}
804}
805
806function startRefresh($: EngineInterface, d: Draft): void {
807	stopRefresh();
808	refreshTimer = $.clock.every(REFRESH_MS, () => void refreshTick($, d));
809}
810
811const kindName = (kind: BundleKind): string => (kind === "broadcast" ? "Broadcast" : "Hand-off");
812
813// A pane opens only where a surface draws one; the reason it did not is reported, never lost.
814async function openPane(
815	$: EngineInterface,
816	kind: BundleKind,
817): Promise<{ isPlaced: boolean; reason?: string }> {
818	const surfaces = await $.session.surfaces();
819	if (!surfaces.some((surface) => surface === "terminal" || surface === "desktop")) {
820		return { isPlaced: false };
821	}
822	try {
823		const opened = await $.ui.open({
824			id: PANE_ID,
825			title: kindName(kind),
826			focus: true,
827			closeOnEscape: true,
828		});
829		return opened.isPlaced ? { isPlaced: true } : { isPlaced: false, reason: opened.reason };
830	} catch (error) {
831		return { isPlaced: false, reason: reasonOf(error) };
832	}
833}
834
835// Opening an open id retitles it. Every open sets `closeOnEscape` anew, so it is passed again;
836// `focus` is left out, the keys stay where they are.
837async function retitlePane($: EngineInterface, kind: BundleKind): Promise<void> {
838	try {
839		await $.ui.open({ id: PANE_ID, title: kindName(kind), closeOnEscape: true });
840	} catch (error) {
841		$.ui.log(`the pane title did not change (${reasonOf(error)})`, { to: "debug" });
842	}
843}
844
845function newDraft(
846	phase: Phase,
847	intent: string,
848	mode: Mode,
849	bundleId: string,
850	kind: BundleKind,
851): Draft {
852	return {
853		phase,
854		intent,
855		typed: "",
856		mode,
857		targets: [],
858		hiddenCount: 0,
859		grouped: groupSessions([], { key: "", label: "" }),
860		collapsed: { this: false, other: true, history: true },
861		now: 0,
862		picked: [],
863		kind,
864		bundleId,
865		isInFlight: false,
866		isPreselected: false,
867		hasListing: false,
868		needsOnePick: false,
869		isExpanded: false,
870	};
871}
872
873// The list for a pane that has no intent yet. A failure is shown in the pane and the phase stays,
874// so the user can still type, or press Refresh.
875async function readSessions($: EngineInterface, d: Draft): Promise<void> {
876	try {
877		await loadListing($, d);
878		if (draft === d && d.phase === "intent") delete d.error;
879	} catch (error) {
880		if (draft === d && d.phase === "intent") {
881			d.error = `could not read the session list: ${reasonOf(error)}`;
882		}
883	}
884	if (draft === d) $.ui.invalidate("ui.render");
885}
886
887// Reads the project's history into the draft and redraws. Only the bare pane shows it, and only a
888// press of Refresh or the opening reads it: no store reads on the refresh timer's ticks.
889async function readHistoryInto($: EngineInterface, d: Draft): Promise<void> {
890	const history = await readHistory($);
891	if (draft !== d) return;
892	d.history = history;
893	$.ui.invalidate("ui.render");
894}
895
896// Bare `/over`: the pane opens with the field and the list, and nothing is composed before Enter
897// (`submit` in `paneAction`). Without a pane there is nothing to type into, so it says how to give
898// the intent instead; the list is not read then.
899async function panelCommand($: EngineInterface): Promise<void> {
900	const d = newDraft("intent", "", await configuredMode($), await freshBundleId($), "hand-off");
901	draft = d;
902	const pane = await openPane($, d.kind);
903	if (pane.isPlaced) {
904		startRefresh($, d);
905		void readSessions($, d);
906		void readHistoryInto($, d);
907		return;
908	}
909	if (draft === d) draft = undefined;
910	if (pane.reason !== undefined) $.ui.log(`the pane did not open (${pane.reason})`);
911	usage($, "no pane here: give the intent as an argument");
912}
913
914// With a pane the command returns at once and the composing runs detached (measured in
915// phase 1, E4: `$` keeps working after `command.run` returned). Without one (`-p`, SDK) the
916// compose is awaited, because the process ends with the command, and the preview is logged.
917async function composeCommand(
918	$: EngineInterface,
919	intent: string,
920	noFork: boolean,
921	kind: BundleKind,
922): Promise<void> {
923	const mode: Mode = noFork ? "none" : await configuredMode($);
924	const d = newDraft("composing", intent, mode, await freshBundleId($), kind);
925	draft = d;
926	const pane = await openPane($, kind);
927	if (pane.isPlaced) {
928		startRefresh($, d);
929		void runCompose($, d);
930		return;
931	}
932	if (pane.reason !== undefined) {
933		$.ui.log(`the pane did not open (${pane.reason}): preview in the log`);
934	}
935	await runCompose($, d);
936	if (d.bundle) logPreview($, d.bundle);
937	else $.ui.log(d.error ?? "could not compose the bundle");
938	if (draft === d) draft = undefined;
939}
940
941// A fresh send id on every call: the platform silently drops a second identical message to
942// the same recipient (evidence E2), and the bundle id stays the same across re-sends.
943async function deliver($: EngineInterface, bundle: Bundle, target: Target): Promise<SendRecord> {
944	const sentAt = await $.clock.now();
945	const sendId = newSendId(Math.random());
946	const result = await $.session.send({
947		to: target.address,
948		text: renderBundle(bundle, sendId),
949	});
950	const record = applySendResult(
951		{
952			bundleId: bundle.bundleId,
953			kind: bundle.kind,
954			sendId,
955			target: target.name,
956			sentAt,
957			status: "passed-on",
958			intent: truncateIntent(bundle.intent),
959			updatedAt: sentAt,
960			ownerSessionId: await selfSessionId($),
961		},
962		result,
963	);
964	sends = [record, ...sends].slice(0, SENDS_PER_SESSION);
965	void persistSend($, record);
966	void persistHistory($, bundle, record);
967	$.ui.log(statusLine(record, false), { to: "transcript" });
968	// A second target sent with --send while the pane is open joins the pane's block of sends.
969	$.ui.invalidate("ui.render");
970	return record;
971}
972
973async function listCommand($: EngineInterface): Promise<void> {
974	const { list, grouped, now } = await listGrouped($);
975	const line = (row: SessionRow) => $.ui.log(`● ${rowParts(row, now).join(" · ")}`);
976	if (list.targets.length === 0) $.ui.log("no live local sessions");
977	else {
978		$.ui.log(`This project: ${grouped.thisProject.label}`);
979		if (grouped.thisProject.rows.length === 0) $.ui.log("● (no other session in this project)");
980		for (const row of grouped.thisProject.rows) line(row);
981		for (const repo of grouped.other.repos) {
982			$.ui.log(`Other sessions: ${repo.label}`);
983			for (const row of repo.rows) line(row);
984		}
985		if (grouped.other.noDetails.length > 0) {
986			$.ui.log(`Other sessions: ${NO_DETAILS_HEADING}`);
987			for (const row of grouped.other.noDetails) line(row);
988		}
989	}
990	if (list.hiddenCount > 0) {
991		$.ui.log(`hidden: ${list.hiddenCount} (not a local interactive session)`);
992	}
993}
994
995// The target must be on a fresh list: never a session the user did not name, never a dead one.
996async function sendCommand($: EngineInterface, wanted: string): Promise<void> {
997	if (lastComposed === undefined) {
998		$.ui.log(NOTHING_TO_SEND);
999		return;
1000	}
1001	const { targets } = await listTargets($);
1002	const matches = targets.filter(
1003		(target) =>
1004			target.address === wanted ||
1005			target.name === wanted ||
1006			(target.ref !== undefined && `${target.name} [${target.ref}]` === wanted),
1007	);
1008	const [target] = matches;
1009	if (target === undefined) {
1010		$.ui.log(
1011			`not passed on: no session named ${wanted} on the list of local sessions (see /over --list)`,
1012		);
1013		return;
1014	}
1015	if (matches.length > 1) {
1016		const options = matches.map((match) => `${match.name} [${match.ref}]`).join(" or ");
1017		$.ui.log(`several sessions are named ${wanted}: use ${options}`);
1018		return;
1019	}
1020	await deliver($, lastComposed, target);
1021}
1022
1023// `-p` has no pane to tick the project's sessions in: this sends the composed broadcast to all of
1024// them. A target that already has a record of this bundle is skipped, so a repeat (or a `--send`
1025// before it) does not message anyone twice. The record holds the bare name, so another session
1026// with the same name is skipped too.
1027async function sendRepoCommand($: EngineInterface): Promise<void> {
1028	const bundle = lastComposed;
1029	if (bundle === undefined) {
1030		$.ui.log(NOTHING_TO_SEND);
1031		return;
1032	}
1033	if (bundle.kind !== "broadcast") {
1034		$.ui.log("--send-repo sends a broadcast; compose one with /over --broadcast <intent>");
1035		return;
1036	}
1037	const { list, grouped } = await listGrouped($);
1038	const { targets: sameRepo } = resolvePicks(preselect(grouped), list.targets);
1039	if (sameRepo.length === 0) {
1040		$.ui.log("no other sessions of this repo on the list");
1041		return;
1042	}
1043	const sentTo = new Set(
1044		sends.filter((record) => record.bundleId === bundle.bundleId).map((record) => record.target),
1045	);
1046	const targets: Target[] = [];
1047	for (const target of sameRepo) {
1048		if (sentTo.has(target.name)) $.ui.log(`already sent to ${target.name}`);
1049		else targets.push(target);
1050	}
1051	if (targets.length === 0) return;
1052	try {
1053		const records = await deliverAll($, bundle, targets);
1054		$.ui.log(broadcastSummary(records));
1055	} catch (error) {
1056		$.ui.log(`send failed: ${reasonOf(error)}`);
1057	}
1058}
1059
1060// One after the other, in the order given: not measured whether the engine serializes parallel
1061// sends, and the status lines then come out in the order of the picks. A throw leaves the records
1062// of the targets already sent in `sends`.
1063async function deliverAll(
1064	$: EngineInterface,
1065	bundle: Bundle,
1066	targets: readonly Target[],
1067): Promise<SendRecord[]> {
1068	const records: SendRecord[] = [];
1069	for (const target of targets) records.push(await deliver($, bundle, target));
1070	return records;
1071}
1072
1073const broadcastSummary = (records: readonly SendRecord[]): string =>
1074	`broadcast passed on to ${records.filter((record) => record.status === "passed-on").length} of ${records.length} sessions`;
1075
1076// The pane's status for -p, where nothing is drawn: every send this session knows, the saved ones
1077// included, grouped by bundle. It waits for the load, which a fresh `-p` process may still be doing.
1078async function statusCommand($: EngineInterface): Promise<void> {
1079	await sendsLoaded;
1080	logLines($, statusReport(sends, await $.clock.now()).join("\n"));
1081}
1082
1083// The pane's history section for -p: the same rows with numbers, or one whole entry by its number.
1084async function historyCommand($: EngineInterface, index?: number): Promise<void> {
1085	const view = await readHistory($);
1086	if (view.state === "off") {
1087		$.ui.log("history is off: turn it on in /config (cc-over.history)");
1088		return;
1089	}
1090	if (view.state === "error") {
1091		$.ui.log(`could not read the history (${view.reason})`);
1092		return;
1093	}
1094	const { entries, project, now } = view;
1095	if (index !== undefined) {
1096		const entry = entries[index - 1];
1097		if (entry === undefined) $.ui.log(`no history entry ${index} (there are ${entries.length})`);
1098		else logLines($, historyDetail(entry, now));
1099		return;
1100	}
1101	if (entries.length === 0) {
1102		$.ui.log("no bundles sent from this project in the last 14 days");
1103		return;
1104	}
1105	$.ui.log(`History: ${project} (${entries.length})`);
1106	for (const [i, entry] of entries.entries()) $.ui.log(`${i + 1}. ${historyRow(entry, now)}`);
1107}
1108
1109function usage($: EngineInterface, reason?: string): void {
1110	const text =
1111		"Usage: /over <intent> · /over --no-fork <intent> · /over --broadcast <intent> · /over --list · /over --send <session> · /over --send-repo · /over --status · /over --history [n]";
1112	$.ui.log(reason ? `${text} (${reason})` : text);
1113}
1114
1115// ---- the pane ----
1116
1117type PaneAction =
1118	| { kind: "select"; key: string }
1119	| { kind: "toggle"; group: "this" | "other" | "history" }
1120	| { kind: "openHistory"; bundleId: string }
1121	| { kind: "submit"; text: string }
1122	| { kind: "expand" | "refresh" | "send" | "close" | "type" };
1123
1124async function refreshTargets($: EngineInterface, d: Draft): Promise<void> {
1125	if (isRefreshing || !canChoose(d.phase)) return;
1126	isRefreshing = true;
1127	try {
1128		const listing = await listGrouped($);
1129		if (draft === d && canChoose(d.phase)) {
1130			applyListing(d, listing);
1131			if (d.phase === "intent" && d.error?.startsWith("could not read the session list")) {
1132				delete d.error;
1133			}
1134		}
1135	} finally {
1136		isRefreshing = false;
1137	}
1138}
1139
1140// The state goes to `sending` before the first await, so a second press of the same button
1141// (a Button has no `disabled`) finds a draft that is no longer `ready` and does nothing.
1142async function sendDraft($: EngineInterface, d: Draft): Promise<void> {
1143	const { bundle } = d;
1144	if (
1145		d.phase !== "ready" ||
1146		bundle === undefined ||
1147		pickHint(d.picked, d.kind, d.needsOnePick) !== undefined
1148	) {
1149		return;
1150	}
1151	// What the user picked, before the fresh list takes away the sessions that are gone.
1152	const picks = d.picked;
1153	d.phase = "sending";
1154	delete d.error;
1155	$.ui.invalidate("ui.render");
1156	try {
1157		const listing = await listGrouped($);
1158		applyListing(d, listing);
1159		const { targets, problems } = resolvePicks(picks, listing.list.targets);
1160		// Any problem stops the whole send: a hand-off to fewer sessions than picked, or a broadcast
1161		// that skipped one silently, is not what the user confirmed. `applyListing` already left in
1162		// `picked` the sessions without a problem.
1163		if (problems.length > 0) {
1164			d.phase = "ready";
1165			d.error = `not sent: ${problems.join("; ")}, check the selection and send again`;
1166			return;
1167		}
1168		const records = await deliverAll($, bundle, targets);
1169		// Closing the pane, or a newer /over, takes `draft` away while the confirmed
1170		// sends finish. Leave that newer pane's refresh timer alone, and do not toast
1171		// into a pane that is gone.
1172		if (draft !== d) return;
1173		d.phase = "sent";
1174		stopRefresh();
1175		const [only] = records;
1176		$.ui.toast(
1177			d.kind === "broadcast" || only === undefined
1178				? broadcastSummary(records)
1179				: statusLine(only, false),
1180		);
1181	} catch (error) {
1182		if (draft !== d) return;
1183		d.phase = "failed";
1184		d.error = `send failed: ${reasonOf(error)}`;
1185		stopRefresh();
1186	} finally {
1187		$.ui.invalidate("ui.render");
1188	}
1189}
1190
1191// The type button. The bundle the other type needs is made first and the switch is applied in one
1192// step after it, so the pane never shows a type whose bundle is the other one, and a second press
1193// that comes in meanwhile (or a send, which still sees the old type) finds a draft that is whole.
1194// The rules (state sequencing in the plan):
1195// - to broadcast: the bundle is the intent as it is, ready at once, also while the fork runs; a
1196//   hand-off bundle already made is kept in `d.handoff`, the fork's late result goes there too;
1197//   the project's sessions are ticked only if the list was already read (else the first list does
1198//   it, in `applyListing`, and a compose still waiting for it makes the broadcast then);
1199// - to hand-off: `d.handoff` when there is one, else the fork in flight writes the bundle (its
1200//   end reads the type again), else a new compose starts.
hooks/ack.ts 40 lines
1// The acknowledgement a recipient sends back, in one place: the bundle tells the recipient
2// what to send (`ackMessage`) and the sender's hook reads it (`parseAck`, `matchAck`).
3// Pure: no `$` in this file.
4
5import type { SendRecord } from "./notice.js";
6
7export type Ack = { bundleId: string; sendId: string; fromName?: string; extra: string };
8
9export const ackMessage = (bundleId: string, sendId: string): string =>
10	`over-ack ${bundleId} ${sendId}`;
11
12// The platform wraps a peer's message in an envelope (measured, research § 5), so the
13// text the hook sees is the envelope, not the bare line the recipient wrote.
14const ENVELOPE = /^<cross-session-message\b([^>]*)>([\s\S]*?)<\/cross-session-message>$/;
15const FROM_NAME = /\bfrom-name="([^"]*)"/;
16
17// Identifier shapes are those of `newBundleId` and `newSendId` in bundle.ts. A model may
18// type them in capitals, so matching ignores case and the result is lower-cased.
19const ACK = /^over-ack[ \t]+(ovr-[0-9a-z]+-[0-9a-z]{4})[ \t]+([0-9a-f]{7})(?=\s|$)([\s\S]*)$/i;
20
21export function parseAck(text: string): Ack | null {
22	const trimmed = text.trim();
23	const envelope = ENVELOPE.exec(trimmed);
24	const body = envelope ? (envelope[2] ?? "").trim() : trimmed;
25	const match = ACK.exec(body);
26	if (!match) return null;
27	const [, bundleId = "", sendId = "", extra = ""] = match;
28	const fromName = envelope ? FROM_NAME.exec(envelope[1] ?? "")?.[1] : undefined;
29	return {
30		bundleId: bundleId.toLowerCase(),
31		sendId: sendId.toLowerCase(),
32		...(fromName ? { fromName } : {}),
33		extra: extra.trim(),
34	};
35}
36
37// The send id differs on every send, so the pair identifies exactly one record.
38export const matchAck = (records: SendRecord[], ack: Ack): SendRecord | undefined =>
39	records.find((r) => r.bundleId === ack.bundleId && r.sendId === ack.sendId);
40
hooks/args.ts 75 lines
1import type { BundleKind } from "./bundle.js";
2
3export type OverArgs =
4	| { kind: "compose"; intent: string; noFork: boolean; bundleKind: BundleKind }
5	| { kind: "list" }
6	| { kind: "send"; target: string }
7	| { kind: "send-repo" }
8	| { kind: "panel" }
9	| { kind: "status" }
10	| { kind: "history"; index?: number }
11	| { kind: "usage"; reason?: string };
12
13// The pane's intent field is one line of free text, so flags are not parsed.
14export function intentFromField(value: string): string | undefined {
15	const text = value.trim();
16	return text === "" ? undefined : text;
17}
18
19// Flags count only as the first token, so an intent such as "send the review"
20// is never mistaken for a subcommand.
21export function parseOverArgs(args: string): OverArgs {
22	const text = args.trim();
23	if (text === "") return { kind: "panel" };
24
25	const first = text.split(/\s+/)[0] ?? "";
26	const afterFirst = text.slice(first.length).trim();
27
28	if (!first.startsWith("--")) {
29		return { kind: "compose", intent: text, noFork: false, bundleKind: "hand-off" };
30	}
31
32	if (first === "--list") {
33		return afterFirst === ""
34			? { kind: "list" }
35			: { kind: "usage", reason: "--list takes no arguments" };
36	}
37	if (first === "--status") {
38		return afterFirst === ""
39			? { kind: "status" }
40			: { kind: "usage", reason: "--status takes no arguments" };
41	}
42	if (first === "--history") {
43		if (afterFirst === "") return { kind: "history" };
44		// Digits only, 1-50: `Number()` would also take "0x3", "1e1" and " 3".
45		return /^[1-9]\d?$/.test(afterFirst) && Number(afterFirst) <= 50
46			? { kind: "history", index: Number(afterFirst) }
47			: { kind: "usage", reason: "--history takes an entry number (1-50)" };
48	}
49	if (first === "--send") {
50		return afterFirst === ""
51			? { kind: "usage", reason: "--send needs a session name" }
52			: { kind: "send", target: afterFirst };
53	}
54	if (first === "--no-fork") {
55		if (afterFirst === "") return { kind: "usage", reason: "--no-fork needs an intent" };
56		if (afterFirst.startsWith("--")) {
57			return { kind: "usage", reason: `unknown flag: ${afterFirst.split(/\s+/)[0]}` };
58		}
59		return { kind: "compose", intent: afterFirst, noFork: true, bundleKind: "hand-off" };
60	}
61	if (first === "--broadcast") {
62		if (afterFirst === "") return { kind: "usage", reason: "--broadcast needs an intent" };
63		if (afterFirst.startsWith("--")) {
64			return { kind: "usage", reason: `unknown flag: ${afterFirst.split(/\s+/)[0]}` };
65		}
66		return { kind: "compose", intent: afterFirst, noFork: false, bundleKind: "broadcast" };
67	}
68	if (first === "--send-repo") {
69		return afterFirst === ""
70			? { kind: "send-repo" }
71			: { kind: "usage", reason: "--send-repo takes no arguments" };
72	}
73	return { kind: "usage", reason: `unknown flag: ${first}` };
74}
75
hooks/bundle.ts 277 lines
1// Hand-off bundle: the fork prompt, its parsed reply, and the fixed template that is
2// sent to the recipient. Pure: no `$` in this file.
3import { ackMessage } from "./ack.js";
4import type { GitSummary } from "./git.js";
5
6/** What the user is sending: a full hand-off, or a short notice to several sessions. */
7export type BundleKind = "hand-off" | "broadcast";
8
9export type Bundle = {
10	bundleId: string;
11	kind: BundleKind;
12	intent: string;
13	summary: string;
14	instructions: string;
15	details: string;
16	/** Files named by the model; only set when git was unavailable (unverified). */
17	files?: string;
18	/** Why there is no conversation summary, when there is none. */
19	note?: string;
20	git?: GitSummary;
21	source: { name?: string; repo: string; branch: string; sessionId: string };
22};
23
24export type ForkReply = {
25	summary: string;
26	instructions: string;
27	details: string;
28	files?: string;
29	isStructured: boolean;
30};
31
32export type Preview = {
33	/** Single-line items for the pane summary and `-p` logs (a newline draws as a glyph). */
34	lines: string[];
35	meta: string;
36	/** The text as the recipient will see it, with a placeholder where the send id goes. */
37	full: string;
38};
39
40// The platform limit is ~1M characters (research § 10).
41const SIZE_LIMIT = 900_000;
42const SUMMARY_FALLBACK_CHARS = 300;
43const LINE_MAX = 200;
44const TRUNCATED_NOTE = "[truncated: bundle exceeded size limit]";
45
46export function forkPrompt(intent: string, opts: { filesFromModel: boolean }): string {
47	const sections = [
48		"### SUMMARY\nAt most 3 sentences: what was done in this conversation and where it stands.",
49		"### INSTRUCTIONS\nWhat the recipient should do, concretely, to carry out the intent.",
50		"### DETAILS\nContext the recipient needs: decisions and why, current state, open questions, relevant paths.",
51	];
52	if (opts.filesFromModel) {
53		sections.push(
54			"### FILES\nOne path per line: the files changed in this conversation. Say so if you are unsure.",
55		);
56	}
57	return [
58		`You are preparing a hand-off of this conversation for another Claude Code session. The user's intent: ${intent}`,
59		"Reply with exactly the sections below, in this order, under these exact headings. No preface and no tool calls. Write in the language of the intent.",
60		sections.join("\n\n"),
61	].join("\n\n");
62}
63
64export function parseForkReply(text: string): ForkReply {
65	const headers = [...text.matchAll(/^###[ \t]+(SUMMARY|INSTRUCTIONS|DETAILS|FILES)[ \t]*$/gim)];
66	const sections = new Map<string, string>();
67	headers.forEach((match, i) => {
68		const name = (match[1] ?? "").toLowerCase();
69		const end = headers[i + 1]?.index ?? text.length;
70		const body = text.slice((match.index ?? 0) + match[0].length, end).trim();
71		if (!sections.has(name)) sections.set(name, body);
72	});
73
74	const files = sections.get("files");
75	const filesPart = files ? { files } : {};
76	const summary = sections.get("summary") ?? "";
77	const instructions = sections.get("instructions") ?? "";
78	if (summary !== "" && instructions !== "") {
79		return {
80			summary,
81			instructions,
82			details: sections.get("details") ?? "",
83			...filesPart,
84			isStructured: true,
85		};
86	}
87	const flat = text.trim();
88	return {
89		summary:
90			flat.length > SUMMARY_FALLBACK_CHARS
91				? `${flat.slice(0, SUMMARY_FALLBACK_CHARS).trimEnd()}…`
92				: flat,
93		instructions: "",
94		details: text,
95		...filesPart,
96		isStructured: false,
97	};
98}
99
100export function noModelContent(
101	intent: string,
102	reason: string,
103): { summary: string; instructions: string; details: string; note: string } {
104	return {
105		summary: intent,
106		instructions: intent,
107		details: "",
108		note: `no conversation summary: ${reason}`,
109	};
110}
111
112// `entropy` is a number in [0, 1) like Math.random(); injected to keep this module deterministic.
113function entropyDigits(entropy: number, radix: number, width: number): string {
114	const size = radix ** width;
115	const n = Number.isFinite(entropy) ? Math.floor(entropy * size) : 0;
116	return Math.min(Math.max(n, 0), size - 1)
117		.toString(radix)
118		.padStart(width, "0");
119}
120
121export const newBundleId = (nowMs: number, entropy: number): string =>
122	`ovr-${Math.floor(nowMs).toString(36)}-${entropyDigits(entropy, 36, 4)}`;
123
124// A new id on every send: the platform silently drops a second identical message to
125// the same recipient (evidence E2), so a re-send of one bundle must differ in text.
126export const newSendId = (entropy: number): string => entropyDigits(entropy, 16, 7);
127
128export const bundleSizeOk = (text: string): boolean => text.length < SIZE_LIMIT;
129
130function modeLabel(git: GitSummary): string {
131	switch (git.mode) {
132		case "branch-diff":
133			return `branch diff against ${git.base}`;
134		case "session-commits":
135			return "commits made during this session";
136		default:
137			return "uncommitted changes only";
138	}
139}
140
141// Git paths can hold a newline once unquoted; shown raw, one would open a line of its own in
142// the bundle and could pass for a header or an instruction. Control characters stay visible.
143const VISIBLE_ESCAPES: Record<string, string> = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
144const visible = (text: string): string =>
145	text.replace(
146		/[\p{Cc}\p{Zl}\p{Zp}]/gu,
147		(ch) => VISIBLE_ESCAPES[ch] ?? `\\u${(ch.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`,
148	);
149
150function changedFiles(b: Bundle, isTrimmed: boolean): string {
151	if (isTrimmed) return `## Changed files\n${TRUNCATED_NOTE}`;
152	const { git } = b;
153	if (git) {
154		const rows = git.changes.map(
155			(c) =>
156				`- ${c.status} ${visible(c.path)}${c.from ? ` <- ${visible(c.from)}` : ""} (${c.source})`,
157		);
158		if (rows.length === 0) rows.push("(no changes detected)");
159		if (git.truncated > 0) rows.push(`...and ${git.truncated} more not listed`);
160		if (git.note) rows.push(`Note: ${git.note}`);
161		return [`## Changed files (git, ${modeLabel(git)})`, ...rows].join("\n");
162	}
163	const files = b.files?.trim();
164	if (files) {
165		return `## Changed files (unverified: listed by the model, not read from git)\n${files}`;
166	}
167	return "## Changed files\n(unknown: git was not available)";
168}
169
170const HANDOFF_HANDLING =
171	"Handling: finish your current task first, then pick up the work below. If you do not pick it up, tell your user why. Leave any step that needs your user's permission (such as a commit) for them to confirm, and do the rest.";
172
173export const BROADCAST_HANDLING =
174	"Handling: this is a notice, not a task. Keep it in mind and carry on with what you were doing; do not start new work because of it, and do not reply beyond the acknowledgement.";
175
176/** A broadcast carries only the intent: no summary, instructions, files or git data. */
177export function broadcastBundle(base: {
178	bundleId: string;
179	intent: string;
180	source: Bundle["source"];
181}): Bundle {
182	return {
183		bundleId: base.bundleId,
184		kind: "broadcast",
185		intent: base.intent,
186		summary: "",
187		instructions: "",
188		details: "",
189		source: base.source,
190	};
191}
192
193// Measured wording (S-02 research, variant A: 36/36 acknowledged). A change of words or of
194// position is a new, unmeasured variant.
195const ackLine = (bundleId: string, sendId: string): string =>
196	`Acknowledge now, before anything else: call the SendMessage tool with \`to\` set to the exact value of the \`from\` attribute of the cross-session-message envelope around this text, and \`message\` set to \`${ackMessage(bundleId, sendId)}\`. This single call does not interrupt your current task; carry on with it afterwards.`;
197
198// The header grammar, one for every bundle type; the type changes the word in lines 1 and 4
199// and the whole of line 5:
200//   1. [claude-over <hand-off|broadcast> <bundle-id>]
201//   2. Send: <send-id>
202//   3. From: <name> · repo <repo> · branch <branch> · session <session-id>
203//   4. Approved by the user: … (names the type)
204//   5. Handling: … (the type-specific text: HANDOFF_HANDLING or BROADCAST_HANDLING)
205//   6. the acknowledgement line (the same for both types)
206// scripts/acceptance/checks.mjs reads lines 1 and 2 (`HEADER_RE`).
207function render(b: Bundle, sendId: string, isTrimmed: boolean): string {
208	const { source } = b;
209	const from = source.name ?? `${source.repo}/${source.sessionId.slice(0, 8)}`;
210	const header = [
211		`[claude-over ${b.kind} ${b.bundleId}]`,
212		`Send: ${sendId}`,
213		`From: ${from} · repo ${source.repo} · branch ${source.branch} · session ${source.sessionId}`,
214		`Approved by the user: they reviewed this ${b.kind} in a preview and chose to send it to you. It is not a prompt typed by the user in this session.`,
215		b.kind === "broadcast" ? BROADCAST_HANDLING : HANDOFF_HANDLING,
216		ackLine(b.bundleId, sendId),
217	].join("\n");
218
219	if (b.kind === "broadcast") {
220		return [header, `## Message\n${isTrimmed ? TRUNCATED_NOTE : b.intent}`].join("\n\n");
221	}
222
223	const detailParts = isTrimmed
224		? [TRUNCATED_NOTE]
225		: [b.details.trim(), b.note ? `Note: ${b.note}` : ""].filter((part) => part !== "");
226	const blocks = [
227		header,
228		`## Summary\n${b.summary.trim() || b.intent}`,
229		`## Instructions\n${b.instructions.trim() || b.intent}`,
230		changedFiles(b, isTrimmed),
231	];
232	if (detailParts.length > 0) blocks.push(`## Details\n${detailParts.join("\n\n")}`);
233	return blocks.join("\n\n");
234}
235
236export function renderBundle(b: Bundle, sendId: string): string {
237	const full = render(b, sendId, false);
238	if (bundleSizeOk(full)) return full;
239	const trimmed = render(b, sendId, true);
240	return bundleSizeOk(trimmed)
241		? trimmed
242		: `${trimmed.slice(0, SIZE_LIMIT - TRUNCATED_NOTE.length - 10)}\n\n${TRUNCATED_NOTE}`;
243}
244
245const oneLine = (label: string, text: string): string => {
246	const chunk = text.slice(0, LINE_MAX * 2);
247	const flat = `${label}${chunk}`.replace(/\s+/g, " ").trim();
248	return flat.length > LINE_MAX ? `${flat.slice(0, LINE_MAX).trimEnd()}…` : flat;
249};
250
251export function previewModel(b: Bundle): Preview {
252	if (b.kind === "broadcast") {
253		return {
254			lines: [oneLine("", `[claude-over broadcast ${b.bundleId}]`), oneLine("Message: ", b.intent)],
255			meta: `message only · ${b.source.repo} · ${b.source.branch}`,
256			full: renderBundle(b, "<send-id>"),
257		};
258	}
259	const { git } = b;
260	const files = git
261		? `files: ${git.changes.length + git.truncated} (${
262				git.mode === "branch-diff" ? `branch-diff vs ${git.base}` : git.mode
263			})`
264		: b.files
265			? "files: unverified (from the model)"
266			: "files: no data (git unavailable)";
267	return {
268		lines: [
269			oneLine("", `[claude-over hand-off ${b.bundleId}]`),
270			oneLine("Summary: ", b.summary.trim() || b.intent),
271			oneLine("Instructions: ", b.instructions.trim() || b.intent),
272		],
273		meta: [`branch ${b.source.branch}`, files, b.note].filter(Boolean).join(" · "),
274		full: renderBundle(b, "<send-id>"),
275	};
276}
277
hooks/git.ts 307 lines
1// Pure git helpers: fixed argv for `$.process.run`, parsers for their output, and
2// the merge of the three change sources into one list. No `$` in this file.
3
4export type ChangeStatus =
5	| "added"
6	| "modified"
7	| "deleted"
8	| "renamed"
9	| "copied"
10	| "untracked"
11	| "other";
12export type ChangeSource = "uncommitted" | "branch" | "session-commits";
13
14export type RawChange = { path: string; status: ChangeStatus; from?: string };
15export type FileChange = RawChange & { source: ChangeSource };
16export type SessionCommit = { hash: string; subject: string; changes: RawChange[] };
17export type GitRun = { exitCode: number; stdout: string };
18export type GitMode = "branch-diff" | "session-commits" | "uncommitted-only";
19export type GitSummary = {
20	branch: string;
21	base: string;
22	mode: GitMode;
23	changes: FileChange[];
24	/** How many changes were cut off by MAX_CHANGES. */
25	truncated: number;
26	note?: string;
27};
28
29export const MAX_CHANGES = 200;
30export const LOG_MARKER = "@@commit ";
31
32export const GIT_STATUS = ["git", "status", "--porcelain=v1"];
33export const GIT_BRANCH = ["git", "rev-parse", "--abbrev-ref", "HEAD"];
34export const GIT_HEAD = ["git", "rev-parse", "HEAD"];
35export const GIT_SHORT_HEAD = ["git", "rev-parse", "--short", "HEAD"];
36export const GIT_ORIGIN_HEAD = ["git", "rev-parse", "--abbrev-ref", "origin/HEAD"];
37
38export const gitMergeBaseArgv = (baseRef: string) => ["git", "merge-base", "--", "HEAD", baseRef];
39export const gitBranchDiffArgv = (baseRef: string) => [
40	"git",
41	"diff",
42	"--name-status",
43	`${baseRef}...HEAD`,
44];
45export const gitSessionLogArgv = (sinceEpochSec: number) => [
46	"git",
47	"log",
48	`--since=@${sinceEpochSec}`,
49	"--name-status",
50	`--format=${LOG_MARKER}%h %s`,
51];
52
53const ESCAPES: Record<string, number> = {
54	a: 7,
55	b: 8,
56	t: 9,
57	n: 10,
58	v: 11,
59	f: 12,
60	r: 13,
61	'"': 34,
62	"\\": 92,
63};
64const encoder = new TextEncoder();
65const decoder = new TextDecoder();
66
67// Git quotes unusual paths C-style, with non-ASCII bytes as octal escapes
68// ("\305\202.txt" is "ł.txt"), so the escapes are collected as bytes and decoded as UTF-8.
69function unquoteInner(inner: string): string {
70	const bytes: number[] = [];
71	let i = 0;
72	while (i < inner.length) {
73		const ch = inner[i] ?? "";
74		if (ch !== "\\") {
75			const cp = inner.codePointAt(i) ?? 0;
76			const width = cp > 0xffff ? 2 : 1;
77			bytes.push(...encoder.encode(inner.slice(i, i + width)));
78			i += width;
79			continue;
80		}
81		const next = inner[i + 1] ?? "";
82		if (next >= "0" && next <= "7") {
83			bytes.push(Number.parseInt(inner.slice(i + 1, i + 4), 8));
84			i += 4;
85			continue;
86		}
87		const escaped = ESCAPES[next];
88		if (escaped !== undefined) {
89			bytes.push(escaped);
90			i += 2;
91			continue;
92		}
93		bytes.push(92);
94		i += 1;
95	}
96	return decoder.decode(new Uint8Array(bytes));
97}
98
99function unquotePath(token: string): string {
100	return token.length >= 2 && token.startsWith('"') && token.endsWith('"')
101		? unquoteInner(token.slice(1, -1))
102		: token;
103}
104
105// Index just past the closing quote of a quoted token that starts at `start`.
106function quotedEnd(s: string, start: number): number {
107	let i = start + 1;
108	while (i < s.length) {
109		if (s[i] === "\\") i += 2;
110		else if (s[i] === '"') return i + 1;
111		else i += 1;
112	}
113	return s.length;
114}
115
116// "old -> new" with either side optionally quoted; null when there is no arrow.
117function splitRename(rest: string): { from: string; to: string } | null {
118	const end = rest.startsWith('"') ? quotedEnd(rest, 0) : rest.indexOf(" -> ");
119	if (end < 0 || !rest.startsWith(" -> ", end)) return null;
120	return { from: unquotePath(rest.slice(0, end)), to: unquotePath(rest.slice(end + 4)) };
121}
122
123function statusOf(letter: string): ChangeStatus {
124	switch (letter) {
125		case "A":
126			return "added";
127		case "M":
128		case "T":
129			return "modified";
130		case "D":
131			return "deleted";
132		case "R":
133			return "renamed";
134		case "C":
135			return "copied";
136		default:
137			return "other";
138	}
139}
140
141const lines = (stdout: string) => stdout.split(/\r?\n/).filter((line) => line !== "");
142
143export function parsePorcelain(stdout: string): RawChange[] {
144	const out: RawChange[] = [];
145	for (const line of lines(stdout)) {
146		if (line.length < 4) continue;
147		const xy = line.slice(0, 2);
148		const rest = line.slice(3);
149		if (xy === "!!") continue;
150		if (xy === "??") {
151			out.push({ path: unquotePath(rest), status: "untracked" });
152			continue;
153		}
154		const status = statusOf(xy[0] !== " " ? (xy[0] ?? "") : (xy[1] ?? ""));
155		const rename = status === "renamed" || status === "copied" ? splitRename(rest) : null;
156		out.push(
157			rename ? { path: rename.to, status, from: rename.from } : { path: unquotePath(rest), status },
158		);
159	}
160	return out;
161}
162
163function parseNameStatusLine(line: string): RawChange | null {
164	const parts = line.split("\t");
165	const status = statusOf((parts[0] ?? "").charAt(0));
166	if (parts.length < 2) return null;
167	if ((status === "renamed" || status === "copied") && parts.length >= 3) {
168		return { path: unquotePath(parts[2] ?? ""), status, from: unquotePath(parts[1] ?? "") };
169	}
170	return { path: unquotePath(parts[1] ?? ""), status };
171}
172
173export function parseNameStatus(stdout: string): RawChange[] {
174	return lines(stdout).flatMap((line) => parseNameStatusLine(line) ?? []);
175}
176
177export function parseLogNameStatus(stdout: string): SessionCommit[] {
178	const commits: SessionCommit[] = [];
179	let current: SessionCommit | undefined;
180	for (const line of lines(stdout)) {
181		if (line.startsWith(LOG_MARKER)) {
182			const header = line.slice(LOG_MARKER.length);
183			const space = header.indexOf(" ");
184			current = {
185				hash: space < 0 ? header : header.slice(0, space),
186				subject: space < 0 ? "" : header.slice(space + 1),
187				changes: [],
188			};
189			commits.push(current);
190			continue;
191		}
192		const change = current ? parseNameStatusLine(line) : null;
193		if (current && change) current.changes.push(change);
194	}
195	return commits;
196}
197
198// Strongest source wins per path: what is on disk now, then what this session
199// committed, then the rest of the branch. Output order follows that strength.
200export function mergeChanges(sources: {
201	uncommitted: RawChange[];
202	branch?: RawChange[];
203	sessionCommits?: SessionCommit[];
204}): FileChange[] {
205	const tagged: [ChangeSource, RawChange[]][] = [
206		["uncommitted", sources.uncommitted],
207		["session-commits", (sources.sessionCommits ?? []).flatMap((commit) => commit.changes)],
208		["branch", sources.branch ?? []],
209	];
210	const seen = new Set<string>();
211	const out: FileChange[] = [];
212	for (const [source, changes] of tagged) {
213		for (const change of changes) {
214			if (seen.has(change.path)) continue;
215			seen.add(change.path);
216			out.push({ ...change, source });
217		}
218	}
219	return out;
220}
221
222// A failed `rev-parse origin/HEAD` still prints "origin/HEAD" on stdout (E3), so
223// the exit code is checked before the output is trusted.
224export function resolveBaseRef(originHead: GitRun | undefined): string {
225	const ref = originHead?.exitCode === 0 ? originHead.stdout.trim() : "";
226	return ref === "" ? "main" : ref;
227}
228
229const stdoutIfOk = (run: GitRun | undefined) => (run?.exitCode === 0 ? run.stdout.trim() : "");
230
231/**
232 * Picks the change source for the repo state. The caller may run the branch diff
233 * and the session log together and let this decide which one counts; a missing
234 * `sessionLog` means the session start time is unknown.
235 */
236export function gitSummary(input: {
237	status: GitRun;
238	branch: GitRun;
239	shortHead?: GitRun;
240	head?: GitRun;
241	baseRef: string;
242	mergeBase?: GitRun;
243	branchDiff?: GitRun;
244	sessionLog?: GitRun;
245}): GitSummary {
246	const base = input.baseRef;
247	if (input.status.exitCode !== 0) {
248		return {
249			branch: "none",
250			base,
251			mode: "uncommitted-only",
252			changes: [],
253			truncated: 0,
254			note: "not a git repository",
255		};
256	}
257	const uncommitted = parsePorcelain(input.status.stdout);
258	const cap = (all: FileChange[], extra: Partial<GitSummary>) =>
259		({
260			base,
261			changes: all.slice(0, MAX_CHANGES),
262			truncated: Math.max(0, all.length - MAX_CHANGES),
263			...extra,
264		}) as GitSummary;
265
266	if (input.branch.exitCode !== 0) {
267		return cap(mergeChanges({ uncommitted }), {
268			branch: "uncommitted",
269			mode: "uncommitted-only",
270			note: "the repository has no commits yet",
271		});
272	}
273	const name = input.branch.stdout.trim();
274	const branch = name === "HEAD" ? `detached at ${stdoutIfOk(input.shortHead) || "HEAD"}` : name;
275
276	const head = stdoutIfOk(input.head);
277	const mergeBase = stdoutIfOk(input.mergeBase);
278	if (mergeBase !== "" && head !== "" && mergeBase !== head) {
279		const diff = input.branchDiff;
280		return cap(
281			mergeChanges({
282				uncommitted,
283				branch: diff?.exitCode === 0 ? parseNameStatus(diff.stdout) : [],
284			}),
285			{
286				branch,
287				mode: "branch-diff",
288				note: diff?.exitCode === 0 ? undefined : "could not read the diff against the base",
289			},
290		);
291	}
292
293	const notes = mergeBase === "" ? [`no common ancestor with ${base}`] : [];
294	if (input.sessionLog?.exitCode === 0) {
295		return cap(
296			mergeChanges({ uncommitted, sessionCommits: parseLogNameStatus(input.sessionLog.stdout) }),
297			{ branch, mode: "session-commits", note: notes.join("; ") || undefined },
298		);
299	}
300	notes.push("session start time unknown, uncommitted changes only");
301	return cap(mergeChanges({ uncommitted }), {
302		branch,
303		mode: "uncommitted-only",
304		note: notes.join("; "),
305	});
306}
307
hooks/groups.ts 201 lines
1// Session list model: join ListAgents targets with the registry, group them, number the visible
2// rows and spell the labels. Pure: no `$` in this file.
3
4import {
5	projectKey,
6	projectLabel,
7	type RegistryEntry,
8	slashed,
9	worktreeLabel,
10} from "./registry.js";
11import type { Target } from "./sessions.js";
12
13export type SessionRow = Target & { entry?: RegistryEntry };
14
15export type Grouped = {
16	thisProject: { label: string; rows: SessionRow[] };
17	other: {
18		total: number;
19		repos: { label: string; rows: SessionRow[] }[];
20		noDetails: SessionRow[];
21	};
22};
23
24export type VisibleRow = { row: SessionRow; hotkey?: string; isLast: boolean; heading?: string };
25
26// `ref` is the only key shared by a list row and a registry entry: it survives `/rename` and
27// `/clear`, while the stored name and session id do not (evidence § 4).
28export function joinRegistry(targets: Target[], entries: RegistryEntry[]): SessionRow[] {
29	const byRef = new Map<string, RegistryEntry>();
30	for (const entry of entries) {
31		if (entry.ref === undefined) continue;
32		const known = byRef.get(entry.ref);
33		if (known === undefined || isNewer(entry, known)) byRef.set(entry.ref, entry);
34	}
35	return targets.map((target): SessionRow => {
36		const entry = target.ref === undefined ? undefined : byRef.get(target.ref);
37		return entry === undefined ? target : { ...target, entry };
38	});
39}
40
41// Two entries share a ref after `/clear`. The later write wins; the tie-breaks only make the
42// choice independent of the order the store returned them in.
43function isNewer(a: RegistryEntry, b: RegistryEntry): boolean {
44	if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt;
45	if (a.lastActivityAt !== b.lastActivityAt) return a.lastActivityAt > b.lastActivityAt;
46	return a.sessionId > b.sessionId;
47}
48
49// Newest first; Array.prototype.sort is stable, so a tie keeps the platform's order.
50function byActivity(rows: SessionRow[]): SessionRow[] {
51	const at = (row: SessionRow) => row.entry?.lastActivityAt ?? 0;
52	return [...rows].sort((a, b) => at(b) - at(a));
53}
54
55// The path as the registry recorded it, only with forward slashes and no trailing one.
56function fullPath(entry: RegistryEntry): string {
57	return slashed(entry.repoRoot ?? entry.root);
58}
59
60export function groupSessions(rows: SessionRow[], self: { key: string; label: string }): Grouped {
61	const mine: SessionRow[] = [];
62	const noDetails: SessionRow[] = [];
63	const repos = new Map<string, { entry: RegistryEntry; rows: SessionRow[] }>();
64	for (const row of rows) {
65		const entry = row.entry;
66		if (entry === undefined) {
67			noDetails.push(row);
68			continue;
69		}
70		const key = projectKey(entry);
71		if (key === self.key) {
72			mine.push(row);
73			continue;
74		}
75		const repo = repos.get(key);
76		if (repo === undefined) repos.set(key, { entry, rows: [row] });
77		else repo.rows.push(row);
78	}
79
80	// Two repos named alike would be indistinguishable, so both show where they live.
81	const nameCounts = new Map<string, number>();
82	for (const { entry } of repos.values()) {
83		const name = projectLabel(entry).toLowerCase();
84		nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
85	}
86	const grouped = [...repos.entries()]
87		.map(([key, { entry, rows: repoRows }]) => ({
88			key,
89			label:
90				(nameCounts.get(projectLabel(entry).toLowerCase()) ?? 0) > 1
91					? fullPath(entry)
92					: projectLabel(entry),
93			rows: byActivity(repoRows),
94		}))
95		.sort((a, b) => {
96			const left = a.label.toLowerCase();
97			const right = b.label.toLowerCase();
98			if (left !== right) return left < right ? -1 : 1;
99			return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
100		})
101		.map(({ label, rows: repoRows }) => ({ label, rows: repoRows }));
102
103	return {
104		thisProject: { label: self.label, rows: byActivity(mine) },
105		other: {
106			total: grouped.reduce((sum, repo) => sum + repo.rows.length, noDetails.length),
107			repos: grouped,
108			noDetails,
109		},
110	};
111}
112
113export const NO_DETAILS_HEADING = "no details (mod not running there)";
114
115const MAX_HOTKEY = 9;
116
117// The rows the panel draws, top to bottom. Only these are numbered, so a collapsed group
118// neither shows rows nor takes keys, and the sub-heading of a block rides on its first row.
119export function visibleRows(
120	g: Grouped,
121	collapsed: { this: boolean; other: boolean },
122): VisibleRow[] {
123	const blocks: { rows: SessionRow[]; heading?: string }[] = [];
124	if (!collapsed.this) blocks.push({ rows: g.thisProject.rows });
125	if (!collapsed.other) {
126		for (const repo of g.other.repos) blocks.push({ rows: repo.rows, heading: repo.label });
127		blocks.push({ rows: g.other.noDetails, heading: NO_DETAILS_HEADING });
128	}
129
130	const visible: VisibleRow[] = [];
131	for (const block of blocks) {
132		block.rows.forEach((row, index) => {
133			const position = visible.length + 1;
134			visible.push({
135				row,
136				...(position <= MAX_HOTKEY ? { hotkey: String(position) } : {}),
137				isLast: index === block.rows.length - 1,
138				...(index === 0 && block.heading !== undefined ? { heading: block.heading } : {}),
139			});
140		});
141	}
142	return visible;
143}
144
145/** Floored, in the largest whole unit: `5s`, `2m`, `1h`, `3d`. Negative or NaN counts as 0. */
146export function relativeAge(ms: number): string {
147	const seconds = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
148	if (seconds < 60) return `${seconds}s`;
149	const minutes = Math.floor(seconds / 60);
150	if (minutes < 60) return `${minutes}m`;
151	const hours = Math.floor(minutes / 60);
152	return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
153}
154
155// The branch in the main checkout, `<worktree> (<branch>)` inside a worktree.
156function placeOf(entry: RegistryEntry): string | undefined {
157	const worktree = worktreeLabel(entry);
158	const branch = entry.branch === "" ? undefined : entry.branch;
159	if (worktree === undefined) return branch;
160	return branch === undefined ? worktree : `${worktree} (${branch})`;
161}
162
163/** What a row says, in order: address, place, status, age, note; the parts that exist. */
164export function rowParts(row: SessionRow, now: number): string[] {
165	const entry = row.entry;
166	const age =
167		entry !== undefined
168			? `${relativeAge(now - entry.lastActivityAt)} ago`
169			: row.startedAgo !== undefined
170				? `started ${row.startedAgo} ago`
171				: undefined;
172	return [
173		row.address,
174		entry === undefined ? undefined : placeOf(entry),
175		row.status,
176		age,
177		row.note,
178	].filter((part): part is string => part !== undefined && part !== "");
179}
180
181export function rowLabel(
182	row: SessionRow,
183	o: { isSelected: boolean; isLast: boolean; hotkey?: string; now: number },
184): string {
185	const parts = rowParts(row, o.now);
186	const branch = o.isLast ? "└─" : "├─";
187	const dot = o.isSelected ? "●" : "○";
188	const key = o.hotkey === undefined ? "" : ` (${o.hotkey})`;
189	return `${branch} ${dot} ${parts.join(" · ")}${key}`;
190}
191
192export function groupHeading(
193	kind: "this" | "other",
194	o: { label: string; count: number; isCollapsed: boolean; hotkey?: string },
195): string {
196	const arrow = o.isCollapsed ? "▶" : "▼";
197	const title = kind === "this" ? `This project: ${o.label}` : `Other sessions: ${o.count}`;
198	const key = o.hotkey === undefined ? "" : ` (${o.hotkey})`;
199	return `${arrow} ${title}${key}`;
200}
201
hooks/history.ts 494 lines
1// A preview of each bundle a session sent, kept in the shared `$.store` under
2// `history:<ownerSessionId>` so bare `/over` and `/over --history` can list it later. Pure: no `$`
3// here. Only the preview is stored (no Details, no diffs, no full text), within fixed limits.
4
5import type { Bundle } from "./bundle.js";
6import { relativeAge } from "./groups.js";
7import { type SendRecord, type SendStatus, statusLine, truncateIntent } from "./notice.js";
8import { normalizePath, type PruneItem } from "./registry.js";
9
10export const HISTORY_PREFIX = "history:";
11/** How many entries one session's key keeps; the oldest fall out first. */
12export const HISTORY_PER_SESSION = 50;
13/** How many entries the project view shows, after the keys are joined. */
14export const HISTORY_SHOWN = 50;
15export const HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
16/** `JSON.stringify(entry).length` never goes above this. */
17export const HISTORY_ENTRY_MAX_CHARS = 8000;
18
19const FIELD_MAX = { intent: 500, summary: 1000, instructions: 1000, reason: 120, target: 80 };
20const PATH_MAX = 120;
21const FILES_MAX = 30;
22const TARGETS_MAX = 20;
23
24export type HistoryTarget = {
25	sendId: string;
26	target: string;
27	sentAt: number;
28	status: SendStatus;
29	reason?: string;
30	updatedAt: number;
31};
32
33export type HistoryEntry = {
34	bundleId: string;
35	/** `projectKey` of the sender's place: what the project view filters on. */
36	project: string;
37	repo: string;
38	branch: string;
39	/** From the bundle id; the first send's time when the id has another format. */
40	composedAt: number;
41	/** The newest `sentAt` of the bundle: the entry's age and its place in the list. */
42	lastSentAt: number;
43	intent: string;
44	summary: string;
45	instructions: string;
46	files: string[];
47	filesTotal: number;
48	targets: HistoryTarget[];
49	targetsTotal: number;
50};
51
52export type HistoryLog = {
53	v: 1;
54	/** The owner session: the one the sends left from (`SendRecord.ownerSessionId`). */
55	sessionId: string;
56	updatedAt: number;
57	/** Newest first by `lastSentAt`. */
58	entries: HistoryEntry[];
59};
60
61/** What reading the history gave: the switch is off, the store failed, or the project's rows. */
62export type HistoryView =
63	| { state: "off" }
64	| { state: "error"; reason: string }
65	| { state: "ready"; entries: HistoryEntry[]; project: string; now: number };
66
67export function historyKey(sessionId: string): string {
68	return `${HISTORY_PREFIX}${sessionId}`;
69}
70
71const BUNDLE_ID = /^ovr-([0-9a-z]+)-[0-9a-z]{4}$/;
72
73/** When the bundle was composed, read from `ovr-<ms base36>-<4>`; null for any other id. */
74export function composedAtFrom(bundleId: string): number | null {
75	const digits = BUNDLE_ID.exec(bundleId)?.[1];
76	if (digits === undefined) return null;
77	const ms = Number.parseInt(digits, 36);
78	return Number.isFinite(ms) ? ms : null;
79}
80
81// Both clips count code points, so a Polish letter is never cut in half. A code point is at most
82// two UTF-16 units, so a text longer than `2 * max` units is surely too long and only its first
83// (or last) `2 * max` units are worth splitting: the cost does not grow with a 900 000-char field.
84function clipEnd(text: string, max: number): string {
85	if (text.length <= max) return text;
86	const head = text.length > max * 2 ? text.slice(0, max * 2) : text;
87	const chars = Array.from(head);
88	if (head === text && chars.length <= max) return text;
89	return `${chars
90		.slice(0, max - 1)
91		.join("")
92		.trimEnd()}…`;
93}
94
95// The beginning goes, so the end of a path (the file name) stays.
96function clipStart(text: string, max: number): string {
97	if (text.length <= max) return text;
98	const tail = text.length > max * 2 ? text.slice(-max * 2) : text;
99	const chars = Array.from(tail);
100	if (tail === text && chars.length <= max) return text;
101	return `…${chars.slice(chars.length - (max - 1)).join("")}`;
102}
103
104function targetOf(record: SendRecord): HistoryTarget {
105	const target: HistoryTarget = {
106		sendId: record.sendId,
107		target: clipEnd(record.target, FIELD_MAX.target),
108		sentAt: record.sentAt,
109		status: record.status,
110		updatedAt: record.updatedAt,
111	};
112	if (record.reason !== undefined) target.reason = clipEnd(record.reason, FIELD_MAX.reason);
113	return target;
114}
115
116// Git paths can hold a newline once unquoted. Shown raw, one would open a line of its own in the
117// history preview and could pass for a Summary or an instruction. Same escapes as the bundle text
118// (`bundle.ts`); that file stays untouched.
119const VISIBLE_ESCAPES: Record<string, string> = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
120function visiblePath(text: string): string {
121	return text.replace(
122		/[\p{Cc}\p{Zl}\p{Zp}]/gu,
123		(ch) => VISIBLE_ESCAPES[ch] ?? `\\u${(ch.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`,
124	);
125}
126
127function filePaths(bundle: Bundle): string[] {
128	if (bundle.git !== undefined) {
129		return bundle.git.changes.map((change) => visiblePath(change.path));
130	}
131	return (bundle.files ?? "")
132		.split(/\r?\n/)
133		.map((line) => visiblePath(line.trim()))
134		.filter((line) => line !== "");
135}
136
137const jsonSize = (value: unknown): number => JSON.stringify(value).length;
138
139type TextField = "instructions" | "summary" | "intent";
140type PlaceField = "project" | "repo" | "branch";
141
142// Cuts `field` by `excess` characters or empties it; JSON only ever shrinks by at least that much.
143function shrinkText(entry: HistoryEntry, field: TextField, excess: number): HistoryEntry {
144	const keep = Array.from(entry[field]).length - excess;
145	return { ...entry, [field]: keep <= 0 ? "" : clipEnd(entry[field], keep) };
146}
147
148// A short path is left alone: cutting it would not help and would erase where the bundle came from.
149function shrinkPlace(entry: HistoryEntry, field: PlaceField, excess: number): HistoryEntry {
150	const keep = Math.max(PATH_MAX, Array.from(entry[field]).length - excess);
151	return { ...entry, [field]: clipStart(entry[field], keep) };
152}
153
154function dropFromEnd(entry: HistoryEntry, field: "files" | "targets"): HistoryEntry {
155	const kept = [...entry[field]];
156	while (kept.length > 0 && jsonSize({ ...entry, [field]: kept }) > HISTORY_ENTRY_MAX_CHARS) {
157		kept.pop();
158	}
159	return { ...entry, [field]: kept };
160}
161
162// The order is the plan's (files, instructions, summary, then where it came from), with the intent
163// and the last targets as a final guard: escaped quotes can make even capped fields too big.
164const SHRINK_STEPS: ((entry: HistoryEntry, excess: number) => HistoryEntry)[] = [
165	(entry) => dropFromEnd(entry, "files"),
166	(entry, excess) => shrinkText(entry, "instructions", excess),
167	(entry, excess) => shrinkText(entry, "summary", excess),
168	(entry, excess) => shrinkPlace(entry, "project", excess),
169	(entry, excess) => shrinkPlace(entry, "repo", excess),
170	(entry, excess) => shrinkPlace(entry, "branch", excess),
171	(entry, excess) => shrinkText(entry, "intent", excess),
172	(entry) => dropFromEnd(entry, "targets"),
173];
174
175// The guard: whatever the bundle held, the stored entry stays within HISTORY_ENTRY_MAX_CHARS.
176function fit(entry: HistoryEntry): HistoryEntry {
177	let fitted = entry;
178	for (const step of SHRINK_STEPS) {
179		const excess = jsonSize(fitted) - HISTORY_ENTRY_MAX_CHARS;
180		if (excess <= 0) break;
181		fitted = step(fitted, excess);
182	}
183	return fitted;
184}
185
186/** The preview of `bundle` with `record` as its first target; details and source session are left out. */
187export function entryFromBundle(bundle: Bundle, project: string, record: SendRecord): HistoryEntry {
188	const paths = filePaths(bundle);
189	return fit({
190		bundleId: bundle.bundleId,
191		project,
192		repo: bundle.source.repo,
193		branch: bundle.source.branch,
194		composedAt: composedAtFrom(bundle.bundleId) ?? record.sentAt,
195		lastSentAt: record.sentAt,
196		intent: clipEnd(bundle.intent, FIELD_MAX.intent),
197		summary: clipEnd(bundle.summary, FIELD_MAX.summary),
198		instructions: clipEnd(bundle.instructions, FIELD_MAX.instructions),
199		files: paths.slice(0, FILES_MAX).map((path) => clipStart(path, PATH_MAX)),
200		filesTotal: paths.length,
201		targets: [targetOf(record)],
202		targetsTotal: 1,
203	});
204}
205
206// When the entry last changed: the newest of its send time and its targets' status times.
207function entryUpdatedAt(entry: HistoryEntry): number {
208	return Math.max(entry.lastSentAt, ...entry.targets.map((t) => t.updatedAt));
209}
210
211// The one order for the cap and the list: newest send, then newest status change, then the larger
212// bundleId, so equal entries rank the same way everywhere.
213function newestFirst(a: HistoryEntry, b: HistoryEntry): number {
214	if (a.lastSentAt !== b.lastSentAt) return b.lastSentAt - a.lastSentAt;
215	const updated = entryUpdatedAt(b) - entryUpdatedAt(a);
216	if (updated !== 0) return updated;
217	return compareText(b.bundleId, a.bundleId);
218}
219
220// Code-unit order, not locale order: the same on every machine.
221function compareText(a: string, b: string): number {
222	if (a === b) return 0;
223	return a < b ? -1 : 1;
224}
225
226// Oldest first among the ones kept, so a row still leads with the earliest stored recipient.
227// Past the cap the oldest fall off: the send just made stays, and a later warning can find it.
228function capTargets(targets: HistoryTarget[]): HistoryTarget[] {
229	return [...targets]
230		.sort((x, y) => x.sentAt - y.sentAt || compareText(x.sendId, y.sendId))
231		.slice(-TARGETS_MAX);
232}
233
234/**
235 * Adds `entry` to the owner's log, or, when the bundle is already there, adds `record` as one more
236 * target (the same `sendId` replaces) and moves `lastSentAt` forward. `prev` is left untouched.
237 */
238export function recordSend(
239	prev: HistoryLog | null,
240	entry: HistoryEntry,
241	record: SendRecord,
242	now: number,
243): HistoryLog {
244	const base = prev !== null && prev.sessionId === record.ownerSessionId ? prev.entries : [];
245	const existing = base.find((e) => e.bundleId === entry.bundleId);
246	const others = base.filter((e) => e.bundleId !== entry.bundleId);
247	let merged = entry;
248	if (existing !== undefined) {
249		const isKnown = existing.targets.some((t) => t.sendId === record.sendId);
250		const targets = [
251			...existing.targets.filter((t) => t.sendId !== record.sendId),
252			targetOf(record),
253		];
254		merged = fit({
255			...existing,
256			lastSentAt: Math.max(existing.lastSentAt, record.sentAt),
257			targets: capTargets(targets),
258			targetsTotal: existing.targetsTotal + (isKnown ? 0 : 1),
259		});
260	}
261	return {
262		v: 1,
263		sessionId: record.ownerSessionId,
264		updatedAt: now,
265		entries: [merged, ...others].sort(newestFirst),
266	};
267}
268
269/**
270 * Copies a send's current status into its target. Returns `prev` itself when there is no entry
271 * for the bundle, no target for the send, or nothing changed, so callers can skip the write.
272 */
273export function applyStatus(
274	prev: HistoryLog | null,
275	record: SendRecord,
276	now: number,
277): HistoryLog | null {
278	if (prev === null) return prev;
279	const entry = prev.entries.find((e) => e.bundleId === record.bundleId);
280	const old = entry?.targets.find((t) => t.sendId === record.sendId);
281	if (entry === undefined || old === undefined) return prev;
282	// Not `sentAt`: a status change never moves the entry in time.
283	const next: HistoryTarget = { ...targetOf(record), sentAt: old.sentAt };
284	if (JSON.stringify(next) === JSON.stringify(old)) return prev;
285	return {
286		...prev,
287		updatedAt: now,
288		entries: prev.entries.map((e) =>
289			e === entry ? fit({ ...e, targets: e.targets.map((t) => (t === old ? next : t)) }) : e,
290		),
291	};
292}
293
294/** Drops what is older than 14 days, then keeps the newest HISTORY_PER_SESSION. */
295export function trimLog(log: HistoryLog, now: number): HistoryLog {
296	const fresh = log.entries.filter((e) => now - e.lastSentAt <= HISTORY_MAX_AGE_MS);
297	return { ...log, entries: fresh.sort(newestFirst).slice(0, HISTORY_PER_SESSION) };
298}
299
300// A Record, not a list: tsc fails here when `SendStatus` gains a member this file does not know.
301const KNOWN_STATUSES: Record<SendStatus, true> = {
302	"passed-on": true,
303	held: true,
304	released: true,
305	refused: true,
306	expired: true,
307	"not-passed-on": true,
308	received: true,
309};
310
311function isRecord(value: unknown): value is Record<string, unknown> {
312	return typeof value === "object" && value !== null && !Array.isArray(value);
313}
314
315function isTime(value: unknown): value is number {
316	return typeof value === "number" && Number.isFinite(value);
317}
318
319function isStatus(value: unknown): value is SendStatus {
320	return typeof value === "string" && Object.hasOwn(KNOWN_STATUSES, value);
321}
322
323// A new target with the known fields only; null when a required field is missing or mistyped.
324function parseTarget(value: unknown): HistoryTarget | null {
325	if (!isRecord(value)) return null;
326	const { sendId, target, sentAt, status, reason, updatedAt } = value;
327	if (typeof sendId !== "string" || typeof target !== "string") return null;
328	if (!isTime(sentAt) || !isTime(updatedAt) || !isStatus(status)) return null;
329	if (reason !== undefined && typeof reason !== "string") return null;
330	const parsed: HistoryTarget = { sendId, target, sentAt, status, updatedAt };
331	if (reason !== undefined) parsed.reason = reason;
332	return parsed;
333}
334
335function parseEntry(value: unknown): HistoryEntry | null {
336	if (!isRecord(value)) return null;
337	const { bundleId, project, repo, branch, intent, summary, instructions } = value;
338	const { composedAt, lastSentAt, files, filesTotal, targets, targetsTotal } = value;
339	if (
340		typeof bundleId !== "string" ||
341		typeof project !== "string" ||
342		typeof repo !== "string" ||
343		typeof branch !== "string" ||
344		typeof intent !== "string" ||
345		typeof summary !== "string" ||
346		typeof instructions !== "string"
347	) {
348		return null;
349	}
350	if (!isTime(composedAt) || !isTime(lastSentAt) || !isTime(filesTotal) || !isTime(targetsTotal)) {
351		return null;
352	}
353	if (!Array.isArray(files) || !files.every((f) => typeof f === "string")) return null;
354	if (!Array.isArray(targets)) return null;
355	const kept: HistoryTarget[] = [];
356	for (const item of targets) {
357		const parsed = parseTarget(item);
358		if (parsed) kept.push(parsed);
359	}
360	return {
361		bundleId,
362		project,
363		repo,
364		branch,
365		composedAt,
366		lastSentAt,
367		intent,
368		summary,
369		instructions,
370		files: files as string[],
371		filesTotal,
372		targets: kept,
373		targetsTotal,
374	};
375}
376
377/**
378 * Validates a value read from the shared store, which can hold junk or another version. A wrong
379 * overall shape gives null; a single damaged entry is skipped and the rest survive. Unknown
380 * fields are dropped. Never throws.
381 */
382export function parseHistoryLog(value: unknown): HistoryLog | null {
383	if (!isRecord(value) || value.v !== 1) return null;
384	const { sessionId, updatedAt, entries } = value;
385	if (typeof sessionId !== "string" || !isTime(updatedAt) || !Array.isArray(entries)) return null;
386	const kept: HistoryEntry[] = [];
387	for (const item of entries) {
388		const parsed = parseEntry(item);
389		if (parsed) kept.push(parsed);
390	}
391	return { v: 1, sessionId, updatedAt, entries: kept.slice(0, HISTORY_PER_SESSION) };
392}
393
394// One bundle sent from two keys (it was sent again after /clear): the entry that ranks first
395// supplies the text, the targets are the union by `sendId`. The newer status change wins a
396// target; on equal times the winner's copy stays.
397function joinEntries(a: HistoryEntry, b: HistoryEntry): HistoryEntry {
398	const [first, second] = newestFirst(a, b) <= 0 ? [a, b] : [b, a];
399	const bySend = new Map<string, HistoryTarget>();
400	for (const target of [...second.targets, ...first.targets]) {
401		const seen = bySend.get(target.sendId);
402		if (seen === undefined || target.updatedAt >= seen.updatedAt) bySend.set(target.sendId, target);
403	}
404	const union = [...bySend.values()];
405	const targets = capTargets(union);
406	return {
407		...first,
408		lastSentAt: Math.max(first.lastSentAt, second.lastSentAt),
409		targets,
410		targetsTotal: Math.max(first.targetsTotal, second.targetsTotal, union.length),
411	};
412}
413
414/**
415 * What the project view lists: the entries of `project` from every session's log, not older than
416 * 14 days, the same bundle joined into one row, newest first, at most HISTORY_SHOWN.
417 */
418export function projectHistory(
419	logs: readonly HistoryLog[],
420	project: string,
421	now: number,
422): HistoryEntry[] {
423	const wanted = normalizePath(project);
424	const byBundle = new Map<string, HistoryEntry>();
425	for (const log of logs) {
426		for (const entry of log.entries) {
427			if (normalizePath(entry.project) !== wanted) continue;
428			const seen = byBundle.get(entry.bundleId);
429			byBundle.set(entry.bundleId, seen === undefined ? entry : joinEntries(seen, entry));
430		}
431	}
432	return [...byBundle.values()]
433		.filter((e) => now - e.lastSentAt <= HISTORY_MAX_AGE_MS)
434		.sort(newestFirst)
435		.slice(0, HISTORY_SHOWN);
436}
437
438/** Lets a history log go through `selectPrunable` next to the registry entries and send logs. */
439export function historyPruneItem(key: string, log: HistoryLog, size: number): PruneItem {
440	return { key, sessionId: log.sessionId, updatedAt: log.updatedAt, size };
441}
442
443// `statusLine` wants a whole SendRecord but reads only the target, status, reason and updatedAt.
444function statusOf(entry: HistoryEntry, target: HistoryTarget, now: number): string {
445	return statusLine(
446		{ ...target, bundleId: entry.bundleId, intent: "", ownerSessionId: "" },
447		false,
448		now,
449	);
450}
451
452/** One list row: age, intent, the first target's status and how many more there are. */
453export function historyRow(entry: HistoryEntry, now: number): string {
454	const parts = [`${relativeAge(now - entry.lastSentAt)} ago`, truncateIntent(entry.intent)];
455	const first = entry.targets[0];
456	if (first !== undefined) {
457		const more = entry.targetsTotal - 1;
458		parts.push(`${statusOf(entry, first, now)}${more > 0 ? ` +${more} more` : ""}`);
459	}
460	return parts.join(" · ");
461}
462
463// UTC, so the same entry reads the same on every machine (and in every test).
464function utcStamp(ms: number): string {
465	const date = new Date(ms);
466	return Number.isNaN(date.getTime())
467		? "unknown time"
468		: `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
469}
470
471/** The whole preview of one entry, one fact per line. */
472export function historyDetail(entry: HistoryEntry, now: number): string {
473	const lines = [
474		`Intent: ${entry.intent}`,
475		`From: ${entry.repo} (${entry.branch})`,
476		`Sent: ${relativeAge(now - entry.lastSentAt)} ago (${utcStamp(entry.lastSentAt)})`,
477	];
478	if (entry.targets.length > 0) {
479		lines.push("To:", ...entry.targets.map((t) => `- ${statusOf(entry, t, now)}`));
480		if (entry.targetsTotal > entry.targets.length) {
481			lines.push(`- +${entry.targetsTotal - entry.targets.length} more`);
482		}
483	}
484	if (entry.summary !== "") lines.push(`Summary: ${entry.summary}`);
485	if (entry.instructions !== "") lines.push(`Instructions: ${entry.instructions}`);
486	if (entry.filesTotal > 0) {
487		lines.push(`Files (${entry.filesTotal}):`, ...entry.files.map((f) => `- ${f}`));
488		if (entry.filesTotal > entry.files.length) {
489			lines.push(`- +${entry.filesTotal - entry.files.length} more`);
490		}
491	}
492	return lines.join("\n");
493}
494
hooks/history-pane.ts 95 lines
1// The "History (h)" section of the bare `/over` pane, drawn from what `readHistory` gave. Pure: no
2// `$` here. The elements are not importable (a surface hands out its own table through
3// `$.ui.resolve`), so the caller passes them in, and so it passes what a press should do.
4
5import type { ElementTable, RenderElement } from "claude-code";
6import { type HistoryView, historyDetail, historyRow } from "./history.js";
7
8type Parts = Pick<ElementTable, "Box" | "Text" | "Button">;
9
10export type HistorySectionView = {
11	/** Undefined until the first read has finished. */
12	history: HistoryView | undefined;
13	isCollapsed: boolean;
14	/** The `bundleId` whose entry is open; only one at a time. */
15	openBundleId?: string;
16	onToggle: () => void;
17	onOpen: (bundleId: string) => void;
18};
19
20const HOTKEY = "h";
21/** One indent step, in columns: the rows under the header, the detail under its row, the lists under their heading. */
22const INDENT = 2;
23
24// The same arrows as the session groups: ▶ folded, ▼ open. The count only once it is known.
25function heading(view: HistorySectionView): string {
26	const count = view.history?.state === "ready" ? ` (${view.history.entries.length})` : "";
27	return `${view.isCollapsed ? "▶" : "▼"} History${count} (${HOTKEY})`;
28}
29
30// One line per Text, a run of `- ` lines in a Box one indent deeper: no markdown, so the indent
31// is ours and a list never falls back to the left edge.
32function detail(parts: Parts, text: string): RenderElement {
33	const { Box, Text } = parts;
34	const nodes: RenderElement[] = [];
35	let items: RenderElement[] = [];
36	const flush = () => {
37		if (items.length === 0) return;
38		nodes.push(Box({ flexDirection: "column", paddingLeft: INDENT, children: items }));
39		items = [];
40	};
41	for (const line of text.split("\n")) {
42		if (line.startsWith("- ")) {
43			items.push(Text({ children: line }));
44			continue;
45		}
46		flush();
47		nodes.push(Text({ children: line }));
48	}
49	flush();
50	return Box({ flexDirection: "column", paddingLeft: INDENT, children: nodes });
51}
52
53function body(parts: Parts, view: HistorySectionView): RenderElement[] {
54	const { Text, Button } = parts;
55	const history = view.history;
56	if (history === undefined) return [Text({ dimColor: true, children: "Reading the history…" })];
57	if (history.state === "off") {
58		return [Text({ dimColor: true, children: "History is off (see /config: cc-over.history)" })];
59	}
60	if (history.state === "error") {
61		return [Text({ color: "red", children: `could not read the history (${history.reason})` })];
62	}
63	if (history.entries.length === 0) {
64		return [
65			Text({ dimColor: true, children: "No bundles sent from this project in the last 14 days" }),
66		];
67	}
68	return history.entries.flatMap((entry) => {
69		const isOpen = entry.bundleId === view.openBundleId;
70		return [
71			Button({
72				key: `history:${entry.bundleId}`,
73				label: `${isOpen ? "▼" : "▶"} ${historyRow(entry, history.now)}`,
74				onPress: () => view.onOpen(entry.bundleId),
75			}),
76			...(isOpen ? [detail(parts, historyDetail(entry, history.now))] : []),
77		];
78	});
79}
80
81/** The header button and, when it is open, the rows; the caller spreads it into the pane. */
82export function historySection(parts: Parts, view: HistorySectionView): RenderElement[] {
83	return [
84		parts.Button({
85			key: "group:history",
86			label: heading(view),
87			hotkey: HOTKEY,
88			onPress: view.onToggle,
89		}),
90		...(view.isCollapsed
91			? []
92			: [parts.Box({ flexDirection: "column", paddingLeft: INDENT, children: body(parts, view) })]),
93	];
94}
95
hooks/notice.ts 260 lines
1// Delivery warnings the platform writes into the sender's conversation, matched to the
2// mod's own sends, plus the status state machine. Pure: no `$` in this file.
3
4import type { BundleKind } from "./bundle.js";
5
6export type DeliveryKind = "held" | "released" | "denied" | "expired" | "refused" | "dropped";
7export type NoticeRecipient = {
8	name: string;
9	/** The platform added `at uds:…`: two targets of the bundle share this name. */
10	isAddressed: boolean;
11};
12export type DeliveryNotice = {
13	kind: DeliveryKind;
14	/** Messages the line speaks of: 1 for the singular wording, N for `messages (N)`. */
15	count: number;
16	recipients: NoticeRecipient[];
17	/** The recipient list ends with `, …`: the platform left some names out. */
18	isTruncated: boolean;
19};
20export type SendStatus =
21	| "passed-on"
22	| "held"
23	| "released"
24	| "refused"
25	| "expired"
26	| "not-passed-on"
27	| "received";
28
29export type SendRecord = {
30	bundleId: string;
31	/** Absent on records saved before broadcasts existed: those are hand-offs. */
32	kind?: BundleKind;
33	sendId: string;
34	/** Recipient name without `[ref]`: the platform warning names the session only that way. */
35	target: string;
36	sentAt: number;
37	status: SendStatus;
38	reason?: string;
39	/** The bundle's intent, cut to INTENT_MAX: the heading of the --status report. */
40	intent: string;
41	/** When the status last changed; equals `sentAt` until a notice or an ack arrives. */
42	updatedAt: number;
43	/** The session the send left from. It names the store key, which /clear does not move. */
44	ownerSessionId: string;
45};
46
47export const INTENT_MAX = 60;
48
49// Counted in code points, so a Polish letter is one character and is never cut in half.
50export function truncateIntent(text: string): string {
51	const flat = text.replace(/\s+/g, " ").trim();
52	const chars = Array.from(flat);
53	if (chars.length <= INTENT_MAX) return flat;
54	return `${chars
55		.slice(0, INTENT_MAX - 1)
56		.join("")
57		.trimEnd()}…`;
58}
59
60// `HH:MM` on this machine's clock: the --status heading and the "no news since" note.
61export function clockTime(ms: number): string {
62	const date = new Date(ms);
63	const pad = (value: number) => String(value).padStart(2, "0");
64	return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
65}
66
67const MATCH_WINDOW_MS = 30 * 60_000;
68/** A held send quiet for longer than this gets the "no news since" note in its status line. */
69export const NO_NEWS_MS = 10 * 60_000;
70
71// Wording: measured on Claude Code 2.1.302 (evidence E1), the rest read from the 2.1.310 binary
72// (research.md § 3). The platform's `delivered` is our `released`. Anchored at the start of the
73// text: the platform prefixes the mod's own log rows with the plugin name, so those never match,
74// even if they quote a warning. Only the first sentence is read; the explanation after it
75// differs between kinds and is the part most likely to change.
76const VERBS: [DeliveryKind, string][] = [
77	["held", "held by the receiving session"],
78	["denied", "denied"],
79	["expired", "expired without approval"],
80	["released", "released after approval"],
81	["refused", "refused"],
82];
83// Groups: 1 = the whole subject, 2 = N of `messages (N)`, 3 = the recipient list.
84const COUNT = String.raw`(message|messages \((\d+)\))`;
85// The list ends at the first ")": names keep spaces and pipes.
86const LIST = String.raw` \((?:recipient|recipients): ([^)]*)\)`;
87const NOTICES: [DeliveryKind, RegExp][] = [
88	...VERBS.map(([kind, verb]): [DeliveryKind, RegExp] => [
89		kind,
90		new RegExp(String.raw`^Cross-session ${COUNT} ${verb}${LIST}\.`),
91	]),
92	[
93		"dropped",
94		new RegExp(
95			String.raw`^Cross-session (message was|messages \((\d+)\) were) dropped at the recipient session's inbox${LIST} and not delivered`,
96		),
97	],
98];
99
100// `@name` and `@name at uds:…` name a recipient; a bare address names nobody we know.
101function parseRecipient(item: string): NoticeRecipient | null {
102	if (!item.startsWith("@")) return null;
103	const body = item.slice(1);
104	const addressed = /^(.+?) at (?:uds|bridge|did):/.exec(body);
105	const name = (addressed ? addressed[1] : body)?.trim();
106	return name ? { name, isAddressed: addressed !== null } : null;
107}
108
109export function parseDeliveryNotice(text: string): DeliveryNotice | null {
110	const head = text.trimStart();
111	for (const [kind, pattern] of NOTICES) {
112		const found = pattern.exec(head);
113		if (!found) continue;
114		const items = (found[3] ?? "").split(", ");
115		const recipients = items.flatMap((item) => {
116			const recipient = parseRecipient(item);
117			return recipient ? [recipient] : [];
118		});
119		if (recipients.length === 0) return null;
120		return {
121			kind,
122			count: found[2] ? Number(found[2]) : 1,
123			recipients,
124			isTruncated: items[items.length - 1] === "…",
125		};
126	}
127	return null;
128}
129
130// A record without its reason: a later status speaks for itself.
131function withoutReason(record: SendRecord): SendRecord {
132	const { reason: _dropped, ...rest } = record;
133	return rest;
134}
135
136export function applySendResult(
137	record: SendRecord,
138	result: { isDelivered: boolean; reason?: string },
139): SendRecord {
140	const rest = withoutReason(record);
141	return result.isDelivered
142		? { ...rest, status: "passed-on" }
143		: { ...rest, status: "not-passed-on", reason: result.reason ?? "" };
144}
145
146const isOpen = (status: SendStatus) => status === "passed-on" || status === "held";
147
148// The warning names only the recipient session, so two open sends to one name cannot be
149// told apart: the newest is chosen and the caller is told the pick is a guess. One call per
150// recipient of the warning. Candidates: passed-on sends from the last 30 minutes, and held
151// sends however old (a held send is still waiting for its outcome). A later outcome
152// (released, denied, expired) can only concern a held send, so held ones take precedence.
153// The pick is also a guess when the platform had to add the address to the name.
154// `exclude` holds the send ids already matched by earlier recipients of the same warning, so two
155// targets that share a name do not both land on one record.
156export function matchNotice(
157	records: SendRecord[],
158	kind: DeliveryKind,
159	recipient: NoticeRecipient,
160	nowMs: number,
161	exclude?: ReadonlySet<string>,
162): { record: SendRecord; isAmbiguous: boolean } | null {
163	const named = records.filter((r) => r.target === recipient.name && !exclude?.has(r.sendId));
164	const held = named.filter((r) => r.status === "held");
165	const open = named.filter(
166		(r) => r.status === "held" || (r.status === "passed-on" && nowMs - r.sentAt <= MATCH_WINDOW_MS),
167	);
168	const isOutcome = kind === "released" || kind === "denied" || kind === "expired";
169	const pool = (isOutcome && held.length > 0 ? held : open).sort((a, b) => b.sentAt - a.sentAt);
170	const record = pool[0];
171	return record ? { record, isAmbiguous: pool.length > 1 || recipient.isAddressed } : null;
172}
173
174// `released`, `expired`, `not-passed-on` (and the unused `refused`) absorb every later notice:
175// notices carry no ordering guarantee, and a late `held` must not reopen a result.
176// `received` is absorbing too: it is direct proof, no warning can undo it.
177export function nextStatus(status: SendStatus, kind: DeliveryKind): SendStatus {
178	if (!isOpen(status)) return status;
179	switch (kind) {
180		case "held":
181			return "held";
182		case "released":
183			return "released";
184		case "expired":
185			return "expired";
186		default:
187			// denied, refused, dropped: the message did not reach the recipient.
188			return "not-passed-on";
189	}
190}
191
192const NOT_PASSED_ON_REASONS: Partial<Record<DeliveryKind, string>> = {
193	denied: "the recipient's user declined it",
194	refused: "the recipient refuses cross-session messages",
195	dropped: "the recipient dropped the repeated text",
196};
197
198export function applyNotice(record: SendRecord, kind: DeliveryKind, now: number): SendRecord {
199	const status = nextStatus(record.status, kind);
200	if (status === record.status) return record;
201	return status === "not-passed-on"
202		? { ...record, status, updatedAt: now, reason: NOT_PASSED_ON_REASONS[kind] ?? "" }
203		: { ...withoutReason(record), status, updatedAt: now };
204}
205
206// The recipient's own acknowledgement beats every earlier status, including `held` and
207// `expired`: it proves the message arrived. A second ack returns the same object.
208export function applyAck(record: SendRecord, now: number): SendRecord {
209	if (record.status === "received") return record;
210	return { ...withoutReason(record), status: "received", updatedAt: now };
211}
212
213function reasonText(reason: string | undefined): string {
214	if (reason?.includes("no SendMessage tool")) {
215		return "this session has no SendMessage tool (messaging between sessions is off here)";
216	}
217	return reason?.split(/\r?\n/)[0]?.trim() || "reason unknown";
218}
219
220/**
221 * One line for the pane and the transcript. FR-009 wording: "passed on", and "received"
222 * only for `received`, which comes from the recipient's acknowledgement and nothing else.
223 * It must not start with the platform's warning phrase or the notice hook would see its
224 * own output again.
225 */
226export function statusLine(record: SendRecord, isAmbiguous: boolean, now?: number): string {
227	const to = record.target;
228	let line: string;
229	switch (record.status) {
230		case "passed-on":
231			line = `passed on to ${to} (queued)`;
232			break;
233		case "held":
234			line = `held: ${to} has not seen it yet, waiting for the recipient's approval`;
235			// Held sends can be settled long after the platform stops telling this session.
236			if (now !== undefined && now - record.updatedAt > NO_NEWS_MS) {
237				line += ` (no news since ${clockTime(record.updatedAt)}; a later outcome may not reach this session)`;
238			}
239			break;
240		case "released":
241			line = `passed on to ${to} (approved by the recipient, queued)`;
242			break;
243		case "expired":
244			line = `expired: ${to} did not approve the message, nothing is waiting for them`;
245			break;
246		// Kept for type exhaustiveness: platform "refused" maps to "not-passed-on" in nextStatus.
247		case "refused":
248			line = `not passed on to ${to}: the recipient refuses cross-session messages`;
249			break;
250		case "received":
251			line = `received by ${to} (acknowledged)`;
252			break;
253		default:
254			line = `not passed on to ${to}: ${reasonText(record.reason)}`;
255	}
256	return isAmbiguous
257		? `${line} (note: several sessions share this name, so it is unclear which one this is about)`
258		: line;
259}
260
hooks/registry.ts 181 lines
1// What each session running cc-over records about itself in the shared `$.store`, so the
2// panel can show repo, worktree, branch and last activity that ListAgents does not give.
3// Pure: no `$` in this file.
4
5export type RegistryEntry = {
6	v: 1;
7	sessionId: string;
8	/** From the ListAgents header; `/rename` has no event, so it can be stale. */
9	name?: string;
10	/** Stable across `/rename` and `/clear`; the key that joins an entry to a list row. */
11	ref?: string;
12	/** `$.session.root()` */
13	root: string;
14	/** `$.session.repo()?.root`: the main working tree's root, also inside a worktree. */
15	repoRoot?: string;
16	branch?: string;
17	lastActivityAt: number;
18	updatedAt: number;
19};
20
21export const REGISTRY_PREFIX = "session:";
22
23export function registryKey(sessionId: string): string {
24	return `${REGISTRY_PREFIX}${sessionId}`;
25}
26
27const DRIVE_LETTER = /^[A-Za-z]:/;
28const BARE_DRIVE = /^[A-Za-z]:$/;
29
30// Forward slashes, no trailing slash; a bare root ("/" or a drive root) keeps its slash.
31export function slashed(path: string): string {
32	const forward = path.replace(/\\/g, "/");
33	const trimmed = forward.replace(/\/+$/, "");
34	if (trimmed === "") return forward.startsWith("/") ? "/" : "";
35	return BARE_DRIVE.test(trimmed) ? `${trimmed}/` : trimmed;
36}
37
38function basename(path: string): string {
39	const normal = slashed(path);
40	const last = normal.slice(normal.lastIndexOf("/") + 1);
41	return last === "" ? normal : last;
42}
43
44// Windows ignores case, Linux does not: only paths with a drive letter are lowercased.
45export function normalizePath(path: string): string {
46	const normal = slashed(path);
47	return DRIVE_LETTER.test(normal) ? normal.toLowerCase() : normal;
48}
49
50type Place = Pick<RegistryEntry, "root" | "repoRoot">;
51
52export function projectKey(entry: Place): string {
53	return normalizePath(entry.repoRoot ?? entry.root);
54}
55
56export function projectLabel(entry: Place): string {
57	return basename(entry.repoRoot ?? entry.root);
58}
59
60/** The worktree directory name, or undefined in the main checkout and outside git. */
61export function worktreeLabel(entry: Place): string | undefined {
62	if (entry.repoRoot === undefined) return undefined;
63	return normalizePath(entry.root) === normalizePath(entry.repoRoot)
64		? undefined
65		: basename(entry.root);
66}
67
68function isRecord(value: unknown): value is Record<string, unknown> {
69	return typeof value === "object" && value !== null && !Array.isArray(value);
70}
71
72function isNonEmptyString(value: unknown): value is string {
73	return typeof value === "string" && value !== "";
74}
75
76function isTime(value: unknown): value is number {
77	return typeof value === "number" && Number.isFinite(value);
78}
79
80const OPTIONAL_TEXT = ["name", "ref", "repoRoot", "branch"] as const;
81
82/**
83 * Validates a value read from the shared store, which can hold junk or another version. Returns a
84 * new entry with the known fields only, or null when the record is corrupt; never throws.
85 */
86export function parseEntry(value: unknown): RegistryEntry | null {
87	if (!isRecord(value) || value.v !== 1) return null;
88	const { sessionId, root, lastActivityAt, updatedAt } = value;
89	if (!isNonEmptyString(sessionId) || !isNonEmptyString(root)) return null;
90	if (!isTime(lastActivityAt) || !isTime(updatedAt)) return null;
91	const entry: RegistryEntry = { v: 1, sessionId, root, lastActivityAt, updatedAt };
92	for (const field of OPTIONAL_TEXT) {
93		const text = value[field];
94		if (text === undefined) continue;
95		if (typeof text !== "string") return null;
96		entry[field] = text;
97	}
98	return entry;
99}
100
101export type RegistryPatch = Partial<RegistryEntry> & { sessionId: string };
102
103// A key present in the patch wins, even as undefined (which removes an optional field); a key left
104// out keeps the previous value.
105function pick(
106	prev: RegistryEntry | null,
107	patch: RegistryPatch,
108	key: (typeof OPTIONAL_TEXT)[number],
109): string | undefined {
110	return key in patch ? patch[key] : prev?.[key];
111}
112
113/**
114 * Merges a patch into the previous entry. Last activity only moves forward. Returns null when no
115 * root is known: a light activity update must not invent an entry.
116 */
117export function nextEntry(
118	prev: RegistryEntry | null,
119	patch: RegistryPatch,
120	now: number,
121): RegistryEntry | null {
122	const root = patch.root ?? prev?.root;
123	if (root === undefined) return null;
124	const seen = [prev?.lastActivityAt, patch.lastActivityAt].filter((t) => t !== undefined);
125	const entry: RegistryEntry = {
126		v: 1,
127		sessionId: patch.sessionId,
128		root,
129		lastActivityAt: seen.length === 0 ? now : Math.max(...seen),
130		updatedAt: now,
131	};
132	for (const field of OPTIONAL_TEXT) {
133		const text = pick(prev, patch, field);
134		if (text !== undefined) entry[field] = text;
135	}
136	return entry;
137}
138
139export const PRUNE_MAX_AGE_MS = 15 * 24 * 60 * 60 * 1000;
140export const PRUNE_MAX_BYTES = 3 * 1024 * 1024;
141/** How many registry entries one prune pass looks at. */
142export const PRUNE_SCAN_LIMIT = 500;
143
144export type PruneItem = { key: string; sessionId: string; updatedAt: number; size: number };
145
146export type PruneLimits = {
147	maxAgeMs: number;
148	maxBytes: number;
149	/** The current session is never selected, though it counts toward the byte total. */
150	keepSessionId: string;
151};
152
153/**
154 * The store keys to delete: first every entry untouched for more than `maxAgeMs`, oldest first;
155 * then, while the rest still weighs more than `maxBytes`, the oldest remaining ones.
156 */
157export function selectPrunable(
158	items: readonly PruneItem[],
159	now: number,
160	limits: PruneLimits,
161): string[] {
162	const oldestFirst = [...items].sort((a, b) => a.updatedAt - b.updatedAt);
163	const doomed: string[] = [];
164	const kept: PruneItem[] = [];
165	for (const item of oldestFirst) {
166		if (item.sessionId !== limits.keepSessionId && now - item.updatedAt > limits.maxAgeMs) {
167			doomed.push(item.key);
168		} else {
169			kept.push(item);
170		}
171	}
172	let total = kept.reduce((sum, item) => sum + item.size, 0);
173	for (const item of kept) {
174		if (total <= limits.maxBytes) break;
175		if (item.sessionId === limits.keepSessionId) continue;
176		doomed.push(item.key);
177		total -= item.size;
178	}
179	return doomed;
180}
181
hooks/report.ts 43 lines
1// The `/over --status` report and the pane block, built from the recorded sends.
2// Pure: no `$` in this file.
3
4import { clockTime, type SendRecord, statusLine } from "./notice.js";
5
6const bySendingOrder = (a: SendRecord, b: SendRecord) => a.sentAt - b.sentAt;
7
8// All records of one bundle share the kind; a record without one is a hand-off.
9function heading(group: readonly SendRecord[]): string {
10	const first = group[0];
11	const label = first?.kind === "broadcast" ? "broadcast: " : "";
12	return `${clockTime(first?.sentAt ?? 0)} ${label}${first?.intent ?? ""}`;
13}
14
15/**
16 * Sends grouped by bundle, the group with the newest send first. Each group is a header
17 * (`HH:MM intent` of its earliest send; `HH:MM broadcast: intent` for a broadcast) and one indented status line per target, in sending order.
18 */
19export function statusReport(records: SendRecord[], now: number): string[] {
20	if (records.length === 0) return ["no sends recorded for this session"];
21	const groups = new Map<string, SendRecord[]>();
22	for (const record of records) {
23		const group = groups.get(record.bundleId);
24		if (group) group.push(record);
25		else groups.set(record.bundleId, [record]);
26	}
27	const ordered = [...groups.values()]
28		.map((group) => group.sort(bySendingOrder))
29		.sort((a, b) => (b.at(-1)?.sentAt ?? 0) - (a.at(-1)?.sentAt ?? 0));
30	return ordered.flatMap((group) => [
31		heading(group),
32		...group.map((record) => `  ${statusLine(record, false, now)}`),
33	]);
34}
35
36/** The lines of one bundle for the pane block: no header, no indent; an unknown bundle gives []. */
37export function bundleLines(records: SendRecord[], bundleId: string, now: number): string[] {
38	return records
39		.filter((record) => record.bundleId === bundleId)
40		.sort(bySendingOrder)
41		.map((record) => statusLine(record, false, now));
42}
43
hooks/selection.ts 121 lines
1// Which sessions the user picked for a send. Pure: no `$` in this file.
2
3import type { BundleKind } from "./bundle.js";
4import type { Grouped } from "./groups.js";
5import type { Target } from "./sessions.js";
6
7/** One picked session. `key` is `Target.ref`, or `"addr:" + address` where the list gave no ref. */
8export type Pick = { key: string; name: string };
9
10const HAND_OFF_HINT = "pick one session for a hand-off";
11const MAX_LISTED_NAMES = 3;
12
13// The key of a pick: `ref` outlives a `/rename`, an address does not.
14export const pickOf = (target: Target): Pick => ({
15	key: target.ref ?? `addr:${target.address}`,
16	name: target.name,
17});
18
19/** The sessions of this project, in list order. Rows of other repos or without details never. */
20export function preselect(grouped: Grouped): Pick[] {
21	return grouped.thisProject.rows.map(pickOf);
22}
23
24/** A click on a row: a hand-off holds one pick, a broadcast toggles. Any click clears the hint. */
25export function togglePick(
26	picked: Pick[],
27	target: Target,
28	kind: BundleKind,
29): { picked: Pick[]; needsOnePick: false } {
30	const pick = pickOf(target);
31	const isPicked = picked.some((p) => p.key === pick.key);
32	if (kind === "hand-off") {
33		return { picked: isPicked ? [] : [pick], needsOnePick: false };
34	}
35	return {
36		picked: isPicked ? picked.filter((p) => p.key !== pick.key) : [...picked, pick],
37		needsOnePick: false,
38	};
39}
40
41/**
42 * The type switch. Up to broadcast the project's sessions join the picks once (a union by key,
43 * so a session picked earlier does not enter twice); down to hand-off more than one pick cannot
44 * stay, and none is chosen for the user.
45 */
46export function switchKind(
47	picked: Pick[],
48	to: BundleKind,
49	opts: { preselected: Pick[]; isPreselected: boolean },
50): { picked: Pick[]; isPreselected: boolean; needsOnePick: boolean } {
51	if (to === "broadcast") {
52		if (opts.isPreselected) return { picked, isPreselected: true, needsOnePick: false };
53		const known = new Set(picked.map((p) => p.key));
54		const added: Pick[] = [];
55		for (const pick of opts.preselected) {
56			if (known.has(pick.key)) continue;
57			known.add(pick.key);
58			added.push(pick);
59		}
60		return { picked: [...picked, ...added], isPreselected: true, needsOnePick: false };
61	}
62	if (picked.length <= 1) {
63		return { picked, isPreselected: opts.isPreselected, needsOnePick: false };
64	}
65	return { picked: [], isPreselected: opts.isPreselected, needsOnePick: true };
66}
67
68/** Drops the picks that left the list; never adds one. */
69export function keepListed(picked: Pick[], targets: Target[]): Pick[] {
70	const listed = new Set(targets.map((target) => pickOf(target).key));
71	return picked.filter((pick) => listed.has(pick.key));
72}
73
74/**
75 * The targets behind the picks, in the order of the picks. A pick that is gone is a problem,
76 * not a guess: a name that now sits under another key is another session.
77 */
78export function resolvePicks(
79	picked: Pick[],
80	targets: Target[],
81): { targets: Target[]; problems: string[] } {
82	const byKey = new Map(targets.map((target) => [pickOf(target).key, target]));
83	const names = new Set(targets.map((target) => target.name));
84	const resolved: Target[] = [];
85	const problems: string[] = [];
86	for (const pick of picked) {
87		const target = byKey.get(pick.key);
88		if (target !== undefined) resolved.push(target);
89		else if (names.has(pick.name)) problems.push(`${pick.name} is a different session now`);
90		else problems.push(`${pick.name} is no longer on the session list`);
91	}
92	return { targets: resolved, problems };
93}
94
95/** The send button's words, without the hotkey suffix. */
96export function sendLabel(picked: Pick[], kind: BundleKind): string {
97	if (kind === "hand-off") {
98		const [first] = picked;
99		return first === undefined ? "Send" : `Send to ${first.name}`;
100	}
101	const [first] = picked;
102	if (first === undefined) return "Send";
103	if (picked.length === 1) return `Send to 1 session: ${first.name}`;
104	const shown = picked.slice(0, MAX_LISTED_NAMES).map((pick) => pick.name);
105	const more = picked.length > MAX_LISTED_NAMES ? ", …" : "";
106	return `Send to ${picked.length} sessions: ${shown.join(", ")}${more}`;
107}
108
109/** Why Send is not offered yet, or undefined when the picks are fine. */
110export function pickHint(
111	picked: Pick[],
112	kind: BundleKind,
113	needsOnePick: boolean,
114): string | undefined {
115	if (picked.length === 0) {
116		return kind === "hand-off" && needsOnePick ? HAND_OFF_HINT : "pick at least one session";
117	}
118	if (kind === "hand-off" && picked.length > 1) return HAND_OFF_HINT;
119	return undefined;
120}
121