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 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. A supported KLYPIX action
449
- offers the note in model-visible context; the next independent supported action replays it and
450
- records an acknowledgement. That acknowledgement proves only that a later action followed the
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 (24h TTL, never written into the brain) |
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 |
@@ -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 [
@@ -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 the other live agent sessions on this project (one-time note, not a brain card)',
750
- description: 'Leave a DELIBERATE, targeted note for the OTHER active agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can send and receive through the shared machine-local presence lane. A supported lifecycle event or KLYPIX tool result offers the note into model-visible context; a later independent supported action acknowledges that offer. Pending/offered notes replay after reconnect, while expiry or capacity loss leaves a failed per-recipient receipt instead of silently disappearing. Acknowledged means a later action followed the offer — it is NOT proof a human read it. A note then retires either by an explicit brain_message_receipt ("acted on it") or by AUTO-CONSUMPTION on a further independent action, with no receipt; your receipt line names which, and auto-consumption evidences activity, not uptake. Delivery remains OS-user-local, machine-local, bounded by a 24h TTL, and unavailable to a peer that never takes a supported action. Ephemeral and NOT persisted to the brain — for a durable decision use brain_note instead.',
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 hint — a peer session id-prefix or branch name; omit or "all" for every live session.'),
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.86.3",
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",
@@ -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
- if (!terminalAt && now - Number(message.ts || 0) >= MESSAGE_FRESH_MS) {
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
- export function resolveMessageTargetIds(message, sessions) {
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
- return branchMatches.length === 1 ? [String(branchMatches[0].id)] : [];
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
- const target = String(to || 'all').replace(/\s+/g, ' ').trim().slice(0, 160) || 'all';
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 resolvedTargets = resolveMessageTargetIds({ to: target }, sessions);
2448
- const candidateIds = resolvedTargets
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
- if (!(allowOfflineTarget && candidateIds.length === 0)) {
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
- return { posted: true, message, reason: null };
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
- lines.push(`- from ${senderLabel} (${ageMin}m ago): ${neutralizeMarkers(String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400))}`);
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.`);