klypix-mcp 1.97.0 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -528,12 +528,22 @@ Future hosts get baseline support merely by connecting the MCP server. A deeper
528
528
 
529
529
  ## Overlap warnings
530
530
 
531
- When two sessions declare overlapping expected files, `brain_sync` surfaces it: the peer, its
532
- declared task, and the exact paths in common. A one-time alert is queued to whichever session got
533
- there first, so a late arrival is not the only one who knows.
534
-
535
- This warns. It does not prevent. Nothing blocks an edit, matching is exact-path, and both sides
536
- have to have declared their files for the overlap to be visible at all.
531
+ `brain_sync` reports three kinds of overlap with other live sessions on the same brain:
532
+
533
+ - **Same file.** Both sessions declared, or were observed editing, the same path. You see the peer,
534
+ its task and the paths in common, and a one-time alert is queued to whichever session got there
535
+ first, so a late arrival is not the only one who knows.
536
+ - **Would conflict at merge.** The two sessions are on different branches, and a test merge of the
537
+ two branch tips (`git merge-tree`, git 2.38 or newer) conflicts in named files, even though no
538
+ declared file overlaps. This sees committed work only: an uncommitted edit is invisible to git.
539
+ It also gets a one-time alert. Files the brain tooling regenerates on every branch (AGENTS.md,
540
+ rule files, `.mcp.json`, `brain.klypix`) are not counted.
541
+ - **Same folder (advisory).** Both sessions work in one folder with no file in common yet. No alert
542
+ is sent. A folder that at least three sessions, and more than half of them, touch is treated as
543
+ noise and not reported.
544
+
545
+ This warns. It does not prevent. Nothing blocks an edit. A session that never declared its files
546
+ and was never observed editing is invisible to the file and folder checks.
537
547
 
538
548
  ## Handoffs and messages
539
549
 
@@ -580,8 +590,36 @@ receives the waiting note at its first action and the sender sees the receipt as
580
590
  waiting for them; `npx klypix-mcp sessions reopen <id>` reopens one (`--quiet` lets it wait for
581
591
  you instead of starting on the note).
582
592
 
583
- Durable handoffs go in the brain itself — decisions, findings, open questions and skills captured
584
- as cards, each stamped with the agent that wrote it.
593
+ ### Session handoff — continue in the next session, with any agent
594
+
595
+ The case this exists for: a Claude Code session stops at its usage limit in the middle of a task,
596
+ and the person opens Codex (or a fresh Claude Code) and says *continue*. Until 1.99.0 the only
597
+ handoff was whatever the person pasted. Now there are two layers, and the person does not have
598
+ to ask for either.
599
+
600
+ **Automatic.** At every turn end the hooks record the session's last state on the lane that already
601
+ remembers sessions for two weeks: the last human prompt, the tail of the last reply (where "done /
602
+ next" usually is), the files it touched, and its *own* commits (the shas its `git commit` printed —
603
+ never a branch range, because twenty sessions can share one checkout). When that session goes quiet
604
+ for ten minutes or closes with its task uncompleted, every new session on the project sees
605
+ **⏸️ Unfinished work left by other sessions** at its first `brain_sync` and at session start — in
606
+ Claude Code, Codex, and any MCP host — with a status (*closed*, *idle 83m*), the files, the commits,
607
+ the last words, and the resume command should the person want the old transcript. The agent takes it
608
+ over with `brain_handoff {action:"take", session:"<id>"}` so peers see it is taken, continues from
609
+ the record, and marks it `done`. Machine-local, like the rest of the lane.
610
+
611
+ **Deliberate.** When a session knows it is stopping — the person asks for a handoff, the context or
612
+ usage limit is near, the day ends, it is blocked, it is switching tool — it calls
613
+ `brain_handoff {action:"write"}` with the task, what is done, what is **next in order**, the files
614
+ and how to verify. That becomes a 🤝 HANDOFF card in the project brain: an open card the person can
615
+ see and correct on the canvas, with the files as evidence anchors, that travels with the repo and
616
+ syncs like every other card. It leads the session-start brief and the `brain_sync` start response
617
+ until a session takes it (`{action:"take", id}`) and finishes it (`{action:"done", id}`, which
618
+ archives it). A later handoff can name the earlier one it `continues`. The per-prompt hook nudges
619
+ the agent to write one when the person says *handoff* or names the limit.
620
+
621
+ Durable decisions still go in the brain as cards — a handoff is the one card about *work in
622
+ flight*, and it closes when the work lands.
585
623
 
586
624
  ## Evidence-gated completion
587
625
 
@@ -757,6 +795,15 @@ lost 0 of 46. If the control ever loses nothing, the run reports **inconclusive*
757
795
 
758
796
  Latest results, with hardware and date: [BENCHMARKS.md](BENCHMARKS.md).
759
797
 
798
+ **Does it change what agents do?** That needs real agents, so it lives in the repository rather than
799
+ the package: [`bench/coordination/`](bench/coordination/README.md) runs Claude Code or Codex on four
800
+ small repos where another session knows something the agent needs (a decision changed today, work
801
+ overlapping on another branch, a note from a session that has ended, a root cause another vendor's
802
+ session found). Each run is graded by a test written after the agent finishes, against no shared
803
+ channel, a human copy-paste, `AGENTS.md` kept tidy or appended, and KLYPIX over MCP only. Every number
804
+ is printed with its sample size and interval; tools it has not measured are listed as not measured.
805
+ `--dry-run` costs nothing and proves the graders.
806
+
760
807
  ## Git and concurrency
761
808
 
762
809
  One file in your repo, committed with your code — versioned, branchable, portable. So two
@@ -872,7 +919,7 @@ The MCP verbs below are what agents call. These are what **you** call:
872
919
 
873
920
  ---
874
921
 
875
- ## The verbs: 25, and 26 on Windows
922
+ ## The verbs: 26, and 27 on Windows
876
923
 
877
924
  | Tool | What it does |
878
925
  |---|---|
@@ -886,6 +933,7 @@ The MCP verbs below are what agents call. These are what **you** call:
886
933
  | `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
887
934
  | `brain_message` | Session-to-session coordination notes — to a live session, or queued for one that is not running until it next starts — with a fixed send-time audience and per-recipient pending / offer / acknowledgement / consumption / failure receipts (a directed note is kept 7 days, a broadcast 24h; never written into the brain) |
888
935
  | `brain_message_receipt` | Explicitly record model-side consumption using the exact message id and per-recipient offer token; acknowledgement alone never consumes a note |
936
+ | `brain_handoff` | The session handoff. `write` puts a 🤝 card in the brain — task, done, next in order, files, verify — when a session stops before the work is done; the next session of any agent sees it at its first `brain_sync` and `take`s it, then marks it `done`. `list` also shows the **unfinished work** KLYPIX recorded automatically for every session (its last reply, files and own commits), which can be taken over by session id |
889
937
  | `brain_reopen` | Reopen a closed Claude Code or Codex session so a note waiting for it gets there now — only after the person answers *Reopen* in an in-chat prompt or KLYPIX's own dialog; opens a new, visible terminal in that session's folder with the host's resume command |
890
938
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
891
939
  | `brain_connect` | Find and draw related-but-unlinked cards |
@@ -903,7 +951,7 @@ The MCP verbs below are what agents call. These are what **you** call:
903
951
  | `add_to_canvas` | Append cards/connections (positions preserved), bordered and readable on KLYPIX's dark and Paper themes; a card's `group` puts it in that titled box. On a canvas open in KLYPIX (Windows) the cards go to KLYPIX itself: live, attributed, one undo. When KLYPIX cannot take them it refuses (`OPEN_IN_APP`, `ACCESS_OFF`, `BLOCKED`), a box locked from AI tools (`SCOPE_LOCKED`) or a frozen box (`FROZEN`), and writes nothing; project brains are the exception to the first. Returns the new card ids |
904
952
  | `list_canvases` | List every `.klypix` in the vault |
905
953
 
906
- Exactly 25 on Linux and macOS and 26 on Windows, where `show_in_klypix` is registered; machine-verifiable with `npx klypix-mcp doctor`, whose TOOLS line applies the same rule and whose App bridge line says whether KLYPIX is running, with access on or off.
954
+ Exactly 26 on Linux and macOS and 27 on Windows, where `show_in_klypix` is registered; machine-verifiable with `npx klypix-mcp doctor`, whose TOOLS line applies the same rule and whose App bridge line says whether KLYPIX is running, with access on or off.
907
955
 
908
956
  > **`canvas_view`:** no MCP Apps host has been observed rendering the UI resource yet — there is no
909
957
  > screenshot and no host-level test. Hosts without the extension get clean text, which is the path
@@ -1105,9 +1153,10 @@ Read this section before you build on any of it.
1105
1153
  loop prevention, stable message IDs and per-recipient-machine acknowledgement primitives), but no
1106
1154
  transport: carrying frames between machines is the desktop app's job. With `klypix-mcp` alone,
1107
1155
  coordination is machine-local.
1108
- - **Overlap matching is exact-path, and both sides must declare.** A session that never declares
1109
- its expected files is invisible to overlap detection, and `src/auth/token.ts` does not match a
1110
- rename or a parent directory.
1156
+ - **File overlap is exact-path.** A session needs declared files or observed edits to be checked;
1157
+ `src/auth/token.ts` does not match a rename. Folder overlap is advisory. The merge test sees
1158
+ committed work only, needs git 2.38 or newer, and runs only for sessions on different local
1159
+ branches of the same repository.
1111
1160
  - **Overlap warnings are advisory.** Nothing is blocked. One severity string in the payload reads
1112
1161
  `blocking`; the mechanism is not.
1113
1162
  - **Codex has no automatic capture**, with or without `--codex-hooks`. The Codex hook never writes
@@ -243,6 +243,7 @@ const DELIBERATELY_UNWIRED = Object.freeze({
243
243
  opBrainReconcile: 'wire-supplied roots can traverse host directories; keep MCP-only',
244
244
  opBrainGarden: 'apply requires an out-of-band human approval code and must never be weakened for A2A',
245
245
  opBrainMessage: 'send-only machine-local messaging has no receivable A2A half',
246
+ opBrainHandoff: 'take/done stamp the calling session identity on the lane; A2A has no authenticated session identity (MCP-only)',
246
247
  });
247
248
 
248
249
  const canonicalPath = (p) => {
@@ -493,7 +493,7 @@ try {
493
493
  // a newer klypix-format cannot. merge-brains and the driver ship here so
494
494
  // brain-history's restore merge, the KLYPIX core and the git driver all
495
495
  // find one engine in this directory.
496
- for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
496
+ for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'session-handoff.mjs', 'canvas-view-app.html']) {
497
497
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
498
498
  }
499
499
  for (const [src, dst] of [
@@ -28,7 +28,7 @@ import {
28
28
  resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema, groupSchema,
29
29
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
- opBrainTaskContext,
31
+ opBrainTaskContext, opBrainHandoff,
32
32
  } from '../src/klypix-core.mjs';
33
33
  import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
34
34
  import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
@@ -1064,9 +1064,64 @@ server.registerTool('brain_reopen', {
1064
1064
  return reply('reopened', `🔓 Reopened ${label} — the human said yes${consent.via === 'dialog' ? ' in the KLYPIX dialog' : ''}. It opened in a new ${plan.method === 'windows-terminal' ? 'Windows Terminal window' : plan.method === 'macos-terminal' ? 'Terminal window' : 'terminal window'} in ${candidate.cwd} with ${candidate.command}${consent.start ? ', and starts on the note right away' : ', waiting for the human to prompt it'}. ${notes === 1 ? 'The waiting note is' : `The ${notes} waiting notes are`} delivered at its first action; your next prompt shows the receipt. If the window shows an error (for example the command is not found), the note stays queued — ${manualLine}.`, { via: consent.via, method: plan.method, cwd: candidate.cwd, command: candidate.command, start: Boolean(consent.start), notes });
1065
1065
  });
1066
1066
 
1067
+ // ── brain_handoff (1.99.0): a session hands its unfinished work to the next ─
1068
+ // Two layers. AUTOMATIC: every Stop hook records the session's last state on
1069
+ // the lane (intent, files, own commits, the tail of its last reply); when the
1070
+ // session goes quiet or closes with its task uncompleted, every new session of
1071
+ // any agent sees "unfinished work" at its first brain_sync. DELIBERATE: this
1072
+ // verb's `write` puts a 🤝 HANDOFF card into the brain — what is done, what is
1073
+ // next, files, how to verify — which the next session takes over by id.
1074
+ server.registerTool('brain_handoff', {
1075
+ title: 'Hand unfinished work to the next session — or take over what another session left',
1076
+ description: 'The session handoff. Call {action:"write"} when you are stopping before the work is done — the human asked for a handoff, your context or usage limit is near, the day ends, you are blocked or switching tool — with the task, what is done, what is NEXT in order, the files and how to verify: it becomes a 🤝 card in the project brain that the human can see and correct, and the next session on this project (any agent — Codex, Claude Code, Cursor) sees it at its first brain_sync / session start. Call {action:"take", id} (a card) or {action:"take", session} (unfinished work a session left without a card — KLYPIX records every session\'s last reply, files and commits automatically) before continuing it, so peers see it is taken; {action:"done"} when it is finished (the card is archived); {action:"list"} shows every open handoff and every unfinished session. Never ask the human to paste a summary into the next session — write a handoff. Machine-local for the automatic layer; the card travels with the brain.',
1077
+ annotations: {
1078
+ destructiveHint: false,
1079
+ idempotentHint: false,
1080
+ openWorldHint: false,
1081
+ },
1082
+ inputSchema: {
1083
+ action: z.enum(['write', 'take', 'done', 'list']).optional().describe('write (default): record a handoff card. take: claim a handoff card (id) or unfinished work (session) before continuing it. done: mark it finished. list: every open handoff card and every unfinished session on this project.'),
1084
+ project: z.string().optional().describe('Absolute project root containing brain.klypix. Defaults to this connection\'s project.'),
1085
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
1086
+ title: z.string().max(120).optional().describe('write: the work, in a few words ("Drive upload: the Place-it-now chooser").'),
1087
+ task: z.string().max(400).optional().describe('write: one or two sentences — what the work is and why.'),
1088
+ done: z.array(z.string().max(240)).max(12).optional().describe('write: what is already finished (so it is not redone).'),
1089
+ next: z.array(z.string().max(240)).max(12).optional().describe('write: the remaining steps IN ORDER, as the next session should do them. Required.'),
1090
+ files: z.array(z.string().max(200)).max(20).optional().describe('write: project-relative files the work touches (recorded as evidence anchors).'),
1091
+ verify: z.string().max(400).optional().describe('write: how to check the state — a command or a visible result.'),
1092
+ open: z.array(z.string().max(240)).max(8).optional().describe('write: questions still open, or decisions the human has to make.'),
1093
+ reason: z.enum(['context_limit', 'usage_limit', 'end_of_session', 'blocked', 'switching_tool', 'other']).optional().describe('write: why the work stops here.'),
1094
+ area: z.string().max(40).optional().describe('write: the brain area (titled box) the card goes into. Defaults to "Handoff".'),
1095
+ continues: z.string().max(64).optional().describe('write: the id of an earlier handoff card this one continues — it is closed as "continued by" this one.'),
1096
+ id: z.string().max(64).optional().describe('take / done: the handoff card id (from brain_sync, the session-start brief, or list).'),
1097
+ session: z.string().max(160).optional().describe('take / done: the session id (or a unique prefix of 8+ characters) from the unfinished-work list, when the work has no card.'),
1098
+ summary: z.string().max(400).optional().describe('done: what was finished, in one or two sentences. take: an optional note.'),
1099
+ },
1100
+ }, async ({ action, project, canvas, ...rest }, extra) => {
1101
+ const identity = extra?.klypixRequestIdentity || {};
1102
+ const result = await opBrainHandoff({
1103
+ vault: project ? path.resolve(project) : mcpPresence.vault,
1104
+ canvas: canvas || boundBrainCanvas(undefined),
1105
+ action: action || 'write',
1106
+ from: { sessionId: identity.sessionId || mcpPresence.id, client: identity.clientInfo?.client || extra?.klypixClientName || 'unknown' },
1107
+ via: extra?.klypixClientName,
1108
+ ...rest,
1109
+ });
1110
+ // Capture receipt, as for brain_note: a written handoff IS this session's
1111
+ // reasoning recorded through MCP — the uncaptured-work nudge must not fire.
1112
+ if ((action || 'write') === 'write' && !result.isError) {
1113
+ try {
1114
+ const { recordSessionCapture } = await import('../src/capture-gap.mjs');
1115
+ recordSessionCapture(identity.sessionId || mcpPresence.id, undefined, Date.now(), { project: mcpPresence.vault });
1116
+ } catch { /* receipt is best-effort */ }
1117
+ }
1118
+ const out = toContent(result);
1119
+ return result.structured ? { ...out, structuredContent: result.structured } : out;
1120
+ });
1121
+
1067
1122
  server.registerTool('brain_sync', {
1068
1123
  title: 'KLYPIX Context Gateway — synchronize task, peers, conflicts, and relevant memory',
1069
- description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: conflict matching is EXACT-PATH and both sessions must have declared their files, coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1124
+ description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. `results` is ONLY for such manifests, never a summary of the work: with nothing machine-checkable to claim, complete without it. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: blocking overlap is EXACT-PATH over the declared files of each session plus the edits its host observed; sessions on different branches are also test-merged with git merge-tree (committed work only); a same-folder overlap is an advisory notice; coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1070
1125
  annotations: {
1071
1126
  destructiveHint: false,
1072
1127
  idempotentHint: true,
@@ -1343,6 +1398,23 @@ server.registerTool('brain_sync', {
1343
1398
  .map((block) => block.text)
1344
1399
  .filter(Boolean)
1345
1400
  .join('\n\n') || '';
1401
+ // ── Handoffs (1.99.0): on task START only — the one moment a session asked
1402
+ // to "continue" needs them. Open 🤝 cards + unfinished work other sessions
1403
+ // left on the lane, as one block; a second brain parse, bounded, never
1404
+ // fails a sync. Checkpoints and completions print nothing here.
1405
+ let handoffText = '';
1406
+ let handoffData = null;
1407
+ if (phase === 'start' && include_context !== false && report.structured?.brain) {
1408
+ try {
1409
+ const h = await opBrainHandoff({ vault: mcpPresence.vault, canvas: report.structured.brain, action: 'list', from: { sessionId: extra?.klypixRequestIdentity?.sessionId || mcpPresence.id } });
1410
+ const cards = h?.structured?.handoffs || [];
1411
+ const lane = h?.structured?.unfinished || [];
1412
+ if (!h?.isError && (cards.length || lane.length)) {
1413
+ handoffText = String(h.blocks?.[0]?.text || '').replace(/^_brain:[^\n]*_\n\n/, '');
1414
+ handoffData = { cards: cards.length, unfinished: lane.length, handoffs: cards.map((c) => ({ id: c.id, title: c.title, from: c.from, at: c.at, reason: c.reason, state: c.state, next: c.next, files: c.files })), sessions: lane.map((r) => ({ id: r.id, client: r.client, intent: r.intent, status: r.status, lastSeen: r.lastSeen, files: r.files, commits: r.commits, handoffCardId: r.handoffCardId, takenBy: r.takenBy })) };
1415
+ }
1416
+ } catch { /* never fails a sync */ }
1417
+ }
1346
1418
  const totalMs = Math.max(0, Date.now() - totalStartedAt);
1347
1419
  const structuredContent = {
1348
1420
  ...(report.structured || {
@@ -1363,6 +1435,7 @@ server.registerTool('brain_sync', {
1363
1435
  },
1364
1436
  ...(registration ? { registration } : {}),
1365
1437
  ...(harness ? { harness } : {}),
1438
+ ...(handoffData ? { handoffs: handoffData } : {}),
1366
1439
  };
1367
1440
  const harnessText = harness?.failed
1368
1441
  ? `KLYPIX harness self-heal needs attention: ${harness.failed} project(s) remain partially aligned; brain_doctor has the exact files.`
@@ -1374,7 +1447,7 @@ server.registerTool('brain_sync', {
1374
1447
  return {
1375
1448
  content: [{
1376
1449
  type: 'text',
1377
- text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, contextText, timingText].filter(Boolean).join('\n\n'),
1450
+ text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, handoffText, contextText, timingText].filter(Boolean).join('\n\n'),
1378
1451
  }],
1379
1452
  structuredContent,
1380
1453
  ...(report.isError ? { isError: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.97.0",
3
+ "version": "1.99.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -86,7 +86,7 @@
86
86
  "bench": "node bin/klypix-mcp.mjs bench",
87
87
  "test:bench": "node test/bench.mjs",
88
88
  "pretest": "node test/publish-workflow.mjs",
89
- "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
89
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/session-handoff.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
90
90
  "test:memory": "node test/memory-runtime.mjs",
91
91
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
92
92
  "runtime": "node bin/klypix-runtime.mjs",
@@ -493,6 +493,55 @@ const endedMatches = (row, id) => {
493
493
  // ── Session directory upkeep (mailbox) ───────────────────────────────────────
494
494
  // PARITY: global-brain-hook.mjs keeps a verbatim twin (dirPrune/dirRemember)
495
495
  // because that hook stays free of sibling imports — change both.
496
+ //
497
+ // ── Unfinished work (session handoff, 1.99.0) ────────────────────────────────
498
+ // A session's last known STATE rides its directory entry as `work`, so the
499
+ // work a session stopped in the middle of — at a usage or context limit, a
500
+ // closed window, a crash — is visible to the next session of ANY agent, with
501
+ // no one having to write or paste a summary:
502
+ // lastPrompt the last human prompt the session answered
503
+ // lastWords the tail of its last reply (where "done / next" usually is)
504
+ // commits commits made since the session started (sha + subject)
505
+ // baseHead the HEAD the session started from (what `commits` counts from)
506
+ // handoffCardId a deliberate 🤝 handoff card it wrote into the brain
507
+ // takenBy the session that took the work over (brain_handoff take)
508
+ // doneAt/doneBy marked finished (brain_handoff done)
509
+ // `intentAt` and `completedAt` are copied from the lane row for the same
510
+ // reason: a session whose intent was never completed is the one to show.
511
+ // Every field is capped here, and an empty object is dropped, so an old entry
512
+ // without `work` is byte-stable through a touch that changes nothing.
513
+ export const WORK_LAST_WORDS_MAX = 700;
514
+ export const WORK_COMMITS_MAX = 6;
515
+ export function normalizeWork(raw) {
516
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
517
+ const out = {};
518
+ const str = (value, max) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
519
+ const ms = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : null);
520
+ // Both texts are rendered into OTHER sessions' prompts: a capture marker
521
+ // inside them must never be harvestable there (see neutralizeMarkers).
522
+ if (raw.lastPrompt) out.lastPrompt = neutralizeMarkers(str(raw.lastPrompt, 240));
523
+ if (raw.lastWords) out.lastWords = neutralizeMarkers(String(raw.lastWords).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim().slice(0, WORK_LAST_WORDS_MAX));
524
+ if (ms(raw.lastWordsAt)) out.lastWordsAt = ms(raw.lastWordsAt);
525
+ if (raw.baseHead) out.baseHead = String(raw.baseHead).trim().slice(0, 40);
526
+ if (Array.isArray(raw.commits)) {
527
+ const commits = raw.commits
528
+ .map((c) => (c && typeof c === 'object' ? { sha: str(c.sha, 12), subject: str(c.subject, 120) } : null))
529
+ .filter((c) => c && c.sha)
530
+ .slice(0, WORK_COMMITS_MAX);
531
+ if (commits.length) out.commits = commits;
532
+ }
533
+ if (raw.handoffCardId) out.handoffCardId = str(raw.handoffCardId, 64);
534
+ if (raw.takenBy && typeof raw.takenBy === 'object' && raw.takenBy.id) {
535
+ out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
536
+ }
537
+ if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
538
+ return Object.keys(out).length ? out : null;
539
+ }
540
+ export function mergeWork(previous, patch) {
541
+ if (patch === undefined) return normalizeWork(previous);
542
+ if (patch === null) return null;
543
+ return normalizeWork({ ...(normalizeWork(previous) || {}), ...(patch && typeof patch === 'object' ? patch : {}) });
544
+ }
496
545
  const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
497
546
  .filter((row) => row && recipientKey(row.id) && now - Number(row.lastSeen || 0) < DIRECTORY_FRESH_MS)
498
547
  .map((row) => ({
@@ -508,6 +557,9 @@ const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
508
557
  lastSeen: Number(row.lastSeen) || now,
509
558
  endedAt: Number(row.endedAt) || null,
510
559
  hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : null,
560
+ ...(Number(row.intentAt) > 0 ? { intentAt: Number(row.intentAt) } : {}),
561
+ ...(Number(row.completedAt) > 0 ? { completedAt: Number(row.completedAt) } : {}),
562
+ ...(normalizeWork(row.work) ? { work: normalizeWork(row.work) } : {}),
511
563
  }))
512
564
  .sort((left, right) => Number(left.lastSeen) - Number(right.lastSeen))
513
565
  .slice(-DIRECTORY_CAP);
@@ -524,6 +576,12 @@ function rememberSession(directory, row, now, { ended = false } = {}) {
524
576
  const prev = index >= 0 ? rows[index] : null;
525
577
  const scope = [...new Set([...normalizeFiles(row.files), ...normalizeFiles(row.observedFiles)])].slice(-8);
526
578
  const intent = String(row.intent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
579
+ // The lane row's own timestamps decide "unfinished": an intent newer than
580
+ // its completion is work nobody finished. A row that carries neither keeps
581
+ // what the directory already knew (a hook twin predating 1.99.0).
582
+ const intentAt = Number(row.intentAt) > 0 ? Number(row.intentAt) : (Number(prev?.intentAt) > 0 ? Number(prev.intentAt) : null);
583
+ const completedAt = Number(row.completedAt) > 0 ? Number(row.completedAt) : (row.completedAt === null && intent ? null : (Number(prev?.completedAt) > 0 ? Number(prev.completedAt) : null));
584
+ const work = mergeWork(prev?.work, row.work);
527
585
  const entry = {
528
586
  id,
529
587
  client: String(row.client || prev?.client || 'unknown').slice(0, 40),
@@ -537,6 +595,9 @@ function rememberSession(directory, row, now, { ended = false } = {}) {
537
595
  lastSeen: now,
538
596
  endedAt: ended ? now : null,
539
597
  hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : (prev?.hostStatus || null),
598
+ ...(intentAt ? { intentAt } : {}),
599
+ ...(completedAt ? { completedAt } : {}),
600
+ ...(work ? { work } : {}),
540
601
  };
541
602
  if (index >= 0) rows[index] = entry; else rows.push(entry);
542
603
  return rows
@@ -957,6 +1018,10 @@ export function upsertSession({
957
1018
  // Host activity status (mailbox, 1.88.0): 'busy' | 'idle'. Derived from
958
1019
  // `event` when a writer does not pass it; heartbeats keep the previous value.
959
1020
  hostStatus = null,
1021
+ // Unfinished-work state (session handoff, 1.99.0): a PATCH merged over the
1022
+ // row's `work` (see normalizeWork) — last reply tail, commits, base head,
1023
+ // handoff card, taken/done stamps. Absent = keep what the row has.
1024
+ work,
960
1025
  now = Date.now(),
961
1026
  }) {
962
1027
  if (!brainPath || !id) return withWriteVerdict([], false, 'no-brain-or-id');
@@ -1119,6 +1184,9 @@ export function upsertSession({
1119
1184
  ...(taskCompleted ? { completedAt: now } : (previous.completedAt ? { completedAt: previous.completedAt } : {})),
1120
1185
  }),
1121
1186
  ...(statusDigestHash !== undefined ? { statusDigestHash: String(statusDigestHash || '').slice(0, 16) } : {}),
1187
+ // ADDITIVE unfinished-work state: merged only when this touch carries a
1188
+ // patch; the `...previous` spread keeps it verbatim otherwise.
1189
+ ...(work !== undefined ? (mergeWork(previous.work, work) ? { work: mergeWork(previous.work, work) } : {}) : {}),
1122
1190
  // ADDITIVE host status: stamped only when this touch carries one, kept
1123
1191
  // verbatim otherwise, so a heartbeat never turns "working" into unknown.
1124
1192
  ...(nextHostStatus
@@ -2824,6 +2892,144 @@ export function listKnownSessions({ brainPath, home, now = Date.now() } = {}) {
2824
2892
  }).sort((left, right) => Number(right.lastSeen) - Number(left.lastSeen));
2825
2893
  }
2826
2894
 
2895
+ // ── Unfinished work — the automatic session handoff (1.99.0) ────────────────
2896
+ // The field case (2026-10-11): the founder's Claude Code session stops at a
2897
+ // usage limit mid-task, and Codex is asked to "continue" — with nothing to
2898
+ // continue from but whatever the human pastes. The lane already remembers
2899
+ // every exactly-identified session for two weeks; `work` (see normalizeWork)
2900
+ // adds the state a Stop hook can record without anyone writing a summary.
2901
+ // This is the READ side: every session whose intent was never completed and
2902
+ // that nobody is working on right now — closed, or live but quiet — newest
2903
+ // first, with its last words, commits and files, and whether another session
2904
+ // already took it over. Nothing here starts, wakes or routes a session.
2905
+ export const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
2906
+ export function clientWord(client) {
2907
+ const key = String(client || '').toLowerCase();
2908
+ if (key === 'claude-code' || key === 'claude') return 'Claude Code';
2909
+ if (key === 'codex') return 'Codex';
2910
+ return key ? key.replace(/(^|[-_ ])([a-z])/g, (_m, p, c) => `${p}${c.toUpperCase()}`) : 'Unknown';
2911
+ }
2912
+ // How long since the row's host last did WORK: a heartbeat refreshes lastSeen
2913
+ // without touching it, so a connected-but-idle MCP session reads as quiet, not
2914
+ // as busy. A row with no host status falls back to its last activity/intent.
2915
+ const quietFor = (row, now) => {
2916
+ if (!row) return Infinity;
2917
+ const status = String(row.hostStatus || '');
2918
+ if (status === 'busy' && now - Number(row.hostStatusAt || row.lastSeen || now) < HOST_STATUS_BUSY_FRESH_MS) return 0;
2919
+ const at = Number(row.hostStatusAt || row.activityAt || row.intentAt || row.startedAt || row.lastSeen || now);
2920
+ return Math.max(0, now - at);
2921
+ };
2922
+ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId = '', includeWorking = false } = {}) {
2923
+ if (!brainPath) return [];
2924
+ const lane = readLane(laneFileFor(brainPath, home));
2925
+ const live = pruneSessions(lane.sessions, now);
2926
+ const liveRow = (id) => live.find((session) => sessionIdentityKeys(session).includes(String(id || '').toLowerCase())) || null;
2927
+ const self = recipientKey(selfId).toLowerCase();
2928
+ return pruneDirectory(lane.directory, now)
2929
+ .filter((entry) => entry.intent && entry.id.toLowerCase() !== self)
2930
+ .map((entry) => {
2931
+ const row = liveRow(entry.id);
2932
+ const work = entry.work || {};
2933
+ const intentAt = Number(entry.intentAt) || Number(entry.firstSeen) || 0;
2934
+ const completed = Number(entry.completedAt) > 0 && Number(entry.completedAt) >= intentAt;
2935
+ const done = Number(work.doneAt) > 0 && Number(work.doneAt) >= intentAt;
2936
+ const taker = work.takenBy ? liveRow(work.takenBy.id) : null;
2937
+ const quietMs = quietFor(row, now);
2938
+ return { entry, row, work, completed, done, taker, quietMs };
2939
+ })
2940
+ .filter(({ completed, done, quietMs }) => !completed && !done && (includeWorking || quietMs >= UNFINISHED_QUIET_MS))
2941
+ .map(({ entry, row, work, taker, quietMs }) => ({
2942
+ id: entry.id,
2943
+ client: entry.client,
2944
+ intent: entry.intent,
2945
+ intentAt: entry.intentAt || null,
2946
+ branch: entry.branch || null,
2947
+ cwd: entry.cwd || null,
2948
+ files: Array.isArray(entry.files) ? entry.files : [],
2949
+ lastSeen: entry.lastSeen,
2950
+ endedAt: entry.endedAt || null,
2951
+ ageMs: Math.max(0, now - Number(entry.lastSeen || now)),
2952
+ quietMs: Number.isFinite(quietMs) ? quietMs : null,
2953
+ live: Boolean(row),
2954
+ status: row
2955
+ ? (sessionStatusLabel(row, now) || 'live')
2956
+ : (entry.endedAt ? 'closed' : `not seen for ${agoLabel(now - entry.lastSeen).replace(/ ago$/, '')}`),
2957
+ resumeCommand: row ? '' : resumeCommandFor(entry.client, entry.id),
2958
+ lastPrompt: work.lastPrompt || '',
2959
+ lastWords: work.lastWords || '',
2960
+ lastWordsAt: work.lastWordsAt || null,
2961
+ commits: Array.isArray(work.commits) ? work.commits : [],
2962
+ handoffCardId: work.handoffCardId || null,
2963
+ takenBy: work.takenBy
2964
+ ? { ...work.takenBy, live: Boolean(taker), status: taker ? (sessionStatusLabel(taker, now) || 'live') : 'not running' }
2965
+ : null,
2966
+ }))
2967
+ .sort((left, right) => Number(right.lastSeen) - Number(left.lastSeen));
2968
+ }
2969
+
2970
+ // Stamp unfinished-work state onto one remembered session (directory entry
2971
+ // AND its live row when there is one): the writer of a handoff card, the
2972
+ // session that takes work over, the one that marks it done. Exact id or a
2973
+ // unique prefix; ambiguity and unknown ids fail closed. Machine-local.
2974
+ export function stampSessionWork({ brainPath, sessionId, patch, home, now = Date.now() }) {
2975
+ if (!brainPath || !sessionId || !patch || typeof patch !== 'object') return { ok: false, reason: 'invalid-stamp' };
2976
+ const laneFile = laneFileFor(brainPath, home);
2977
+ const lockFile = laneFile + '.lock';
2978
+ if (!acquireLock(lockFile)) return { ok: false, reason: 'lane-locked' };
2979
+ try {
2980
+ const laneRead = readMutableLane(laneFile);
2981
+ if (!laneRead.ok) return { ok: false, reason: laneRead.reason };
2982
+ const data = laneRead.data;
2983
+ const sessions = pruneSessions(data.sessions, now);
2984
+ const directory = pruneDirectory(data.directory, now);
2985
+ const wanted = String(sessionId).trim();
2986
+ const known = resolveDirectoryTarget(wanted, directory);
2987
+ if (known.ambiguous) return { ok: false, reason: 'target-not-unique' };
2988
+ const entry = known.entry;
2989
+ const keys = [wanted.toLowerCase(), ...(entry ? [entry.id.toLowerCase()] : [])];
2990
+ const row = sessions.find((session) => sessionIdentityKeys(session).some((key) => keys.includes(key))) || null;
2991
+ if (!entry && !row) return { ok: false, reason: 'target-unknown' };
2992
+ if (entry) { const merged = mergeWork(entry.work, patch); if (merged) entry.work = merged; else delete entry.work; }
2993
+ if (row) { const merged = mergeWork(row.work, patch); if (merged) row.work = merged; else delete row.work; }
2994
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
2995
+ writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, directory }));
2996
+ return { ok: true, id: entry?.id || row?.logicalSessionId || row?.id, entry: entry || null };
2997
+ } catch (error) {
2998
+ return { ok: false, reason: writeFailureReason(error) };
2999
+ } finally {
3000
+ releaseLock(lockFile);
3001
+ }
3002
+ }
3003
+
3004
+ // The block every surface prints (brain_sync, the Claude and Codex hooks):
3005
+ // one record per unfinished session, newest first, then how to take it over.
3006
+ // Quoted text is another session's record and is labelled as such — never as
3007
+ // the human's instructions; markers inside it were neutralized at store time.
3008
+ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null } = {}) {
3009
+ const list = Array.isArray(records) ? records : [];
3010
+ if (!list.length) return '';
3011
+ const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
3012
+ const lines = [`⏸️ Unfinished work left by other sessions on this project (${list.length}, newest first) — each stopped before its task was done and nobody is working on it now:`];
3013
+ for (const r of list.slice(0, max)) {
3014
+ const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
3015
+ const bits = [r.status, `last active ${agoLabel(r.ageMs)}`];
3016
+ if (r.branch) bits.push(`branch ${r.branch}`);
3017
+ lines.push(`- ${who} · “${flat(r.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
3018
+ if (r.commits.length) lines.push(` commits: ${r.commits.map((c) => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
3019
+ if (r.files.length) lines.push(` files: ${r.files.slice(-8).join(', ')}`);
3020
+ if (r.lastWords) lines.push(` its last words: “${flat(r.lastWords).slice(0, 420)}”`);
3021
+ if (r.handoffCardId) {
3022
+ const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
3023
+ lines.push(` 🤝 it left a handoff card in the brain: ${r.handoffCardId}${title ? ` — “${flat(title).slice(0, 100)}”` : ''} (brain_handoff {action:"list"} prints it in full)`);
3024
+ }
3025
+ if (r.takenBy) lines.push(` taken over by ${clientWord(r.takenBy.client)} ${String(r.takenBy.id).slice(0, 8)} ${r.takenBy.at ? agoLabel(now - r.takenBy.at) : ''} (${r.takenBy.status}) — coordinate with brain_message before duplicating it`);
3026
+ if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
3027
+ }
3028
+ if (list.length > max) lines.push(`- …and ${list.length - max} more — brain_handoff {action:"list"} shows all.`);
3029
+ lines.push('To continue one: brain_handoff {action:"take", session:"<its id>"} (peers then see it is taken), then brain_sync with that intent and files; when it is finished, brain_handoff {action:"done", session:"<its id>"}. If it is not what the human asked for, leave it.');
3030
+ return lines.join('\n');
3031
+ }
3032
+
2827
3033
  // ── Reopen on the human's OK (1.91.0) ───────────────────────────────────────
2828
3034
  // A note to a session that has closed waits up to a week and is delivered the
2829
3035
  // moment that session next acts — but only if a human happens to reopen it.
@@ -86,6 +86,8 @@ const CODEX_GLOBAL_BODY = [
86
86
  'To reach another agent session on this project, call `brain_message` with its session id (from',
87
87
  '`brain_sync` or `brain_doctor`); a session that has closed receives the note when it next starts.',
88
88
  'Never ask the human to carry a message between agents — send it and say it was sent or queued.',
89
+ 'Stopping before the work is done (limit, end of day, blocked)? Call `brain_handoff` {action:"write"} with what is done, what is next, files and how to verify;',
90
+ 'the next session of any agent sees it at its first `brain_sync` and takes it with {action:"take"}. Asked to continue what another session left? Take it there first.',
89
91
  'Capture durable decisions and milestones with `brain_note`. Never hand-edit `brain.klypix`.',
90
92
  'If the project has no `brain.klypix`, ignore this section.',
91
93
  CODEX_GLOBAL_END,
@@ -503,6 +505,16 @@ otherwise ask the human to relay or paste something to another agent session, se
503
505
  \`brain_message\` refuses. Notes are ephemeral, NOT brain cards — durable decisions still go
504
506
  through \`brain_note\`.
505
507
 
508
+ **Stopping before the work is done?** (the human asks for a handoff, your context or usage limit
509
+ is near, the day ends, you are blocked, you are switching tool) Call \`brain_handoff\`
510
+ {action:"write"} with the task, what is done, what is NEXT in order, the files and how to verify.
511
+ It is a 🤝 card in this brain the human can see and correct, and the next session on this project —
512
+ any agent — sees it at its first \`brain_sync\` / session start and takes it with {action:"take"},
513
+ then {action:"done"} when finished. KLYPIX also records every session's last reply, files and
514
+ commits automatically, so \`brain_sync\` may list **unfinished work** another session left without
515
+ a card: if the human asks you to continue it, take it with \`brain_handoff\` {action:"take",
516
+ session:"<id>"} first. Never ask the human to paste a summary into the next session.
517
+
506
518
  **Don't** hand-edit \`brain.klypix\` (it's a packaged canvas — use the tools) or dump file contents into it; capture the *decision*, not the file.`;
507
519
 
508
520
  // Content fingerprint of the canonical instructions — stamped into the fence so a