@jeffober/thatch 0.1.43 → 0.1.44

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/src/setup.ts CHANGED
@@ -90,53 +90,111 @@ function writeMcpConfig(path: string, thatchBin: string): void {
90
90
  // CLAUDE.md - append thatch instructions (idempotent)
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
+ /**
94
+ * Sentinel delimiters for the thatch instructions block. HTML comments, not
95
+ * prose: agents edit these files constantly (and normalize punctuation while
96
+ * they are in there), so any prose sentence used as a delimiter eventually
97
+ * stops matching and the block becomes un-updatable. Sentinels survive prose
98
+ * edits because they are not prose.
99
+ */
100
+ export const THATCH_BEGIN = "<!-- thatch:begin -->";
101
+ export const THATCH_END = "<!-- thatch:end -->";
102
+
103
+ /**
104
+ * Legacy delimiters, used by installs before the sentinels: prose sentences
105
+ * from the instructions themselves. Detection-only - they exist so setup can
106
+ * find and migrate (or heal) pre-sentinel blocks. Do not write new blocks
107
+ * with them; any edit to the file's prose breaks the match.
108
+ */
93
109
  const THATCH_MARKER = "# Persistence\n\nThatch provides persistent memory across Claude Code sessions.";
94
110
  const THATCH_END_MARKER = '"Forget X" - `memory_recall` to find it, then `memory_forget`.';
95
111
  const CURSOR_MARKER = "# Persistence\n\nThatch provides persistent memory across Cursor sessions.";
96
112
  const CURSOR_END_MARKER = THATCH_END_MARKER;
97
113
 
98
114
  /**
99
- * Idempotently append (or replace) a block of instructions in a markdown file.
100
- * The block is delimited by startMarker and endMarker so re-running setup
101
- * updates drifted content without clobbering surrounding text.
115
+ * The instructions' last line (punctuation-insensitive tail test): the
116
+ * "heal" rule may only consume file tail when the corruption is the known
117
+ * kind - the legacy block running to EOF. Without this guard, a broken
118
+ * legacy block followed by unrelated user content would eat that content.
102
119
  */
120
+ const INSTRUCTIONS_TAIL_SENTINEL = "memory_forget";
121
+
103
122
  function appendBlock(
104
123
  path: string,
105
124
  instructions: string,
106
- startMarker: string,
107
- endMarker: string,
125
+ legacyStartMarker: string,
126
+ legacyEndMarker: string,
108
127
  ): void {
109
- if (existsSync(path)) {
110
- const existing = readFileSync(path, "utf8");
111
- if (existing.includes(startMarker)) {
112
- const startIdx = existing.indexOf(startMarker);
113
- if (startIdx >= 0) {
114
- const endIdx = existing.indexOf(endMarker, startIdx);
115
- if (endIdx >= 0) {
116
- const afterEnd = endIdx + endMarker.length;
117
- const updated = existing.slice(0, startIdx) + instructions.trimEnd() + existing.slice(afterEnd);
118
- writeFileSync(path, updated);
119
- return;
120
- }
121
- }
122
- // Marker found but block boundaries didn't parse - leave it alone.
128
+ const block = `${THATCH_BEGIN}\n${instructions.trimEnd()}\n${THATCH_END}`;
129
+
130
+ if (!existsSync(path)) {
131
+ mkdirSync(join(path, ".."), { recursive: true });
132
+ writeFileSync(path, block + "\n");
133
+ return;
134
+ }
135
+
136
+ const existing = readFileSync(path, "utf8");
137
+
138
+ // Current format: replace between the sentinels.
139
+ if (existing.includes(THATCH_BEGIN)) {
140
+ const beginIdx = existing.indexOf(THATCH_BEGIN);
141
+ const endIdx = existing.indexOf(THATCH_END, beginIdx);
142
+ if (endIdx >= 0) {
143
+ const afterEnd = endIdx + THATCH_END.length;
144
+ writeFileSync(path, existing.slice(0, beginIdx) + block + existing.slice(afterEnd));
123
145
  return;
124
146
  }
125
- // Append to existing file.
126
- const sep = existing.endsWith("\n") ? "\n" : "\n\n";
127
- writeFileSync(path, existing + sep + instructions);
128
- } else {
129
- mkdirSync(join(path, ".."), { recursive: true });
130
- writeFileSync(path, instructions);
147
+ // Begin without end: the sentinel block itself was corrupted. Same heal
148
+ // rule as the legacy broken case below.
149
+ healBrokenBlock(path, existing, beginIdx, block);
150
+ return;
131
151
  }
152
+
153
+ // Legacy format: prose-sentence delimiters from a pre-sentinel install.
154
+ if (existing.includes(legacyStartMarker)) {
155
+ const startIdx = existing.indexOf(legacyStartMarker);
156
+ const endIdx = existing.indexOf(legacyEndMarker, startIdx);
157
+ if (endIdx >= 0) {
158
+ // Intact legacy block: swap the whole span for the sentinel block.
159
+ const afterEnd = endIdx + legacyEndMarker.length;
160
+ writeFileSync(path, existing.slice(0, startIdx) + block + existing.slice(afterEnd));
161
+ return;
162
+ }
163
+ // Broken legacy block (the observed corruption: an agent edit normalized
164
+ // the prose - hyphens became em dashes - so the end sentence no longer
165
+ // matches). Heal by replacing from the legacy start to EOF, but only
166
+ // when the tail is recognizably the instructions; otherwise leave it
167
+ // for manual repair.
168
+ healBrokenBlock(path, existing, startIdx, block);
169
+ return;
170
+ }
171
+
172
+ // No thatch block yet: append.
173
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n";
174
+ writeFileSync(path, existing + sep + block + "\n");
175
+ }
176
+
177
+ /**
178
+ * Replaces everything from `startIdx` to EOF with the fresh sentinel block,
179
+ * but only when the corrupted tail is recognizably the thatch instructions
180
+ * (the tail test). Reports via return value so callers can surface a
181
+ * skipped heal; the file is left untouched when the guard does not pass.
182
+ */
183
+ function healBrokenBlock(path: string, existing: string, startIdx: number, block: string): boolean {
184
+ const tail = existing.slice(startIdx).slice(-400);
185
+ if (!tail.includes(INSTRUCTIONS_TAIL_SENTINEL)) {
186
+ return false;
187
+ }
188
+ writeFileSync(path, existing.slice(0, startIdx) + block + "\n");
189
+ return true;
132
190
  }
133
191
 
134
192
  function appendInstructions(path: string): void {
135
- appendBlock(path, claudeInstructions() + "\n", THATCH_MARKER, THATCH_END_MARKER);
193
+ appendBlock(path, claudeInstructions(), THATCH_MARKER, THATCH_END_MARKER);
136
194
  }
137
195
 
138
196
  function appendCursorInstructions(path: string): void {
139
- appendBlock(path, cursorInstructions() + "\n", CURSOR_MARKER, CURSOR_END_MARKER);
197
+ appendBlock(path, cursorInstructions(), CURSOR_MARKER, CURSOR_END_MARKER);
140
198
  }
141
199
 
142
200
  // ---------------------------------------------------------------------------
@@ -153,6 +211,7 @@ interface SettingsJson {
153
211
  SessionStart?: { hooks: HookEntry[] }[];
154
212
  PostToolBatch?: { hooks: HookEntry[] }[];
155
213
  UserPromptSubmit?: { hooks: HookEntry[] }[];
214
+ Stop?: { hooks: HookEntry[] }[];
156
215
  };
157
216
  [key: string]: any;
158
217
  }
@@ -178,6 +237,12 @@ function writeHooks(path: string, thatchBin: string): void {
178
237
  // falls back to the static write-nudge. Either output is transcript-visible
179
238
  // since UserPromptSubmit adds stdout to context.
180
239
  const flushCmd = `${thatchBin} flush-tools`;
240
+ // Stop fires when the agent turn ends. chat-notify emits a Stop-hook
241
+ // additionalContext reminder when chat mail is unread - the turn
242
+ // continues so the model can read its mail, the Claude Code analog of
243
+ // opencode's poller wake. The host's loop protections (stop_hook_active,
244
+ // the 8-consecutive cap) plus the delivered stamp bound repeats.
245
+ const notifyCmd = `${thatchBin} chat-notify`;
181
246
 
182
247
  settings.hooks.SessionStart = replaceThatchHooks(
183
248
  settings.hooks.SessionStart ?? [],
@@ -191,6 +256,10 @@ function writeHooks(path: string, thatchBin: string): void {
191
256
  settings.hooks.UserPromptSubmit ?? [],
192
257
  flushCmd,
193
258
  );
259
+ settings.hooks.Stop = replaceThatchHooks(
260
+ settings.hooks.Stop ?? [],
261
+ notifyCmd,
262
+ );
194
263
 
195
264
  mkdirSync(join(path, ".."), { recursive: true });
196
265
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
@@ -311,6 +380,92 @@ export function setupClaudeCode(
311
380
  };
312
381
  }
313
382
 
383
+ // ---------------------------------------------------------------------------
384
+ // Global MCP registration (Claude Code)
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Outcome of trying to register the thatch MCP server with the `claude`
389
+ * CLI. `registered` and `already-registered` mean the MCP server is
390
+ * usable; `claude-missing` and `failed` mean the caller must print
391
+ * `manualCommand` for the user to run by hand.
392
+ */
393
+ export interface ClaudeMcpRegistration {
394
+ status: "registered" | "already-registered" | "claude-missing" | "failed";
395
+ manualCommand: string;
396
+ detail: string;
397
+ }
398
+
399
+ export interface CommandRunResult {
400
+ exitCode: number;
401
+ }
402
+
403
+ async function defaultCommandRun(argv: string[]): Promise<CommandRunResult> {
404
+ const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
405
+ await proc.exited;
406
+ return { exitCode: proc.exitCode ?? 1 };
407
+ }
408
+
409
+ /**
410
+ * Runs `claude mcp add --scope user` on the user's behalf - the
411
+ * global-setup counterpart of writing .mcp.json, which project-local
412
+ * setup does directly. ~/.claude.json is not hand-writable (the reason
413
+ * setup used to print the command instead), so the claude CLI does it.
414
+ *
415
+ * Idempotent and non-destructive: an existing registration is detected
416
+ * via `claude mcp get thatch` (exit 1 when absent) and left untouched,
417
+ * whatever scope or binary it points at. `claudeBin` and `run` are
418
+ * injectable for tests; when `claude` is missing the result says so and
419
+ * the manual command stands in.
420
+ */
421
+ export async function registerClaudeMcpServer(
422
+ thatchBin: string,
423
+ opts: {
424
+ claudeBin?: string | null;
425
+ run?: (argv: string[]) => Promise<CommandRunResult>;
426
+ } = {},
427
+ ): Promise<ClaudeMcpRegistration> {
428
+ const claudeBin = opts.claudeBin === undefined ? Bun.which("claude") : opts.claudeBin;
429
+ const manualCommand = `claude mcp add --scope user thatch -- ${thatchBin} mcp`;
430
+ const run = opts.run ?? defaultCommandRun;
431
+ if (!claudeBin) {
432
+ return {
433
+ status: "claude-missing",
434
+ manualCommand,
435
+ detail: "claude CLI not found on PATH",
436
+ };
437
+ }
438
+ try {
439
+ const get = await run([claudeBin, "mcp", "get", "thatch"]);
440
+ if (get.exitCode === 0) {
441
+ return {
442
+ status: "already-registered",
443
+ manualCommand,
444
+ detail: "a thatch MCP registration already exists (`claude mcp get thatch` to inspect)",
445
+ };
446
+ }
447
+ const add = await run([claudeBin, "mcp", "add", "--scope", "user", "thatch", "--", thatchBin, "mcp"]);
448
+ if (add.exitCode === 0) {
449
+ return {
450
+ status: "registered",
451
+ manualCommand,
452
+ detail: "registered with `claude mcp add --scope user`",
453
+ };
454
+ }
455
+ return {
456
+ status: "failed",
457
+ manualCommand,
458
+ detail: "`claude mcp add` exited non-zero",
459
+ };
460
+ } catch (err) {
461
+ return {
462
+ status: "failed",
463
+ manualCommand,
464
+ detail: `claude invocation failed: ${err}`,
465
+ };
466
+ }
467
+ }
468
+
314
469
  // ---------------------------------------------------------------------------
315
470
  // Cursor setup - .cursor/mcp.json, AGENTS.md, .cursor/hooks.json, skills
316
471
  // ---------------------------------------------------------------------------
@@ -394,21 +549,29 @@ function writeCursorHooks(path: string, thatchBin: string): void {
394
549
  // beforeSubmitPrompt is Cursor's UserPromptSubmit equivalent. Drains the
395
550
  // queue and prints the extraction nudge as JSON additional_context.
396
551
  const flushCmd = `${thatchBin} flush-tools --json`;
552
+ // stop fires when the agent loop ends. chat-notify emits followup_message
553
+ // (auto-submitted by Cursor as the next user message) when chat mail is
554
+ // unread - the Cursor analog of opencode's poller wake. loop_limit bounds
555
+ // consecutive auto-followups; reading the mail naturally terminates the
556
+ // loop (unread drops to 0), the limit is the backstop.
557
+ const notifyCmd = `${thatchBin} chat-notify`;
397
558
 
398
559
  config.hooks.sessionStart = replaceCursorThatchHooks(config.hooks.sessionStart ?? [], sessionStartCmd);
399
560
  config.hooks.postToolUse = replaceCursorThatchHooks(config.hooks.postToolUse ?? [], bufferCmd);
400
561
  config.hooks.beforeSubmitPrompt = replaceCursorThatchHooks(config.hooks.beforeSubmitPrompt ?? [], flushCmd);
562
+ config.hooks.stop = replaceCursorThatchHooks(config.hooks.stop ?? [], notifyCmd, 3);
401
563
 
402
564
  mkdirSync(join(path, ".."), { recursive: true });
403
565
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
404
566
  }
405
567
 
406
568
  function replaceCursorThatchHooks(
407
- entries: { command: string }[],
569
+ entries: { command: string; loop_limit?: number }[],
408
570
  command: string,
409
- ): { command: string }[] {
571
+ loopLimit?: number,
572
+ ): { command: string; loop_limit?: number }[] {
410
573
  const filtered = entries.filter((e) => !e.command?.includes("thatch"));
411
- filtered.push({ command });
574
+ filtered.push(loopLimit === undefined ? { command } : { command, loop_limit: loopLimit });
412
575
  return filtered;
413
576
  }
414
577
 
@@ -484,12 +647,19 @@ export type SetupStatus =
484
647
 
485
648
  type FileCheck = "installed" | "absent" | "broken";
486
649
 
487
- function checkInstructionsFile(path: string, startMarker: string, endMarker: string): FileCheck {
650
+ function checkInstructionsFile(path: string, legacyStartMarker: string, legacyEndMarker: string): FileCheck {
488
651
  if (!existsSync(path)) return "absent";
489
652
  const content = readFileSync(path, "utf8");
490
- const startIdx = content.indexOf(startMarker);
653
+ // Current format: sentinel-delimited. Begin without end is the same
654
+ // corruption class as the legacy broken case.
655
+ const sentinelIdx = content.indexOf(THATCH_BEGIN);
656
+ if (sentinelIdx !== -1) {
657
+ return content.indexOf(THATCH_END, sentinelIdx) === -1 ? "broken" : "installed";
658
+ }
659
+ // Legacy format: prose-sentence delimiters.
660
+ const startIdx = content.indexOf(legacyStartMarker);
491
661
  if (startIdx === -1) return "absent";
492
- const endIdx = content.indexOf(endMarker, startIdx);
662
+ const endIdx = content.indexOf(legacyEndMarker, startIdx);
493
663
  if (endIdx === -1) return "broken";
494
664
  return "installed";
495
665
  }
package/src/tool-defs.ts CHANGED
@@ -20,7 +20,7 @@ import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } fr
20
20
  import { predictionVerb, chatInboxFrame } from "./prompts";
21
21
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
22
22
  import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
23
- import { CHAT_STALE_MINUTES, isStale, renderChatParticipant, humanAge, type ChatHostKind } from "./chat";
23
+ import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, splitChatRoster, type ChatHostKind } from "./chat";
24
24
  import { detectWorktreeKind } from "./git";
25
25
 
26
26
  // Near-duplicate thresholds for matcher/prediction/behavior dedup at
@@ -86,6 +86,12 @@ export interface CoreContext {
86
86
  /** Injectable command runner for notify_user. Defaults to Bun.spawn.
87
87
  * Tests inject a mock so notifications never actually fire or speak. */
88
88
  spawner?: Spawner;
89
+ /** Resolves the calling MCP server process's own chat identity from its
90
+ * parent process id (the hook-recorded Claude Code anchor). Wired on the
91
+ * MCP path only; the model cannot influence it, which is what makes it
92
+ * authoritative over the caller-claimed `as` argument. Returns null when
93
+ * no fresh mapping exists (Cursor, unknown server process). */
94
+ chatDerivedIdentity?: () => string | null;
89
95
  }
90
96
 
91
97
  /**
@@ -904,7 +910,7 @@ function renderChatSection(prefs: ChatPrefs | undefined): string {
904
910
  return [
905
911
  "chat:",
906
912
  ` enabled: ${prefs?.enabled ?? "<unset>"} (default: on)`,
907
- ` autoRegister: ${prefs?.autoRegister ?? "<unset>"} (default: on; opencode sessions join the chat directory automatically)`,
913
+ ` autoRegister: ${prefs?.autoRegister ?? "<unset>"} (default: on; opencode sessions join the chat directory on their first idle event)`,
908
914
  ].join("\n");
909
915
  }
910
916
 
@@ -977,7 +983,8 @@ const configSetDef: ToolDef = {
977
983
  ),
978
984
  autoRegister: z.boolean().optional().describe(
979
985
  "Whether opencode sessions join the chat directory automatically " +
980
- "(assigned names). Unset or true is the default; false restores " +
986
+ "(assigned names, on the session's first idle event - not at " +
987
+ "startup). Unset or true is the default; false restores " +
981
988
  "opt-in joining via chat_register only.",
982
989
  ),
983
990
  }).optional().describe(
@@ -1129,6 +1136,13 @@ const sessionSearchDef: ToolDef = {
1129
1136
  },
1130
1137
  opencodeOnly: true,
1131
1138
  async execute(args) {
1139
+ // The schema marks query required, but a caller that cross-wired this
1140
+ // tool with another server's search (different param name) gets args
1141
+ // without it - fail with a usage line instead of a raw dereference
1142
+ // crash ("undefined is not an object (evaluating 'query.toLowerCase')").
1143
+ if (typeof args.query !== "string" || !args.query.trim()) {
1144
+ return `Usage: session_search requires a "query" string (e.g. {"query": "PR 7310 review"}); optional: regex, session_id, limit.`;
1145
+ }
1132
1146
  const dbPath = resolveOpencodeDbPath();
1133
1147
  if (!dbPath) {
1134
1148
  return "Session search is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
@@ -1181,6 +1195,9 @@ const sessionGetDef: ToolDef = {
1181
1195
  },
1182
1196
  opencodeOnly: true,
1183
1197
  async execute(args) {
1198
+ if (typeof args.id !== "string" || !args.id.trim()) {
1199
+ return `Usage: session_get requires an "id" - a part id (prt_...) or message id (msg_...) from session_search results.`;
1200
+ }
1184
1201
  const dbPath = resolveOpencodeDbPath();
1185
1202
  if (!dbPath) {
1186
1203
  return "Session retrieval is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
@@ -1508,10 +1525,13 @@ function chatDisabledReply(): string {
1508
1525
  }
1509
1526
 
1510
1527
  /**
1511
- * Resolves the chat identity for a tool call. opencode supplies the host
1512
- * session; MCP hosts (host === undefined) declare theirs with the `as`
1513
- * argument, which must name a registered session. Returns either a usable
1514
- * identity or the failure text the tool should return.
1528
+ * Resolves the chat identity for a tool call, by priority:
1529
+ * 1. opencode supplies the host-injected session (cannot be claimed).
1530
+ * 2. The MCP server's derived identity (hook-recorded host-pid mapping on
1531
+ * Claude Code - the process fact the model cannot spoof).
1532
+ * 3. The caller-claimed `as` argument (Cursor; documented limit).
1533
+ * Returns either a usable identity or the failure text the tool should
1534
+ * return.
1515
1535
  */
1516
1536
  async function resolveChatIdentity(
1517
1537
  ctx: CoreContext,
@@ -1522,6 +1542,11 @@ async function resolveChatIdentity(
1522
1542
  return { ok: false, error: chatDisabledReply() };
1523
1543
  }
1524
1544
  if (host) return { ok: true, sessionID: host.sessionID, kind: "opencode" };
1545
+ const derived = ctx.chatDerivedIdentity?.() ?? null;
1546
+ if (derived) {
1547
+ const row = ctx.db.findChatSession(derived);
1548
+ if (row) return { ok: true, sessionID: row.session_id, kind: row.host_kind };
1549
+ }
1525
1550
  if (typeof as !== "string" || !as.trim()) {
1526
1551
  return {
1527
1552
  ok: false,
@@ -1529,14 +1554,14 @@ async function resolveChatIdentity(
1529
1554
  "Chat needs an identity: pass `as` with your registered display name (MCP hosts have no session context).",
1530
1555
  };
1531
1556
  }
1532
- const row = ctx.db.findChatSession(as.trim());
1533
- if (!row) {
1557
+ const claimed = ctx.db.findChatSession(as.trim());
1558
+ if (!claimed) {
1534
1559
  return {
1535
1560
  ok: false,
1536
1561
  error: `No registered session named "${as.trim()}" - call chat_register first (or chat_status to check).`,
1537
1562
  };
1538
1563
  }
1539
- return { ok: true, sessionID: row.session_id, kind: row.host_kind };
1564
+ return { ok: true, sessionID: claimed.session_id, kind: claimed.host_kind };
1540
1565
  }
1541
1566
 
1542
1567
  /** The zod arg shared by chat tools whose MCP mode needs the caller's
@@ -1577,9 +1602,9 @@ const chatRegisterDef: ToolDef = {
1577
1602
  "Join the cross-session chat directory so other sessions on this " +
1578
1603
  "machine can message you and you can message them (works from opencode, " +
1579
1604
  "Claude Code, and Cursor). Display names are assigned by thatch, never " +
1580
- "chosen: a slug plus a never-reused counter (the slug comes from your " +
1581
- "session title when one exists, otherwise from a built-in pool), so a " +
1582
- "name always refers to the same session and cannot be claimed by anyone " +
1605
+ "chosen: a name drawn from a built-in pool plus a never-reused counter, " +
1606
+ "so a name always refers to the same session and cannot be claimed by " +
1607
+ "anyone " +
1583
1608
  "else. Call with no arguments to join, to rejoin after chat_unregister, " +
1584
1609
  "or to check your assigned name (opencode sessions are usually " +
1585
1610
  "registered automatically already). On hosts without session context, " +
@@ -1594,6 +1619,27 @@ const chatRegisterDef: ToolDef = {
1594
1619
  async execute(args, ctx, host) {
1595
1620
  if (!chatEnabled(loadConfig().config)) return chatDisabledReply();
1596
1621
  const kind: ChatHostKind = host ? "opencode" : "mcp";
1622
+ // Derived identity (hook-recorded host-pid mapping): the conversation is
1623
+ // already anchored by its own hooks, so this is an idempotent ensure -
1624
+ // the same contract the hook path uses. An explicit join clears the
1625
+ // leave tombstone first: "I want back in" is the model's explicit
1626
+ // intent, and the tombstone must not override it.
1627
+ const derived = !host ? ctx.chatDerivedIdentity?.() ?? null : null;
1628
+ if (derived) {
1629
+ ctx.db.clearChatLeaveTombstone?.(derived);
1630
+ const res = ctx.db.registerChatSession(derived, ctx.defaultStore, null, kind, null, detectWorktreeKind(ctx.projectDir));
1631
+ if (!res.ok) return `Registration failed: ${res.error}`;
1632
+ return (
1633
+ `[registered] ${res.name}\n` +
1634
+ `session_id: ${derived}\n` +
1635
+ `project: ${ctx.defaultStore}\n` +
1636
+ degradedDeliveryNote() +
1637
+ `\n` +
1638
+ `Your identity comes from your host's hook anchor, so it is stable across prompts. ` +
1639
+ `Other sessions can message you by name with chat_send, or reach everyone at once with ` +
1640
+ `chat_broadcast; use chat_list to see who else is available.`
1641
+ );
1642
+ }
1597
1643
  // MCP reclaim: `as` must name an EXISTING registration - the identity
1598
1644
  // printed by the host's thatch hook for this conversation. Names are
1599
1645
  // assigned, so there is no claim path; a stale name from an old
@@ -1676,11 +1722,10 @@ const chatListDef: ToolDef = {
1676
1722
  return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
1677
1723
  }
1678
1724
  const unread = ctx.db.unreadChatCount(identity.sessionID);
1679
- const lines = sessions.map((s) => {
1725
+ const { active, stale } = splitChatRoster(sessions);
1726
+ const line = (s: (typeof sessions)[number]) => {
1680
1727
  const self = s.session_id === identity.sessionID;
1681
- const liveness = s.host_kind === "mcp"
1682
- ? isStale(s, CHAT_STALE_MINUTES) ? "idle" : "active"
1683
- : isStale(s, CHAT_STALE_MINUTES) ? "stale" : "fresh";
1728
+ const liveness = chatLiveness(s);
1684
1729
  const project = s.project ? ` project:${s.project}` : "";
1685
1730
  // The age turns the liveness bucket into a concrete last-check-in;
1686
1731
  // the checkout kind tells sessions on a shared tree who sits in a
@@ -1690,14 +1735,26 @@ const chatListDef: ToolDef = {
1690
1735
  const topic = s.topic ? ` topic:${s.topic}` : "";
1691
1736
  const mailbox = self ? (unread > 0 ? ` ${unread} unread` : "") : "";
1692
1737
  return `- ${s.name} (${s.session_id.slice(0, 12)})${project}${age}${loc} ${liveness}${topic}${self ? " [you]" : ""}${mailbox}`;
1693
- });
1738
+ };
1739
+ const sections: string[] = [];
1740
+ if (active.length > 0) {
1741
+ sections.push(`# Active Sessions\n\n${active.map(line).join("\n")}`);
1742
+ }
1743
+ if (stale.length > 0) {
1744
+ sections.push(
1745
+ `# Stale Sessions\n\n` +
1746
+ `_These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. ` +
1747
+ `Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume._\n\n` +
1748
+ `${stale.map(line).join("\n")}`,
1749
+ );
1750
+ }
1694
1751
  // The name-stability note closes the roster because it answers the
1695
1752
  // question a reader asking "is this the same X as before?" needs:
1696
1753
  // assigned names are unique forever; only hook-anchored MCP identities
1697
1754
  // can be revisited by an old name.
1698
1755
  return (
1699
- `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n` +
1700
- `${lines.join("\n")}\n` +
1756
+ `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n\n` +
1757
+ `${sections.join("\n\n")}\n\n` +
1701
1758
  `Names are assigned by thatch and never reused or reassigned, so the ` +
1702
1759
  `same name is always the same session. A name whose row shows stale or ` +
1703
1760
  `idle has not been seen recently.`
@@ -1728,11 +1785,21 @@ const chatSendDef: ToolDef = {
1728
1785
  // The store returns the recipient it resolved, so no second lookup can
1729
1786
  // race a concurrent unregister between send and confirmation.
1730
1787
  const { name, session_id } = result.recipient;
1788
+ // State the recipient's ACTUAL liveness: the boilerplate "waits until
1789
+ // idle" is only true for a live host. A stale recipient's harness is
1790
+ // probably gone - say so, so the sender can reroute instead of waiting
1791
+ // on a wake that will never fire.
1792
+ const row = ctx.db.findChatSession(session_id);
1793
+ const liveness = row ? chatLiveness(row) : "stale";
1794
+ const delivery =
1795
+ liveness === "fresh"
1796
+ ? `The recipient is fresh (last seen ${row ? humanAge(row.last_seen) : "just now"}) and will be woken when its session goes idle.`
1797
+ : liveness === "active"
1798
+ ? `The recipient is an MCP-host session; it sees this message at its next prompt.`
1799
+ : `NOTE: the recipient is STALE (last seen ${row ? humanAge(row.last_seen) : "unknown"}). Its harness may no longer be running - the message waits unread until that session resumes, and no wake will fire meanwhile. If you meant a different session, check chat_list and resend.`;
1731
1800
  return (
1732
1801
  `[sent] to ${name} (${session_id.slice(0, 12)})\n\n` +
1733
- `The recipient is nudged when its session is idle. If its host process ` +
1734
- `is gone (stale in chat_list), the message waits unread - a dead ` +
1735
- `session never reads it.`
1802
+ `${delivery}`
1736
1803
  );
1737
1804
  },
1738
1805
  };
@@ -1805,7 +1872,7 @@ const chatBroadcastDef: ToolDef = {
1805
1872
  "Broadcast one message to every other registered session on this " +
1806
1873
  "machine at once - for announcements and open questions (\"which of " +
1807
1874
  "you is working on X?\", \"main just moved, rebase if you are based on " +
1808
- "it\"). Stale opencode sessions (dead host processes) are skipped and " +
1875
+ "it\"). Stale opencode sessions (harness stopped heartbeating) are skipped and " +
1809
1876
  "reported, since they will never read the mail; MCP sessions receive " +
1810
1877
  "it at their next prompt. Each recipient is woken like any chat " +
1811
1878
  "message, so broadcast sparingly: every live session spends a model " +
@@ -1858,6 +1925,13 @@ const chatStatusDef: ToolDef = {
1858
1925
  if (!identity.ok) return identity.error;
1859
1926
  const status = ctx.db.chatMessageStatus(identity.sessionID);
1860
1927
  if (!status.registered) {
1928
+ // Distinguish "not YET registered" from "broken": opencode sessions
1929
+ // auto-register when the turn goes idle, so a mid-turn check here is
1930
+ // normal and must not read as a failure: a bare "Not registered" is
1931
+ // indistinguishable from auto-registration being broken.
1932
+ if (host) {
1933
+ return "Not registered YET - this session auto-registers when the turn goes idle (or join now with chat_register).";
1934
+ }
1861
1935
  return "Not registered - call chat_register to join the cross-session chat directory.";
1862
1936
  }
1863
1937
  return (