Claude Code mod: cross-session handoff bundles

Hand off work between parallel Claude Code sessions with one explicit gesture: /over <intent>.
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
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:
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.
Not shipped yet:
plugin/.claude-code-version; the mods API itself needs 2.1.287 or later.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.
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.
| Command | What it does |
|---|---|
/over | Opens 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 --list | Prints 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-repo | Sends 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 --status | Prints 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.
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 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)
worktree (branch)), the status and the time of its last turn.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.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.
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".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".
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.
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.
/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./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.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.
[claude-over <hand-off|broadcast> <bundle-id>]Send: <send-id>: new for every send; the bundle id stays the sameFrom: <name> · repo <repo> · branch <branch> · session <session-id>Approved by the user: …Handling: …: what the recipient does with the work; the text depends on the typeover-ack <bundle-id> <send-id> back to the from address of the envelopeAfter 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.
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:
| Warning | Meaning | Status |
|---|---|---|
Cross-session message held by the receiving session (recipient: @X) | X holds incoming messages for its user | held |
Cross-session message released after approval (recipient: @X) | X's user approved a held message | passed on to X (approved by the recipient, queued) |
Cross-session message denied (recipient: @X) | X's user declined a held message | not passed on to X: the recipient's user declined it |
Cross-session message refused (recipient: @X) | X does not accept messages | not passed on |
Cross-session message expired without approval (recipient: @X) | nobody approved it, nothing is waiting in X | expired |
Cross-session message was dropped at the recipient session's inbox (recipient: @X) ... | the same text was sent twice | not passed on |
an over-ack <bundle-id> <send-id> reply from X (not a platform warning) | X has acknowledged this send | received 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.
Send: <id>, while the bundle id stays the same, so sending one bundle again works.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.waiting) can be sent to, but may not read the message for a long time.SendMessage tool. Without it the platform refuses every send and the mod says so.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.no details./rename a session to recognise it.passed on (queued) although a warning concerned them./over --send-repo also skips by session name, so a second session with the same name as one already sent to is skipped too./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 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.cc-over/
├── .claude-plugin/
│ └── marketplace.json # plugin marketplace "cc-over", points at ./plugin
├── plugin/ hooks/register.ts 1766 lines1// 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 lines1// 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);
40hooks/args.ts 75 lines1import 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}
75hooks/bundle.ts 277 lines1// 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}
277hooks/git.ts 307 lines1// 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}
307hooks/groups.ts 201 lines1// 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}
201hooks/history.ts 494 lines1// 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}
494hooks/history-pane.ts 95 lines1// 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}
95hooks/notice.ts 260 lines1// 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}
260hooks/registry.ts 181 lines1// 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}
181hooks/report.ts 43 lines1// 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}
43hooks/selection.ts 121 lines1// 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