klypix-mcp 1.98.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 +33 -4
- package/bin/klypix-a2a.mjs +1 -0
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-worker.mjs +75 -2
- package/package.json +2 -2
- package/src/agent-presence.mjs +206 -0
- package/src/agent-rules.mjs +12 -0
- package/src/codex-brain-hook.mjs +38 -0
- package/src/global-brain-hook.mjs +158 -1
- package/src/klypix-core.mjs +199 -2
- package/src/klypix-format.mjs +110 -0
- package/src/mcp-presence.mjs +1 -0
- package/src/session-handoff.mjs +281 -0
package/README.md
CHANGED
|
@@ -590,8 +590,36 @@ receives the waiting note at its first action and the sender sees the receipt as
|
|
|
590
590
|
waiting for them; `npx klypix-mcp sessions reopen <id>` reopens one (`--quiet` lets it wait for
|
|
591
591
|
you instead of starting on the note).
|
|
592
592
|
|
|
593
|
-
|
|
594
|
-
|
|
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.
|
|
595
623
|
|
|
596
624
|
## Evidence-gated completion
|
|
597
625
|
|
|
@@ -891,7 +919,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
891
919
|
|
|
892
920
|
---
|
|
893
921
|
|
|
894
|
-
## The verbs:
|
|
922
|
+
## The verbs: 26, and 27 on Windows
|
|
895
923
|
|
|
896
924
|
| Tool | What it does |
|
|
897
925
|
|---|---|
|
|
@@ -905,6 +933,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
905
933
|
| `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
|
|
906
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) |
|
|
907
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 |
|
|
908
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 |
|
|
909
938
|
| `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
|
|
910
939
|
| `brain_connect` | Find and draw related-but-unlinked cards |
|
|
@@ -922,7 +951,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
922
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 |
|
|
923
952
|
| `list_canvases` | List every `.klypix` in the vault |
|
|
924
953
|
|
|
925
|
-
Exactly
|
|
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.
|
|
926
955
|
|
|
927
956
|
> **`canvas_view`:** no MCP Apps host has been observed rendering the UI resource yet — there is no
|
|
928
957
|
> screenshot and no host-level test. Hosts without the extension get clean text, which is the path
|
package/bin/klypix-a2a.mjs
CHANGED
|
@@ -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) => {
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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 [
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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,6 +1064,61 @@ 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
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.',
|
|
@@ -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.
|
|
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/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",
|
|
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",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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.
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
findProjectBrain,
|
|
13
13
|
formatPresenceMessage,
|
|
14
14
|
formatReceivedMessages,
|
|
15
|
+
formatUnfinishedWork,
|
|
16
|
+
listUnfinishedWork,
|
|
15
17
|
peekMessages,
|
|
16
18
|
postPresenceMessage,
|
|
17
19
|
receiveMessages,
|
|
@@ -366,6 +368,31 @@ async function statusDigestContext({ brainPath, sessionId, prompt, sessions, cwd
|
|
|
366
368
|
return ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)', content].join('\n');
|
|
367
369
|
}
|
|
368
370
|
|
|
371
|
+
// ⏸️ Unfinished work other sessions left — the engine's block (agent-presence
|
|
372
|
+
// listUnfinishedWork + formatUnfinishedWork), read-only, never throws.
|
|
373
|
+
const CONTINUE_PROMPT_RE = /\b(continue|resume|pick (it |this |that )?up|carry on|keep going|where (were|did) we|what('s| is| was) (next|left|remaining|pending)|unfinished|left off|hand[- ]?off|handed (over|off)|take over|finish (what|the work|it|this))\b/i;
|
|
374
|
+
function unfinishedWorkBlock(brainPath, sessionId) {
|
|
375
|
+
try {
|
|
376
|
+
return formatUnfinishedWork(listUnfinishedWork({ brainPath, selfId: sessionId }));
|
|
377
|
+
} catch {
|
|
378
|
+
return '';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Unfinished-work patch for one Codex lifecycle event (session handoff). A
|
|
383
|
+
// human prompt records what was asked; a Stop records the reply tail only if
|
|
384
|
+
// the host put it in the payload. Null when the event carries nothing.
|
|
385
|
+
function codexWorkPatch({ event, humanTurn, prompt, input }) {
|
|
386
|
+
const patch = {};
|
|
387
|
+
if (humanTurn && prompt) patch.lastPrompt = String(prompt).slice(0, 240);
|
|
388
|
+
if (event === 'Stop') {
|
|
389
|
+
const words = input.last_assistant_message ?? input.lastAssistantMessage ?? input.assistant_message ?? '';
|
|
390
|
+
const text = typeof words === 'string' ? words.trim() : '';
|
|
391
|
+
if (text) { patch.lastWords = text.length > 700 ? '…' + text.slice(-699) : text; patch.lastWordsAt = Date.now(); }
|
|
392
|
+
}
|
|
393
|
+
return Object.keys(patch).length ? patch : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
369
396
|
async function compactTaskContext(projectDir, prompt, files) {
|
|
370
397
|
if (!prompt) return '';
|
|
371
398
|
try {
|
|
@@ -439,6 +466,11 @@ async function main() {
|
|
|
439
466
|
logicalSessionId: sessionId,
|
|
440
467
|
identitySource: 'codex-lifecycle',
|
|
441
468
|
cwd,
|
|
469
|
+
// Unfinished-work state (session handoff, 1.99.0), host-neutral: the last
|
|
470
|
+
// human prompt on a human turn, and at Stop the reply tail WHEN the host
|
|
471
|
+
// hands it over (Codex's Stop payload may carry `last_assistant_message`;
|
|
472
|
+
// this adapter never reads Codex's transcript). Absent = keep the known.
|
|
473
|
+
...(codexWorkPatch({ event, humanTurn, prompt, input }) ? { work: codexWorkPatch({ event, humanTurn, prompt, input }) } : {}),
|
|
442
474
|
});
|
|
443
475
|
|
|
444
476
|
const ignored = event === 'PostToolUse' ? ownBrainMessageText(input) : [];
|
|
@@ -481,6 +513,10 @@ async function main() {
|
|
|
481
513
|
observeShipDrift(projectDir),
|
|
482
514
|
gitHookNotice.trim(),
|
|
483
515
|
stampReceivedMessages(messages, Date.now(), undefined, sessionId),
|
|
516
|
+
// ⏸️ Unfinished work other sessions left (session handoff, 1.99.0): the
|
|
517
|
+
// same engine block brain_sync prints, so a Codex session asked to
|
|
518
|
+
// "continue" what a Claude session stopped in starts from its record.
|
|
519
|
+
unfinishedWorkBlock(brainPath, sessionId),
|
|
484
520
|
], event);
|
|
485
521
|
return;
|
|
486
522
|
}
|
|
@@ -495,6 +531,8 @@ async function main() {
|
|
|
495
531
|
context,
|
|
496
532
|
formatPresenceMessage(sessions, sessionId),
|
|
497
533
|
stampReceivedMessages(messages, Date.now(), undefined, sessionId),
|
|
534
|
+
// Only on a continue-shaped human prompt — SessionStart printed it once.
|
|
535
|
+
...(prompt && CONTINUE_PROMPT_RE.test(prompt) ? [unfinishedWorkBlock(brainPath, sessionId)] : []),
|
|
498
536
|
], event);
|
|
499
537
|
return;
|
|
500
538
|
}
|