klypix-mcp 1.86.3 → 1.88.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 +11 -4
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-worker.mjs +6 -6
- package/package.json +2 -2
- package/src/agent-presence.mjs +313 -18
- package/src/agent-rules.mjs +13 -6
- package/src/brain-doctor.mjs +104 -2
- package/src/enrichment.mjs +18 -2
- package/src/finding-routing.mjs +60 -0
- package/src/global-brain-hook.mjs +526 -115
- package/src/klypix-core.mjs +158 -8
- package/src/klypix-format.mjs +366 -21
- package/src/mcp-presence.mjs +7 -0
- package/src/provenance.mjs +432 -0
package/README.md
CHANGED
|
@@ -445,9 +445,16 @@ have to have declared their files for the overlap to be visible at all.
|
|
|
445
445
|
|
|
446
446
|
## Handoffs and messages
|
|
447
447
|
|
|
448
|
-
`brain_message` leaves one-time coordination notes for other sessions
|
|
449
|
-
|
|
450
|
-
|
|
448
|
+
`brain_message` leaves one-time coordination notes for other sessions — live, idle, or recently
|
|
449
|
+
closed. Address a session by id: a live session gets the note on its next action; a session
|
|
450
|
+
KLYPIX has identified before that is **not on the lane now** (closed, or quiet) still receives a
|
|
451
|
+
directed note the moment it next acts — a directed note is kept 7 days, and the sender is told it
|
|
452
|
+
is *queued*, not delivered. That is what stops the human from being the courier between two
|
|
453
|
+
agents: the standing rule every host receives is that a message it would otherwise ask the person
|
|
454
|
+
to relay or paste is sent this way instead. Nothing here starts or wakes a session — the note
|
|
455
|
+
waits until a person opens it. A supported
|
|
456
|
+
KLYPIX action offers the note in model-visible context; the next independent supported action
|
|
457
|
+
replays it and records an acknowledgement. That acknowledgement proves only that a later action followed the
|
|
451
458
|
offer — never that a person read it or that an agent acted on it. The note keeps replaying until the
|
|
452
459
|
receiving model calls `brain_message_receipt` with the exact message id and per-recipient offer
|
|
453
460
|
token; only that token-bound action records `consumed`. Pending, offered, and acknowledged notes
|
|
@@ -622,7 +629,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
622
629
|
| `brain_lens` | Machine-readable freshness, provenance, activity, timeline, orrery and unresolved views |
|
|
623
630
|
| `brain_garden` | Maintenance pass — proposes first; consolidation cannot apply without an approval code the human generates. The separate `repair:"duplicate-partials"` pass is dry-run first and needs no code (it removes only exact repeats and archives nothing) |
|
|
624
631
|
| `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
|
|
625
|
-
| `brain_message` | Session-to-session coordination notes with a fixed send-time audience and per-recipient pending / offer / acknowledgement / consumption / failure receipts (
|
|
632
|
+
| `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) |
|
|
626
633
|
| `brain_message_receipt` | Explicitly record model-side consumption using the exact message id and per-recipient offer token; acknowledgement alone never consumes a note |
|
|
627
634
|
| `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
|
|
628
635
|
| `brain_connect` | Find and draw related-but-unlinked cards |
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -395,7 +395,7 @@ try {
|
|
|
395
395
|
// canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
|
|
396
396
|
// file must never get a JS-comment banner) beside the flat server, which
|
|
397
397
|
// resolves it via its ./canvas-view-app.html candidate path.
|
|
398
|
-
for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.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']) {
|
|
398
|
+
for (const f of ['global-brain-hook.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', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.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']) {
|
|
399
399
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
400
400
|
}
|
|
401
401
|
for (const [src, dst] of [
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -621,7 +621,7 @@ server.registerTool('brain_lens', {
|
|
|
621
621
|
|
|
622
622
|
server.registerTool('brain_connect', {
|
|
623
623
|
title: 'Connect related-but-unlinked brain cards (densify the graph)',
|
|
624
|
-
description: 'Repairs orphaned decision/milestone cards first (scope:"orphans", the default) by proposing genuinely related unlinked pairs — semantic similarity at a conservative 0.55 threshold when the on-device model is installed, else shared tags + [[mentions]]. The dry run includes a before→projected orphan receipt; apply:true draws only additive, removable arrows and reports the measured after count. It NEVER archives or rewrites cards. Use scope:"all" for deliberate whole-graph densification. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] with relationship:"not_contradiction".',
|
|
624
|
+
description: 'Repairs orphaned decision/milestone cards first (scope:"orphans", the default) by proposing genuinely related unlinked pairs — semantic similarity at a conservative 0.55 threshold when the on-device model is installed, else shared tags + [[mentions]]. The dry run includes a before→projected orphan receipt; apply:true draws only additive, removable arrows and reports the measured after count. It NEVER archives or rewrites cards. Use scope:"all" for deliberate whole-graph densification. To DISMISS a brain_reconcile false-positive contradiction, pass pairs:[{fromId,toId}] with relationship:"not_contradiction". Every applied not_fulfilled / not_contradiction dismissal is also recorded, with its surface and client, to the machine-local provenance sidecar (nothing extra is written into the brain file).',
|
|
625
625
|
inputSchema: {
|
|
626
626
|
canvas: z.string().optional().describe('Canvas filename/path. Defaults to the project brain ("brain").'),
|
|
627
627
|
apply: z.boolean().optional().describe('false (default) = suggest only; true = draw the connections.'),
|
|
@@ -631,11 +631,11 @@ server.registerTool('brain_connect', {
|
|
|
631
631
|
pairs: z.array(z.object({ fromId: z.string(), toId: z.string() })).optional().describe('Explicit card-id pairs to connect (bypasses auto-proposal). Use to dismiss a reconcile false-positive: pass the two card ids with relationship:"not_contradiction".'),
|
|
632
632
|
relationship: z.string().optional().describe('Relationship for explicit `pairs` (e.g. "not_contradiction" to permanently dismiss a contradiction candidate, or "relates_to", "depends_on", "supports").'),
|
|
633
633
|
},
|
|
634
|
-
}, async ({ canvas, apply, max, threshold, scope, pairs, relationship }) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), apply, max, threshold, scope, pairs, relationship, log })));
|
|
634
|
+
}, async ({ canvas, apply, max, threshold, scope, pairs, relationship }, extra) => toContent(await opBrainConnect({ vault: mcpPresence.vault, canvas: boundBrainCanvas(canvas), apply, max, threshold, scope, pairs, relationship, via: extra.klypixClientName, log })));
|
|
635
635
|
|
|
636
636
|
server.registerTool('brain_reconcile', {
|
|
637
637
|
title: 'Reconcile the brain — contradictions, unrecorded migrations, and what a release already closed',
|
|
638
|
-
description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth, UNLESS the cue predates its counterpart: then the pair is marked "presumed superseded" and the newer card is presumed current — verify before retiring) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces (and its cue stops overlaying recall/ask for that pair). (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. (4) RELEASE: which open cards look fulfilled by the commits a release ref already carries (subject+body coverage, the card\'s own #commit- receipt, or a hint edge whose milestone is in the ref). READ-ONLY by default and on every other mode. THE ONE EXCEPTION: on mode "claims" and mode "release" you may pass confirm/dismiss to actually close what you verified — confirm names exact card ids, so nothing is matched by prose; covering only part of a multi-item clause writes "✔ partial" and KEEPS the card open unless you pass whole:true; a call whose every entry is refused leaves the brain byte-identical. Never reads the database or the network. Run it periodically, when recall surfaces something you believe is stale, or right before cutting a release.',
|
|
638
|
+
description: 'Truth maintenance. (1) CONTRADICTIONS: finds same-subject live card pairs where one carries an explicit correction cue (uppercase "CORRECTION", "was WRONG", "OBSOLETE" — that side is the presumed truth, UNLESS the cue predates its counterpart: then the pair is marked "presumed superseded" and the newer card is presumed current — verify before retiring) or the two use opposite polarity words (deferred↔wired, broken↔fixed, dead↔live), i.e. stale facts whose correction never got linked — candidates only, YOU confirm each: retire the stale card via brain_note ✓. Dismiss a FALSE positive (either kind) by connecting the two ids with brain_connect pairs + relationship:"not_contradiction" — persisted, so it never resurfaces (and its cue stops overlaying recall/ask for that pair). (2) MIGRATIONS: lists committed migration files (Supabase / Rails / Prisma / Knex / generic) that NO brain card references, so an applied-but-unnarrated rollout can be recorded. (3) LEGACY: pre-v1.15 raw-bash ship cards to tidy. (4) RELEASE: which open cards look fulfilled by the commits a release ref already carries (subject+body coverage, the card\'s own #commit- receipt, or a hint edge whose milestone is in the ref). READ-ONLY by default and on every other mode. THE ONE EXCEPTION: on mode "claims" and mode "release" you may pass confirm/dismiss to actually close what you verified — confirm names exact card ids, so nothing is matched by prose; covering only part of a multi-item clause writes "✔ partial" and KEEPS the card open unless you pass whole:true; a call whose every entry is refused leaves the brain byte-identical. Never reads the database or the network. Every applied confirm/dismiss is also recorded to the machine-local provenance sidecar (actor, surface, exact card ids — nothing extra is written into the brain file), and a claims confirm settles the pair\'s dashed "likely closed by" hint into a solid confirmed edge. Run it periodically, when recall surfaces something you believe is stale, or right before cutting a release.',
|
|
639
639
|
inputSchema: {
|
|
640
640
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
641
641
|
root: z.string().optional().describe("Project root holding the migrations dir / git repo (default: the brain file's folder)."),
|
|
@@ -746,11 +746,11 @@ server.registerTool('brain_note', {
|
|
|
746
746
|
});
|
|
747
747
|
|
|
748
748
|
server.registerTool('brain_message', {
|
|
749
|
-
title: 'Message
|
|
750
|
-
description: 'Leave a DELIBERATE, targeted note for
|
|
749
|
+
title: 'Message another agent session on this project — live, idle, or recently closed (one-time note, not a brain card)',
|
|
750
|
+
description: 'Leave a DELIBERATE, targeted note for another agent session working on this project ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Address it to a session id from the brain_sync peer list or brain_doctor. A LIVE session gets it on its next lifecycle event or KLYPIX tool call, even if it is idle right now. A session KLYPIX has identified before that is NOT on the lane now (closed, or quiet) still receives a directed note the moment it next acts — the note is kept for up to 7 days — so if you would otherwise ask the human to relay or paste something to another agent session, send it here instead and tell them it was sent or queued. Broadcasts ("all") reach only sessions live right now. Any MCP client can send and receive through the shared machine-local presence lane. Delivery contract: a supported action offers the note into model-visible context, a later independent action acknowledges it, and it retires either by an explicit brain_message_receipt ("acted on it") or by auto-consumption on a further independent action; your receipt line names which, and neither is proof a human read it. Expiry or capacity loss leaves a failed per-recipient receipt instead of silently disappearing. OS-user-local and machine-local. Ephemeral and NOT persisted to the brain — for a durable decision use brain_note instead.',
|
|
751
751
|
inputSchema: {
|
|
752
752
|
text: z.string().describe('The note to deliver (kept to 400 chars).'),
|
|
753
|
-
to: z.string().optional().describe('Target
|
|
753
|
+
to: z.string().optional().describe('Target — a session id (or unique prefix of at least 8 characters) from the peer list, or an exact unique branch; omit or "all" for every session live right now. A directed id may name a session that is not running: the note then waits and is delivered when that session next starts.'),
|
|
754
754
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
755
755
|
},
|
|
756
756
|
}, async ({ text, to, canvas }, extra) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.88.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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
85
85
|
"test:bench": "node test/bench.mjs",
|
|
86
86
|
"pretest": "node test/publish-workflow.mjs",
|
|
87
|
-
"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/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/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/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-hash-parity.mjs && node test/enrichment.mjs && node test/hook-fallback.mjs && node test/eval-hook-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/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/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
|
|
87
|
+
"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/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/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/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-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/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/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
|
|
88
88
|
"test:memory": "node test/memory-runtime.mjs",
|
|
89
89
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
90
90
|
"runtime": "node bin/klypix-runtime.mjs",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -19,6 +19,30 @@ export const MESSAGE_RECEIPT_CAP = 100;
|
|
|
19
19
|
export const SESSION_ALIAS_CAP = 8;
|
|
20
20
|
export const ENDED_SESSION_CAP = 80;
|
|
21
21
|
export const ENDED_SESSION_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
// ── Session directory (mailbox, 1.88.0) ──────────────────────────────────────
|
|
23
|
+
// The lane forgets a session ten minutes after its last heartbeat and keeps
|
|
24
|
+
// only a 24h tombstone for the (Codex-only) SessionEnd path, so a note to a
|
|
25
|
+
// session that had closed was refused as "target-not-unique" and the human
|
|
26
|
+
// became the courier between two agents (2026-09-30 field case). The
|
|
27
|
+
// directory is an ADDITIVE lane key remembering every exactly-identified
|
|
28
|
+
// session the lane has seen — its resume id, client, last intent, last scope,
|
|
29
|
+
// when it was last live — for two weeks. It is what lets a directed note be
|
|
30
|
+
// QUEUED for a session that is not running (delivered the moment that id
|
|
31
|
+
// comes back) and what a doctor/CLI/app can list as "recent sessions, not
|
|
32
|
+
// running". Provisional MCP connection ids are never remembered: they cannot
|
|
33
|
+
// be resumed and do not name a conversation.
|
|
34
|
+
export const DIRECTORY_FRESH_MS = 14 * 24 * 60 * 60 * 1000;
|
|
35
|
+
export const DIRECTORY_CAP = 200;
|
|
36
|
+
// A DIRECTED note waits a week, not a day: the promise is "a session that has
|
|
37
|
+
// closed still receives it when it next starts", and that must hold whether
|
|
38
|
+
// the session was already gone at send time or closed an hour later. Stored
|
|
39
|
+
// per message (expiresAt); broadcasts keep the 24h rule.
|
|
40
|
+
export const MESSAGE_DIRECTED_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
|
+
export const MESSAGE_OFFLINE_FRESH_MS = MESSAGE_DIRECTED_FRESH_MS;
|
|
42
|
+
// "working" is believed only while the host keeps stamping it: an MCP-only
|
|
43
|
+
// host never stamps idle (no Stop event), so a busy stamp older than this
|
|
44
|
+
// renders as idle-for-N-minutes instead of "working" all afternoon.
|
|
45
|
+
export const HOST_STATUS_BUSY_FRESH_MS = 10 * 60 * 1000;
|
|
22
46
|
// Marker shared by the pure relay seam. A message carrying this prefix was
|
|
23
47
|
// received from another computer, rather than authored on this local lane.
|
|
24
48
|
// Revoking cross-computer presence must remove these notes as well as cloud
|
|
@@ -435,6 +459,128 @@ const endedMatches = (row, id) => {
|
|
|
435
459
|
return key && (recipientKey(row?.id) === key || normalizeAliases(row?.aliases).includes(key));
|
|
436
460
|
};
|
|
437
461
|
|
|
462
|
+
// ── Session directory upkeep (mailbox) ───────────────────────────────────────
|
|
463
|
+
// PARITY: global-brain-hook.mjs keeps a verbatim twin (dirPrune/dirRemember)
|
|
464
|
+
// because that hook stays free of sibling imports — change both.
|
|
465
|
+
const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
|
|
466
|
+
.filter((row) => row && recipientKey(row.id) && now - Number(row.lastSeen || 0) < DIRECTORY_FRESH_MS)
|
|
467
|
+
.map((row) => ({
|
|
468
|
+
id: recipientKey(row.id),
|
|
469
|
+
client: String(row.client || 'unknown').slice(0, 40),
|
|
470
|
+
surface: row.surface ? String(row.surface).slice(0, 40) : null,
|
|
471
|
+
branch: row.branch ? String(row.branch).slice(0, 120) : null,
|
|
472
|
+
intent: String(row.intent || '').replace(/\s+/g, ' ').trim().slice(0, 120),
|
|
473
|
+
files: normalizeFiles(row.files).slice(-8),
|
|
474
|
+
cwd: row.cwd ? String(row.cwd).slice(0, 400) : null,
|
|
475
|
+
aliases: normalizeAliases(row.aliases, row.id),
|
|
476
|
+
firstSeen: Number(row.firstSeen) || Number(row.lastSeen) || now,
|
|
477
|
+
lastSeen: Number(row.lastSeen) || now,
|
|
478
|
+
endedAt: Number(row.endedAt) || null,
|
|
479
|
+
hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : null,
|
|
480
|
+
}))
|
|
481
|
+
.sort((left, right) => Number(left.lastSeen) - Number(right.lastSeen))
|
|
482
|
+
.slice(-DIRECTORY_CAP);
|
|
483
|
+
|
|
484
|
+
// Remember (or refresh) the directory entry for one lane row. Only rows with an
|
|
485
|
+
// EXACT logical identity are remembered — that id is the one a human can
|
|
486
|
+
// resume and a note can wait for. `ended` stamps endedAt (SessionEnd); any
|
|
487
|
+
// live touch clears it, because a revived session is running again.
|
|
488
|
+
function rememberSession(directory, row, now, { ended = false } = {}) {
|
|
489
|
+
const rows = pruneDirectory(directory, now);
|
|
490
|
+
const id = recipientKey(row?.logicalSessionId || '');
|
|
491
|
+
if (!id || row?.via === 'cloud') return rows;
|
|
492
|
+
const index = rows.findIndex((entry) => entry.id === id);
|
|
493
|
+
const prev = index >= 0 ? rows[index] : null;
|
|
494
|
+
const scope = [...new Set([...normalizeFiles(row.files), ...normalizeFiles(row.observedFiles)])].slice(-8);
|
|
495
|
+
const intent = String(row.intent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
496
|
+
const entry = {
|
|
497
|
+
id,
|
|
498
|
+
client: String(row.client || prev?.client || 'unknown').slice(0, 40),
|
|
499
|
+
surface: row.surface ?? prev?.surface ?? null,
|
|
500
|
+
branch: row.branch ?? prev?.branch ?? null,
|
|
501
|
+
intent: intent || prev?.intent || '',
|
|
502
|
+
files: scope.length ? scope : (prev?.files || []),
|
|
503
|
+
cwd: row.cwd ? String(row.cwd).slice(0, 400) : (prev?.cwd || null),
|
|
504
|
+
aliases: normalizeAliases([...(prev?.aliases || []), ...normalizeAliases(row.aliases), recipientKey(row.id)], id),
|
|
505
|
+
firstSeen: Number(prev?.firstSeen) || now,
|
|
506
|
+
lastSeen: now,
|
|
507
|
+
endedAt: ended ? now : null,
|
|
508
|
+
hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : (prev?.hostStatus || null),
|
|
509
|
+
};
|
|
510
|
+
if (index >= 0) rows[index] = entry; else rows.push(entry);
|
|
511
|
+
return rows
|
|
512
|
+
.sort((left, right) => Number(left.lastSeen) - Number(right.lastSeen))
|
|
513
|
+
.slice(-DIRECTORY_CAP);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Directed-target resolution over the directory: exact id/alias, else a unique
|
|
517
|
+
// >=8-character prefix. Branch names are deliberately NOT accepted here — over
|
|
518
|
+
// two weeks of history a branch names many conversations. Returns the single
|
|
519
|
+
// entry, or ambiguous=true when several match (fail closed).
|
|
520
|
+
function resolveDirectoryTarget(target, directory) {
|
|
521
|
+
const wanted = String(target || '').trim().toLowerCase();
|
|
522
|
+
const rows = Array.isArray(directory) ? directory : [];
|
|
523
|
+
if (!wanted) return { entry: null, ambiguous: false };
|
|
524
|
+
const keysOf = (entry) => [entry.id, ...(entry.aliases || [])].map((value) => String(value).toLowerCase());
|
|
525
|
+
const exact = rows.filter((entry) => keysOf(entry).includes(wanted));
|
|
526
|
+
if (exact.length === 1) return { entry: exact[0], ambiguous: false };
|
|
527
|
+
if (exact.length > 1) return { entry: null, ambiguous: true };
|
|
528
|
+
if (wanted.length >= 8) {
|
|
529
|
+
const prefixed = rows.filter((entry) => keysOf(entry).some((key) => key.startsWith(wanted)));
|
|
530
|
+
if (prefixed.length === 1) return { entry: prefixed[0], ambiguous: false };
|
|
531
|
+
if (prefixed.length > 1) return { entry: null, ambiguous: true };
|
|
532
|
+
}
|
|
533
|
+
return { entry: null, ambiguous: false };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Host activity status from the event that touched the row. A prompt or tool
|
|
537
|
+
// call means the model is working; a Stop/complete means it is waiting for the
|
|
538
|
+
// human. Heartbeats carry no status and keep the previous one. This is the
|
|
539
|
+
// difference between "inactive" (which the founder read as "closed") and
|
|
540
|
+
// "idle for 14 minutes — it will read your note the next time it is prompted".
|
|
541
|
+
const hostStatusFromEvent = (event) => {
|
|
542
|
+
const name = String(event || '');
|
|
543
|
+
if (/^(?:UserPromptSubmit|PreToolUse|PostToolUse|McpToolUse|McpTaskStart|McpTaskCheckpoint)$/i.test(name)) return 'busy';
|
|
544
|
+
if (/^(?:Stop|McpTaskComplete|SessionStart)$/i.test(name)) return 'idle';
|
|
545
|
+
return null;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// "working" | "idle" | "idle 14m" | "" (unknown). Shared by every renderer so
|
|
549
|
+
// the peer footer, brain_sync, doctor and the send receipt agree on one word.
|
|
550
|
+
export function sessionStatusLabel(session, now = Date.now()) {
|
|
551
|
+
const status = String(session?.hostStatus || '');
|
|
552
|
+
if (!status) return '';
|
|
553
|
+
const at = Number(session?.hostStatusAt || session?.lastSeen || now);
|
|
554
|
+
if (status === 'busy' && now - at < HOST_STATUS_BUSY_FRESH_MS) return 'working';
|
|
555
|
+
const min = Math.max(0, Math.round((now - at) / 60_000));
|
|
556
|
+
return min <= 0 ? 'idle' : `idle ${min}m`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// "2h ago" for receipts and labels — one helper so every surface agrees.
|
|
560
|
+
export function agoLabel(ms) {
|
|
561
|
+
const m = Math.max(0, Math.round(Number(ms || 0) / 60_000));
|
|
562
|
+
if (m < 1) return 'just now';
|
|
563
|
+
if (m < 60) return `${m}m ago`;
|
|
564
|
+
const h = Math.round(m / 60);
|
|
565
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// The command a human can paste to bring a closed session back, when the host
|
|
569
|
+
// has one. Only the two hosts whose resume-by-id was verified (2026-09-30):
|
|
570
|
+
// Claude Code `claude --resume <id>` and Codex `codex resume <id>`; both
|
|
571
|
+
// accept the exact id the lane stores. Anything else returns ''.
|
|
572
|
+
export function resumeCommandFor(client, id) {
|
|
573
|
+
const key = String(client || '').toLowerCase();
|
|
574
|
+
const sid = recipientKey(id);
|
|
575
|
+
// The command is shown to a model and may be pasted by a human: only an id
|
|
576
|
+
// made of identifier characters may ever appear in it (the lane is
|
|
577
|
+
// same-user-writable, so the id is not trusted as shell-safe by origin).
|
|
578
|
+
if (!sid || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(sid)) return '';
|
|
579
|
+
if (key === 'claude-code' || key === 'claude') return `claude --resume ${sid}`;
|
|
580
|
+
if (key === 'codex') return `codex resume ${sid}`;
|
|
581
|
+
return '';
|
|
582
|
+
}
|
|
583
|
+
|
|
438
584
|
export function sessionDeliveryReachability(session) {
|
|
439
585
|
const channels = new Set(Array.isArray(session?.channels) ? session.channels.map(String) : []);
|
|
440
586
|
const transport = session?.transport && typeof session.transport === 'object' ? session.transport : {};
|
|
@@ -663,7 +809,13 @@ function pruneMessages(messages, now) {
|
|
|
663
809
|
if (!message) continue;
|
|
664
810
|
const terminalAt = Number(message.deadLetter?.at || message.retiredAt || 0);
|
|
665
811
|
if (terminalAt && now - terminalAt >= MESSAGE_RECEIPT_FRESH_MS) continue;
|
|
666
|
-
|
|
812
|
+
// A note queued for a session that was not running carries its own
|
|
813
|
+
// deadline (MESSAGE_OFFLINE_FRESH_MS from send); every other note keeps
|
|
814
|
+
// the 24h rule. PARITY: global-brain-hook.mjs maintainMsgs.
|
|
815
|
+
const deadline = Number(message.expiresAt) > 0
|
|
816
|
+
? Number(message.expiresAt)
|
|
817
|
+
: Number(message.ts || 0) + MESSAGE_FRESH_MS;
|
|
818
|
+
if (!terminalAt && now >= deadline) {
|
|
667
819
|
message = terminalizeMessage(message, now, 'expired-before-consumption');
|
|
668
820
|
}
|
|
669
821
|
out.push(message);
|
|
@@ -771,6 +923,9 @@ export function upsertSession({
|
|
|
771
923
|
// pointer instead of the same ~5k chars. ADDITIVE — written only when a
|
|
772
924
|
// writer passes it; kept verbatim by every other touch (…previous spread).
|
|
773
925
|
statusDigestHash,
|
|
926
|
+
// Host activity status (mailbox, 1.88.0): 'busy' | 'idle'. Derived from
|
|
927
|
+
// `event` when a writer does not pass it; heartbeats keep the previous value.
|
|
928
|
+
hostStatus = null,
|
|
774
929
|
now = Date.now(),
|
|
775
930
|
}) {
|
|
776
931
|
if (!brainPath || !id) return withWriteVerdict([], false, 'no-brain-or-id');
|
|
@@ -869,6 +1024,7 @@ export function upsertSession({
|
|
|
869
1024
|
const activityEvent = /^(?:McpToolUse|McpTaskStart|McpTaskCheckpoint|UserPromptSubmit|PreToolUse|PostToolUse)$/i.test(String(event || ''))
|
|
870
1025
|
? String(event)
|
|
871
1026
|
: null;
|
|
1027
|
+
const nextHostStatus = hostStatus ? String(hostStatus).slice(0, 16) : hostStatusFromEvent(event);
|
|
872
1028
|
const next = {
|
|
873
1029
|
...previous,
|
|
874
1030
|
id: String(id),
|
|
@@ -932,6 +1088,11 @@ export function upsertSession({
|
|
|
932
1088
|
...(taskCompleted ? { completedAt: now } : (previous.completedAt ? { completedAt: previous.completedAt } : {})),
|
|
933
1089
|
}),
|
|
934
1090
|
...(statusDigestHash !== undefined ? { statusDigestHash: String(statusDigestHash || '').slice(0, 16) } : {}),
|
|
1091
|
+
// ADDITIVE host status: stamped only when this touch carries one, kept
|
|
1092
|
+
// verbatim otherwise, so a heartbeat never turns "working" into unknown.
|
|
1093
|
+
...(nextHostStatus
|
|
1094
|
+
? { hostStatus: nextHostStatus, hostStatusAt: now }
|
|
1095
|
+
: (previous.hostStatus ? { hostStatus: previous.hostStatus, hostStatusAt: previous.hostStatusAt || null } : {})),
|
|
935
1096
|
lastSeen: now,
|
|
936
1097
|
};
|
|
937
1098
|
const kept = sessions.filter((session) => session.id !== id);
|
|
@@ -942,6 +1103,7 @@ export function upsertSession({
|
|
|
942
1103
|
sessions: kept.slice(-40),
|
|
943
1104
|
messages: maintainMessages(data.messages, now),
|
|
944
1105
|
endedSessions,
|
|
1106
|
+
directory: rememberSession(data.directory, next, now),
|
|
945
1107
|
}));
|
|
946
1108
|
return withWriteVerdict(kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0)), true);
|
|
947
1109
|
} finally {
|
|
@@ -1200,12 +1362,18 @@ export function endSession({ brainPath, id, home, now = Date.now(), expectedPid
|
|
|
1200
1362
|
const kept = sessions.filter((session) => !identities.includes(recipientKey(session.id))
|
|
1201
1363
|
&& !normalizeAliases(session.aliases).some((alias) => identities.includes(alias))
|
|
1202
1364
|
&& !(session.logicalSessionId && identities.includes(recipientKey(session.logicalSessionId))));
|
|
1365
|
+
// The directory keeps the closed conversation as a door a note can wait at
|
|
1366
|
+
// ("closed 2h ago"), stamped ended so readers never call it merely idle.
|
|
1367
|
+
const directory = row
|
|
1368
|
+
? rememberSession(data.directory, { ...row, logicalSessionId: row.logicalSessionId || row.id, hostStatus: 'idle' }, now, { ended: true })
|
|
1369
|
+
: pruneDirectory(data.directory, now);
|
|
1203
1370
|
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
1204
1371
|
writeLaneFileAtomic(laneFile, JSON.stringify({
|
|
1205
1372
|
...data,
|
|
1206
1373
|
sessions: kept,
|
|
1207
1374
|
messages: maintainMessages(data.messages, now),
|
|
1208
1375
|
endedSessions,
|
|
1376
|
+
directory,
|
|
1209
1377
|
}));
|
|
1210
1378
|
return { ok: true, changed: kept.length !== sessions.length, reason: null, sessions: kept };
|
|
1211
1379
|
} finally {
|
|
@@ -2077,29 +2245,39 @@ export function shortestUniqueSessionPrefix(sessions, sessionId, minLength = 8)
|
|
|
2077
2245
|
return canonical;
|
|
2078
2246
|
}
|
|
2079
2247
|
|
|
2080
|
-
|
|
2248
|
+
// Live-target resolution with its verdict: `ids` is the resolved audience and
|
|
2249
|
+
// `ambiguous` says whether an empty result means "nobody matched" or "several
|
|
2250
|
+
// matched" — the mailbox needs the difference, because only a target that
|
|
2251
|
+
// matched NO live row may fall through to the directory. PARITY:
|
|
2252
|
+
// global-brain-hook.mjs resolveMsgTarget.
|
|
2253
|
+
export function resolveMessageTarget(message, sessions) {
|
|
2081
2254
|
const rows = Array.isArray(sessions) ? sessions : [];
|
|
2082
2255
|
const target = String(message?.to || '').trim().toLowerCase();
|
|
2083
|
-
if (!target || target === 'all' || target === '*') return rows.map((row) => String(row.id));
|
|
2256
|
+
if (!target || target === 'all' || target === '*') return { ids: rows.map((row) => String(row.id)), ambiguous: false };
|
|
2084
2257
|
|
|
2085
2258
|
// Full canonical ids and aliases are accepted only when they resolve to one
|
|
2086
2259
|
// row. This also makes a retained provisional id useful after atomic rekey.
|
|
2087
2260
|
const exactIdentity = rows.filter((row) => sessionIdentityKeys(row).includes(target));
|
|
2088
|
-
if (exactIdentity.length === 1) return [String(exactIdentity[0].id)];
|
|
2089
|
-
if (exactIdentity.length > 1) return [];
|
|
2261
|
+
if (exactIdentity.length === 1) return { ids: [String(exactIdentity[0].id)], ambiguous: false };
|
|
2262
|
+
if (exactIdentity.length > 1) return { ids: [], ambiguous: true };
|
|
2090
2263
|
|
|
2091
2264
|
// Prefixes are intentionally fail-closed and require >=8 characters.
|
|
2092
2265
|
if (target.length >= 8) {
|
|
2093
2266
|
const prefixMatches = rows.filter((row) => sessionIdentityKeys(row)
|
|
2094
2267
|
.some((key) => key.startsWith(target)));
|
|
2095
|
-
if (prefixMatches.length === 1) return [String(prefixMatches[0].id)];
|
|
2096
|
-
if (prefixMatches.length > 1) return [];
|
|
2268
|
+
if (prefixMatches.length === 1) return { ids: [String(prefixMatches[0].id)], ambiguous: false };
|
|
2269
|
+
if (prefixMatches.length > 1) return { ids: [], ambiguous: true };
|
|
2097
2270
|
}
|
|
2098
2271
|
|
|
2099
2272
|
// Human-friendly branch targeting remains, but only exact and unique. Intent,
|
|
2100
2273
|
// client and surface substring matching are deliberately forbidden.
|
|
2101
2274
|
const branchMatches = rows.filter((row) => String(row?.branch || '').trim().toLowerCase() === target);
|
|
2102
|
-
|
|
2275
|
+
if (branchMatches.length === 1) return { ids: [String(branchMatches[0].id)], ambiguous: false };
|
|
2276
|
+
return { ids: [], ambiguous: branchMatches.length > 1 };
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
export function resolveMessageTargetIds(message, sessions) {
|
|
2280
|
+
return resolveMessageTarget(message, sessions).ids;
|
|
2103
2281
|
}
|
|
2104
2282
|
|
|
2105
2283
|
function messageTargetsSession(message, session, sessionId, sessions = []) {
|
|
@@ -2421,6 +2599,12 @@ export function postPresenceMessage({
|
|
|
2421
2599
|
// hand-typed targets: the exactly-one-live-row refusal below is what keeps an
|
|
2422
2600
|
// ambiguous prefix from queuing a note nobody will ever receive.
|
|
2423
2601
|
allowOfflineTarget = false,
|
|
2602
|
+
// Mailbox (1.88.0): a hand-typed target that matches NO live row may still
|
|
2603
|
+
// name exactly one session the directory remembers (closed, or gone from the
|
|
2604
|
+
// lane). The note is then QUEUED for that id — delivered the moment the
|
|
2605
|
+
// session is back — instead of refused. Ambiguity still fails closed, and a
|
|
2606
|
+
// target nobody remembers is 'target-unknown', never a silent queue.
|
|
2607
|
+
allowKnownOfflineTarget = false,
|
|
2424
2608
|
}) {
|
|
2425
2609
|
const body = neutralizeMarkers(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 400));
|
|
2426
2610
|
if (!brainPath || !from || !body) return { posted: false, message: null, reason: 'invalid-message' };
|
|
@@ -2440,15 +2624,19 @@ export function postPresenceMessage({
|
|
|
2440
2624
|
if (existing) return { posted: false, message: existing, reason: 'duplicate' };
|
|
2441
2625
|
}
|
|
2442
2626
|
const senderId = String(from).slice(0, 160);
|
|
2443
|
-
|
|
2627
|
+
// The broadcast verdict is decided on the same folded value the resolver
|
|
2628
|
+
// uses ("ALL" is a broadcast everywhere, including the hook twin), and the
|
|
2629
|
+
// stored `to` is the canonical 'all' so every later reader agrees.
|
|
2630
|
+
const rawTarget = String(to || 'all').replace(/\s+/g, ' ').trim().slice(0, 160) || 'all';
|
|
2631
|
+
const broadcast = /^(?:all|\*)$/i.test(rawTarget);
|
|
2632
|
+
const target = broadcast ? 'all' : rawTarget;
|
|
2444
2633
|
// Receipt truth must survive peers ending before the sender checks doctor.
|
|
2445
2634
|
// Snapshot only recipient session ids (already lane metadata) at SEND time;
|
|
2446
2635
|
// old messages without this additive field retain reconstruction fallback.
|
|
2447
|
-
const
|
|
2448
|
-
const candidateIds =
|
|
2636
|
+
const resolution = resolveMessageTarget({ to: target }, sessions);
|
|
2637
|
+
const candidateIds = resolution.ids
|
|
2449
2638
|
.filter((id) => id && id !== senderId)
|
|
2450
2639
|
.map((id) => String(id).slice(0, 160));
|
|
2451
|
-
const broadcast = target === 'all' || target === '*';
|
|
2452
2640
|
// A broadcast with no OTHER live recipient is not a successful handoff.
|
|
2453
2641
|
// Refuse before constructing/persisting a message so no zero-audience row
|
|
2454
2642
|
// can later be mistaken for queued or delivered work.
|
|
@@ -2457,13 +2645,37 @@ export function postPresenceMessage({
|
|
|
2457
2645
|
}
|
|
2458
2646
|
// A targeted hint that does not resolve to exactly one OTHER live row is
|
|
2459
2647
|
// unsafe: it may be an ambiguous UUID prefix or duplicated branch. Refuse
|
|
2460
|
-
// instead of queuing a note whose visible `to` never had a recipient
|
|
2648
|
+
// instead of queuing a note whose visible `to` never had a recipient —
|
|
2649
|
+
// unless the target matched NOBODY live and is (a) an exact id a machine
|
|
2650
|
+
// caller vouched for, or (b) one session the directory remembers.
|
|
2651
|
+
let offline = null;
|
|
2461
2652
|
if (!broadcast && candidateIds.length !== 1) {
|
|
2462
|
-
|
|
2653
|
+
const selfOnly = resolution.ids.length > 0 && resolution.ids.every((id) => id === senderId);
|
|
2654
|
+
const nobodyLive = candidateIds.length === 0 && !resolution.ambiguous && !selfOnly;
|
|
2655
|
+
if (nobodyLive && allowKnownOfflineTarget) {
|
|
2656
|
+
const known = resolveDirectoryTarget(target, pruneDirectory(data.directory, now));
|
|
2657
|
+
if (known.ambiguous) return { posted: false, message: null, reason: 'target-not-unique' };
|
|
2658
|
+
if (!known.entry) return { posted: false, message: null, reason: 'target-unknown' };
|
|
2659
|
+
if (known.entry.id === senderId) return { posted: false, message: null, reason: 'target-not-unique' };
|
|
2660
|
+
candidateIds.push(known.entry.id);
|
|
2661
|
+
offline = {
|
|
2662
|
+
queuedAt: now,
|
|
2663
|
+
target: {
|
|
2664
|
+
id: known.entry.id,
|
|
2665
|
+
client: known.entry.client,
|
|
2666
|
+
intent: known.entry.intent,
|
|
2667
|
+
lastSeen: known.entry.lastSeen,
|
|
2668
|
+
endedAt: known.entry.endedAt || null,
|
|
2669
|
+
},
|
|
2670
|
+
};
|
|
2671
|
+
} else if (allowOfflineTarget && candidateIds.length === 0) {
|
|
2672
|
+
// Offline machine-known recipient: address the exact id we were handed
|
|
2673
|
+
// (1.87 semantics kept verbatim — a logical id split across two live
|
|
2674
|
+
// rows still resolves through candidateIds, so it is not refused).
|
|
2675
|
+
candidateIds.push(String(target).slice(0, 160));
|
|
2676
|
+
} else {
|
|
2463
2677
|
return { posted: false, message: null, reason: 'target-not-unique' };
|
|
2464
2678
|
}
|
|
2465
|
-
// Offline machine-known recipient: address the exact id we were handed.
|
|
2466
|
-
candidateIds.push(String(target).slice(0, 160));
|
|
2467
2679
|
}
|
|
2468
2680
|
const message = {
|
|
2469
2681
|
id: sha16(`${from}|${to}|${body}|${now}|${crypto.randomBytes(4).toString('hex')}`),
|
|
@@ -2476,6 +2688,10 @@ export function postPresenceMessage({
|
|
|
2476
2688
|
deliveries: candidateIds.map((recipientId) => ({ recipientId, state: 'pending', attempts: 0 })),
|
|
2477
2689
|
candidateIds,
|
|
2478
2690
|
...(key ? { dedupeKey: key } : {}),
|
|
2691
|
+
// Every directed note keeps for a week (see MESSAGE_DIRECTED_FRESH_MS);
|
|
2692
|
+
// `offline` additionally records that the target was away at send time.
|
|
2693
|
+
...(broadcast ? {} : { expiresAt: now + MESSAGE_DIRECTED_FRESH_MS }),
|
|
2694
|
+
...(offline ? { offline } : {}),
|
|
2479
2695
|
};
|
|
2480
2696
|
messages.push(message);
|
|
2481
2697
|
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
@@ -2484,12 +2700,82 @@ export function postPresenceMessage({
|
|
|
2484
2700
|
sessions,
|
|
2485
2701
|
messages: capMessages(messages, MESSAGE_LANE_CAP, now),
|
|
2486
2702
|
}));
|
|
2487
|
-
|
|
2703
|
+
// Who will actually see this, and in what state — the sender's reply to the
|
|
2704
|
+
// human is built from this, so it must say "idle 14m" or "not running",
|
|
2705
|
+
// never just "queued".
|
|
2706
|
+
const recipients = candidateIds.map((id) => {
|
|
2707
|
+
const row = sessions.find((session) => String(session.id) === id);
|
|
2708
|
+
if (row) {
|
|
2709
|
+
return {
|
|
2710
|
+
id,
|
|
2711
|
+
live: true,
|
|
2712
|
+
client: row.client || 'unknown',
|
|
2713
|
+
intent: String(row.intent || '').slice(0, 120),
|
|
2714
|
+
branch: row.branch || null,
|
|
2715
|
+
deliveryReachability: row.deliveryReachability || sessionDeliveryReachability(row),
|
|
2716
|
+
hostStatus: row.hostStatus || null,
|
|
2717
|
+
statusLabel: sessionStatusLabel(row, now),
|
|
2718
|
+
lastSeen: Number(row.lastSeen || 0) || null,
|
|
2719
|
+
endedAt: null,
|
|
2720
|
+
resumeCommand: '',
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
const known = offline?.target?.id === id ? offline.target : null;
|
|
2724
|
+
// "not seen for 2h" — honest about what the lane knows: ten minutes of
|
|
2725
|
+
// silence means closed OR idle with no KLYPIX heartbeat; only a
|
|
2726
|
+
// SessionEnd (Codex) proves "closed". Either way the note is delivered
|
|
2727
|
+
// the moment that session next acts.
|
|
2728
|
+
return {
|
|
2729
|
+
id,
|
|
2730
|
+
live: false,
|
|
2731
|
+
client: known?.client || 'unknown',
|
|
2732
|
+
intent: known?.intent || '',
|
|
2733
|
+
branch: null,
|
|
2734
|
+
deliveryReachability: 'not-running',
|
|
2735
|
+
hostStatus: null,
|
|
2736
|
+
statusLabel: known?.endedAt ? 'closed' : (known?.lastSeen ? `not seen for ${agoLabel(now - known.lastSeen).replace(/ ago$/, '')}` : 'not running'),
|
|
2737
|
+
lastSeen: known?.lastSeen || null,
|
|
2738
|
+
endedAt: known?.endedAt || null,
|
|
2739
|
+
resumeCommand: resumeCommandFor(known?.client, id),
|
|
2740
|
+
};
|
|
2741
|
+
});
|
|
2742
|
+
return { posted: true, message, reason: null, queuedOffline: Boolean(offline), recipients };
|
|
2488
2743
|
} finally {
|
|
2489
2744
|
releaseLock(lockFile);
|
|
2490
2745
|
}
|
|
2491
2746
|
}
|
|
2492
2747
|
|
|
2748
|
+
// Every session the directory remembers (two weeks), joined with the live lane:
|
|
2749
|
+
// `live` says whether a row is heartbeating now, `status` is the shared word
|
|
2750
|
+
// ("working", "idle 14m", "closed", "not running"), and `waitingNotes` counts
|
|
2751
|
+
// notes queued for that id that nobody has offered yet. Read-only; this is the
|
|
2752
|
+
// data behind "recent sessions" in doctor, a CLI verb, or an app switchboard.
|
|
2753
|
+
export function listKnownSessions({ brainPath, home, now = Date.now() } = {}) {
|
|
2754
|
+
if (!brainPath) return [];
|
|
2755
|
+
const lane = readLane(laneFileFor(brainPath, home));
|
|
2756
|
+
const live = pruneSessions(lane.sessions, now);
|
|
2757
|
+
const messages = (Array.isArray(lane.messages) ? lane.messages : [])
|
|
2758
|
+
.map((message) => normalizeMessageDelivery(message, now))
|
|
2759
|
+
.filter((message) => message && !isTerminalMessage(message));
|
|
2760
|
+
return pruneDirectory(lane.directory, now).map((entry) => {
|
|
2761
|
+
const wanted = entry.id.toLowerCase();
|
|
2762
|
+
const row = live.find((session) => sessionIdentityKeys(session).includes(wanted)) || null;
|
|
2763
|
+
const waitingNotes = messages.filter((message) => Array.isArray(message.candidateIds)
|
|
2764
|
+
&& message.candidateIds.map(recipientKey).includes(entry.id)
|
|
2765
|
+
&& messageDeliveryState(message, entry.id) === 'pending').length;
|
|
2766
|
+
return {
|
|
2767
|
+
...entry,
|
|
2768
|
+
live: Boolean(row),
|
|
2769
|
+
status: row ? (sessionStatusLabel(row, now) || 'live')
|
|
2770
|
+
: (entry.endedAt ? 'closed' : `not seen for ${agoLabel(now - entry.lastSeen).replace(/ ago$/, '')}`),
|
|
2771
|
+
lastSeen: row ? Number(row.lastSeen || entry.lastSeen) : entry.lastSeen,
|
|
2772
|
+
deliveryReachability: row ? (row.deliveryReachability || sessionDeliveryReachability(row)) : 'not-running',
|
|
2773
|
+
waitingNotes,
|
|
2774
|
+
resumeCommand: row ? '' : resumeCommandFor(entry.client, entry.id),
|
|
2775
|
+
};
|
|
2776
|
+
}).sort((left, right) => Number(right.lastSeen) - Number(left.lastSeen));
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2493
2779
|
const clientLabel = (session) => {
|
|
2494
2780
|
const client = String(session?.client || '').toLowerCase();
|
|
2495
2781
|
if (!client) return 'Claude Code';
|
|
@@ -2610,8 +2896,14 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
|
|
|
2610
2896
|
// 100-minute-old task line can never read as "what they're doing right now".
|
|
2611
2897
|
const intentAgeMin = group.intentAt ? Math.max(0, Math.round((now - Number(group.intentAt)) / 60_000)) : null;
|
|
2612
2898
|
const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
|
|
2899
|
+
// Host status rides the row that carries it (a lifecycle row knows Stop /
|
|
2900
|
+
// prompt; an MCP-only row may not) — take the freshest stamped one.
|
|
2901
|
+
const statusRow = group.rows
|
|
2902
|
+
.filter((row) => row.hostStatus)
|
|
2903
|
+
.sort((a, b) => Number(b.hostStatusAt || b.lastSeen || 0) - Number(a.hostStatusAt || a.lastSeen || 0))[0] || null;
|
|
2613
2904
|
const details = [
|
|
2614
2905
|
clientLabel(session),
|
|
2906
|
+
statusRow ? sessionStatusLabel(statusRow, now) : null,
|
|
2615
2907
|
group.rows.length > 1 ? `${group.rows.length} connections` : null,
|
|
2616
2908
|
group.branch ? `branch ${group.branch}` : null,
|
|
2617
2909
|
group.intent ? `"${String(group.intent).slice(0, 90)}"${intentAge}` : null,
|
|
@@ -2693,7 +2985,10 @@ export function formatReceivedMessages(messages, now = Date.now(), decay = {}, s
|
|
|
2693
2985
|
const senderLabel = senders.length <= 3 ? senders.join(', ') : `${senders.slice(0, 3).join(', ')} +${senders.length - 3}`;
|
|
2694
2986
|
const oldestTs = Math.min(...group.map(item => Number(item?.ts) || now));
|
|
2695
2987
|
const ageMin = Math.max(0, Math.round((now - oldestTs) / 60_000));
|
|
2696
|
-
|
|
2988
|
+
// A note that waited for this session while it was not running says so:
|
|
2989
|
+
// the reader should treat it as the sender's state THEN, not a live ping.
|
|
2990
|
+
const waited = group.some((item) => item?.offline) ? ', left while this session was not running' : '';
|
|
2991
|
+
lines.push(`- from ${senderLabel} (${ageMin}m ago${waited}): ${neutralizeMarkers(String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400))}`);
|
|
2697
2992
|
const receipts = group.map((item) => messageDeliveryReceipt(item, sessionId)).filter(Boolean);
|
|
2698
2993
|
if (receipts.length) {
|
|
2699
2994
|
lines.push(` Receipt(s): ${receipts.map((receipt) => `${receipt.messageId}:${receipt.offerToken}`).join(', ')}. After incorporating ${receipts.length === 1 ? 'it' : 'them'}, call brain_message_receipt with each exact message_id and offer_token — that is the ONLY way the sender learns you acted on ${receipts.length === 1 ? 'it' : 'them'}. If you skip it, your next independent action auto-consumes ${receipts.length === 1 ? 'this note' : 'these notes'} and the sender is told only that ${receipts.length === 1 ? 'it was' : 'they were'} auto-consumed.`);
|