@jeffober/thatch 0.1.43 → 0.1.45

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
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
2
2
  import { join } from "node:path";
3
3
  import { claudeInstructions, cursorInstructions } from "./prompts";
4
4
  import { installSkills, type InstallReport } from "./skills";
5
+ import { installClaudeCommands } from "./commands";
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // Path resolution
@@ -90,53 +91,111 @@ function writeMcpConfig(path: string, thatchBin: string): void {
90
91
  // CLAUDE.md - append thatch instructions (idempotent)
91
92
  // ---------------------------------------------------------------------------
92
93
 
94
+ /**
95
+ * Sentinel delimiters for the thatch instructions block. HTML comments, not
96
+ * prose: agents edit these files constantly (and normalize punctuation while
97
+ * they are in there), so any prose sentence used as a delimiter eventually
98
+ * stops matching and the block becomes un-updatable. Sentinels survive prose
99
+ * edits because they are not prose.
100
+ */
101
+ export const THATCH_BEGIN = "<!-- thatch:begin -->";
102
+ export const THATCH_END = "<!-- thatch:end -->";
103
+
104
+ /**
105
+ * Legacy delimiters, used by installs before the sentinels: prose sentences
106
+ * from the instructions themselves. Detection-only - they exist so setup can
107
+ * find and migrate (or heal) pre-sentinel blocks. Do not write new blocks
108
+ * with them; any edit to the file's prose breaks the match.
109
+ */
93
110
  const THATCH_MARKER = "# Persistence\n\nThatch provides persistent memory across Claude Code sessions.";
94
111
  const THATCH_END_MARKER = '"Forget X" - `memory_recall` to find it, then `memory_forget`.';
95
112
  const CURSOR_MARKER = "# Persistence\n\nThatch provides persistent memory across Cursor sessions.";
96
113
  const CURSOR_END_MARKER = THATCH_END_MARKER;
97
114
 
98
115
  /**
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.
116
+ * The instructions' last line (punctuation-insensitive tail test): the
117
+ * "heal" rule may only consume file tail when the corruption is the known
118
+ * kind - the legacy block running to EOF. Without this guard, a broken
119
+ * legacy block followed by unrelated user content would eat that content.
102
120
  */
121
+ const INSTRUCTIONS_TAIL_SENTINEL = "memory_forget";
122
+
103
123
  function appendBlock(
104
124
  path: string,
105
125
  instructions: string,
106
- startMarker: string,
107
- endMarker: string,
126
+ legacyStartMarker: string,
127
+ legacyEndMarker: string,
108
128
  ): 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.
129
+ const block = `${THATCH_BEGIN}\n${instructions.trimEnd()}\n${THATCH_END}`;
130
+
131
+ if (!existsSync(path)) {
132
+ mkdirSync(join(path, ".."), { recursive: true });
133
+ writeFileSync(path, block + "\n");
134
+ return;
135
+ }
136
+
137
+ const existing = readFileSync(path, "utf8");
138
+
139
+ // Current format: replace between the sentinels.
140
+ if (existing.includes(THATCH_BEGIN)) {
141
+ const beginIdx = existing.indexOf(THATCH_BEGIN);
142
+ const endIdx = existing.indexOf(THATCH_END, beginIdx);
143
+ if (endIdx >= 0) {
144
+ const afterEnd = endIdx + THATCH_END.length;
145
+ writeFileSync(path, existing.slice(0, beginIdx) + block + existing.slice(afterEnd));
123
146
  return;
124
147
  }
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);
148
+ // Begin without end: the sentinel block itself was corrupted. Same heal
149
+ // rule as the legacy broken case below.
150
+ healBrokenBlock(path, existing, beginIdx, block);
151
+ return;
131
152
  }
153
+
154
+ // Legacy format: prose-sentence delimiters from a pre-sentinel install.
155
+ if (existing.includes(legacyStartMarker)) {
156
+ const startIdx = existing.indexOf(legacyStartMarker);
157
+ const endIdx = existing.indexOf(legacyEndMarker, startIdx);
158
+ if (endIdx >= 0) {
159
+ // Intact legacy block: swap the whole span for the sentinel block.
160
+ const afterEnd = endIdx + legacyEndMarker.length;
161
+ writeFileSync(path, existing.slice(0, startIdx) + block + existing.slice(afterEnd));
162
+ return;
163
+ }
164
+ // Broken legacy block (the observed corruption: an agent edit normalized
165
+ // the prose - hyphens became em dashes - so the end sentence no longer
166
+ // matches). Heal by replacing from the legacy start to EOF, but only
167
+ // when the tail is recognizably the instructions; otherwise leave it
168
+ // for manual repair.
169
+ healBrokenBlock(path, existing, startIdx, block);
170
+ return;
171
+ }
172
+
173
+ // No thatch block yet: append.
174
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n";
175
+ writeFileSync(path, existing + sep + block + "\n");
176
+ }
177
+
178
+ /**
179
+ * Replaces everything from `startIdx` to EOF with the fresh sentinel block,
180
+ * but only when the corrupted tail is recognizably the thatch instructions
181
+ * (the tail test). Reports via return value so callers can surface a
182
+ * skipped heal; the file is left untouched when the guard does not pass.
183
+ */
184
+ function healBrokenBlock(path: string, existing: string, startIdx: number, block: string): boolean {
185
+ const tail = existing.slice(startIdx).slice(-400);
186
+ if (!tail.includes(INSTRUCTIONS_TAIL_SENTINEL)) {
187
+ return false;
188
+ }
189
+ writeFileSync(path, existing.slice(0, startIdx) + block + "\n");
190
+ return true;
132
191
  }
133
192
 
134
193
  function appendInstructions(path: string): void {
135
- appendBlock(path, claudeInstructions() + "\n", THATCH_MARKER, THATCH_END_MARKER);
194
+ appendBlock(path, claudeInstructions(), THATCH_MARKER, THATCH_END_MARKER);
136
195
  }
137
196
 
138
197
  function appendCursorInstructions(path: string): void {
139
- appendBlock(path, cursorInstructions() + "\n", CURSOR_MARKER, CURSOR_END_MARKER);
198
+ appendBlock(path, cursorInstructions(), CURSOR_MARKER, CURSOR_END_MARKER);
140
199
  }
141
200
 
142
201
  // ---------------------------------------------------------------------------
@@ -153,6 +212,7 @@ interface SettingsJson {
153
212
  SessionStart?: { hooks: HookEntry[] }[];
154
213
  PostToolBatch?: { hooks: HookEntry[] }[];
155
214
  UserPromptSubmit?: { hooks: HookEntry[] }[];
215
+ Stop?: { hooks: HookEntry[] }[];
156
216
  };
157
217
  [key: string]: any;
158
218
  }
@@ -178,6 +238,12 @@ function writeHooks(path: string, thatchBin: string): void {
178
238
  // falls back to the static write-nudge. Either output is transcript-visible
179
239
  // since UserPromptSubmit adds stdout to context.
180
240
  const flushCmd = `${thatchBin} flush-tools`;
241
+ // Stop fires when the agent turn ends. chat-notify emits a Stop-hook
242
+ // additionalContext reminder when chat mail is unread - the turn
243
+ // continues so the model can read its mail, the Claude Code analog of
244
+ // opencode's poller wake. The host's loop protections (stop_hook_active,
245
+ // the 8-consecutive cap) plus the delivered stamp bound repeats.
246
+ const notifyCmd = `${thatchBin} chat-notify`;
181
247
 
182
248
  settings.hooks.SessionStart = replaceThatchHooks(
183
249
  settings.hooks.SessionStart ?? [],
@@ -191,6 +257,10 @@ function writeHooks(path: string, thatchBin: string): void {
191
257
  settings.hooks.UserPromptSubmit ?? [],
192
258
  flushCmd,
193
259
  );
260
+ settings.hooks.Stop = replaceThatchHooks(
261
+ settings.hooks.Stop ?? [],
262
+ notifyCmd,
263
+ );
194
264
 
195
265
  mkdirSync(join(path, ".."), { recursive: true });
196
266
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
@@ -251,6 +321,8 @@ export interface SetupResult {
251
321
  settings: string;
252
322
  /** Skill install report for the directory this run wrote to. */
253
323
  skills: InstallReport;
324
+ /** Command files (the /thatch/* actions) written by this run, if any. */
325
+ commands: string[];
254
326
  /** Thatch skills in the opposite scope's dir, if any; never written by this run. */
255
327
  otherScopeSkills: OtherScopeSkills | null;
256
328
  global: boolean;
@@ -291,6 +363,11 @@ export function setupClaudeCode(
291
363
  writeHooks(paths.settingsPath, thatchBin);
292
364
  const skills = installSkills(paths.skillsDir);
293
365
 
366
+ // Command files (the /thatch/* actions Claude Code reads as slash
367
+ // commands) live beside skills under the .claude dir, same scope rules.
368
+ const claudeDir = global ? claudeConfigDir(home) : join(projectDir, ".claude");
369
+ const commands = installClaudeCommands(claudeDir);
370
+
294
371
  // The scope this run did NOT write to. Local runs leave user-scope copies
295
372
  // behind (pre-existing global installs); global runs leave project-local
296
373
  // copies in the repo. Either way the user should hear about them.
@@ -303,6 +380,7 @@ export function setupClaudeCode(
303
380
  claudeMd: paths.claudeMdPath,
304
381
  settings: paths.settingsPath,
305
382
  skills,
383
+ commands,
306
384
  otherScopeSkills: otherScopeInfo(otherDir),
307
385
  global,
308
386
  mcpAddCommand: global
@@ -311,6 +389,92 @@ export function setupClaudeCode(
311
389
  };
312
390
  }
313
391
 
392
+ // ---------------------------------------------------------------------------
393
+ // Global MCP registration (Claude Code)
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Outcome of trying to register the thatch MCP server with the `claude`
398
+ * CLI. `registered` and `already-registered` mean the MCP server is
399
+ * usable; `claude-missing` and `failed` mean the caller must print
400
+ * `manualCommand` for the user to run by hand.
401
+ */
402
+ export interface ClaudeMcpRegistration {
403
+ status: "registered" | "already-registered" | "claude-missing" | "failed";
404
+ manualCommand: string;
405
+ detail: string;
406
+ }
407
+
408
+ export interface CommandRunResult {
409
+ exitCode: number;
410
+ }
411
+
412
+ async function defaultCommandRun(argv: string[]): Promise<CommandRunResult> {
413
+ const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" });
414
+ await proc.exited;
415
+ return { exitCode: proc.exitCode ?? 1 };
416
+ }
417
+
418
+ /**
419
+ * Runs `claude mcp add --scope user` on the user's behalf - the
420
+ * global-setup counterpart of writing .mcp.json, which project-local
421
+ * setup does directly. ~/.claude.json is not hand-writable (the reason
422
+ * setup used to print the command instead), so the claude CLI does it.
423
+ *
424
+ * Idempotent and non-destructive: an existing registration is detected
425
+ * via `claude mcp get thatch` (exit 1 when absent) and left untouched,
426
+ * whatever scope or binary it points at. `claudeBin` and `run` are
427
+ * injectable for tests; when `claude` is missing the result says so and
428
+ * the manual command stands in.
429
+ */
430
+ export async function registerClaudeMcpServer(
431
+ thatchBin: string,
432
+ opts: {
433
+ claudeBin?: string | null;
434
+ run?: (argv: string[]) => Promise<CommandRunResult>;
435
+ } = {},
436
+ ): Promise<ClaudeMcpRegistration> {
437
+ const claudeBin = opts.claudeBin === undefined ? Bun.which("claude") : opts.claudeBin;
438
+ const manualCommand = `claude mcp add --scope user thatch -- ${thatchBin} mcp`;
439
+ const run = opts.run ?? defaultCommandRun;
440
+ if (!claudeBin) {
441
+ return {
442
+ status: "claude-missing",
443
+ manualCommand,
444
+ detail: "claude CLI not found on PATH",
445
+ };
446
+ }
447
+ try {
448
+ const get = await run([claudeBin, "mcp", "get", "thatch"]);
449
+ if (get.exitCode === 0) {
450
+ return {
451
+ status: "already-registered",
452
+ manualCommand,
453
+ detail: "a thatch MCP registration already exists (`claude mcp get thatch` to inspect)",
454
+ };
455
+ }
456
+ const add = await run([claudeBin, "mcp", "add", "--scope", "user", "thatch", "--", thatchBin, "mcp"]);
457
+ if (add.exitCode === 0) {
458
+ return {
459
+ status: "registered",
460
+ manualCommand,
461
+ detail: "registered with `claude mcp add --scope user`",
462
+ };
463
+ }
464
+ return {
465
+ status: "failed",
466
+ manualCommand,
467
+ detail: "`claude mcp add` exited non-zero",
468
+ };
469
+ } catch (err) {
470
+ return {
471
+ status: "failed",
472
+ manualCommand,
473
+ detail: `claude invocation failed: ${err}`,
474
+ };
475
+ }
476
+ }
477
+
314
478
  // ---------------------------------------------------------------------------
315
479
  // Cursor setup - .cursor/mcp.json, AGENTS.md, .cursor/hooks.json, skills
316
480
  // ---------------------------------------------------------------------------
@@ -394,21 +558,29 @@ function writeCursorHooks(path: string, thatchBin: string): void {
394
558
  // beforeSubmitPrompt is Cursor's UserPromptSubmit equivalent. Drains the
395
559
  // queue and prints the extraction nudge as JSON additional_context.
396
560
  const flushCmd = `${thatchBin} flush-tools --json`;
561
+ // stop fires when the agent loop ends. chat-notify emits followup_message
562
+ // (auto-submitted by Cursor as the next user message) when chat mail is
563
+ // unread - the Cursor analog of opencode's poller wake. loop_limit bounds
564
+ // consecutive auto-followups; reading the mail naturally terminates the
565
+ // loop (unread drops to 0), the limit is the backstop.
566
+ const notifyCmd = `${thatchBin} chat-notify`;
397
567
 
398
568
  config.hooks.sessionStart = replaceCursorThatchHooks(config.hooks.sessionStart ?? [], sessionStartCmd);
399
569
  config.hooks.postToolUse = replaceCursorThatchHooks(config.hooks.postToolUse ?? [], bufferCmd);
400
570
  config.hooks.beforeSubmitPrompt = replaceCursorThatchHooks(config.hooks.beforeSubmitPrompt ?? [], flushCmd);
571
+ config.hooks.stop = replaceCursorThatchHooks(config.hooks.stop ?? [], notifyCmd, 3);
401
572
 
402
573
  mkdirSync(join(path, ".."), { recursive: true });
403
574
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
404
575
  }
405
576
 
406
577
  function replaceCursorThatchHooks(
407
- entries: { command: string }[],
578
+ entries: { command: string; loop_limit?: number }[],
408
579
  command: string,
409
- ): { command: string }[] {
580
+ loopLimit?: number,
581
+ ): { command: string; loop_limit?: number }[] {
410
582
  const filtered = entries.filter((e) => !e.command?.includes("thatch"));
411
- filtered.push({ command });
583
+ filtered.push(loopLimit === undefined ? { command } : { command, loop_limit: loopLimit });
412
584
  return filtered;
413
585
  }
414
586
 
@@ -484,12 +656,19 @@ export type SetupStatus =
484
656
 
485
657
  type FileCheck = "installed" | "absent" | "broken";
486
658
 
487
- function checkInstructionsFile(path: string, startMarker: string, endMarker: string): FileCheck {
659
+ function checkInstructionsFile(path: string, legacyStartMarker: string, legacyEndMarker: string): FileCheck {
488
660
  if (!existsSync(path)) return "absent";
489
661
  const content = readFileSync(path, "utf8");
490
- const startIdx = content.indexOf(startMarker);
662
+ // Current format: sentinel-delimited. Begin without end is the same
663
+ // corruption class as the legacy broken case.
664
+ const sentinelIdx = content.indexOf(THATCH_BEGIN);
665
+ if (sentinelIdx !== -1) {
666
+ return content.indexOf(THATCH_END, sentinelIdx) === -1 ? "broken" : "installed";
667
+ }
668
+ // Legacy format: prose-sentence delimiters.
669
+ const startIdx = content.indexOf(legacyStartMarker);
491
670
  if (startIdx === -1) return "absent";
492
- const endIdx = content.indexOf(endMarker, startIdx);
671
+ const endIdx = content.indexOf(legacyEndMarker, startIdx);
493
672
  if (endIdx === -1) return "broken";
494
673
  return "installed";
495
674
  }
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, sortChatRoster, 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
  /**
@@ -446,18 +452,29 @@ const getExtractionPayloadDef: ToolDef = {
446
452
  "Retrieve the queued tool interactions for extraction. Call this with " +
447
453
  "the session_id from the extraction nudge to get the JSON payload " +
448
454
  "(interactions, projectStore, globalStore). Then run the " +
449
- "thatch-fact-extractor skill on the returned payload.",
455
+ "thatch-fact-extractor skill on the returned payload. Omit session_id " +
456
+ "when acting on the session you are running in.",
450
457
  args: {
451
- session_id: z.string().describe(
452
- "The session ID from the extraction nudge. This is the parent session " +
453
- "whose tool interactions are queued for extraction.",
458
+ session_id: z.string().optional().describe(
459
+ "The session whose tool interactions are queued for extraction. " +
460
+ "Omit when acting on the session you are running in - the invoking " +
461
+ "session's ID is used. Pass the parent session's ID when running " +
462
+ "inside a sub-agent dispatched to process another session's queue.",
454
463
  ),
455
464
  },
456
- async execute(args, ctx) {
465
+ async execute(args, ctx, host) {
457
466
  if (!ctx.extractionPayloadProvider) {
458
467
  return "Extraction payload retrieval is not available in this host.";
459
468
  }
460
- const payload = ctx.extractionPayloadProvider(args.session_id as string);
469
+ // The zod shape is shared across hosts, so optionality cannot be
470
+ // per-host at the schema level: MCP hosts have no session context to
471
+ // fall back on, so an omitted session_id only resolves on the
472
+ // opencode path, where the wrapper supplies the invoking session.
473
+ const sessionID = (args.session_id as string | undefined) ?? host?.sessionID;
474
+ if (!sessionID) {
475
+ return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
476
+ }
477
+ const payload = ctx.extractionPayloadProvider(sessionID);
461
478
  if (!payload) {
462
479
  return "No queued tool interactions found for this session.";
463
480
  }
@@ -904,7 +921,7 @@ function renderChatSection(prefs: ChatPrefs | undefined): string {
904
921
  return [
905
922
  "chat:",
906
923
  ` enabled: ${prefs?.enabled ?? "<unset>"} (default: on)`,
907
- ` autoRegister: ${prefs?.autoRegister ?? "<unset>"} (default: on; opencode sessions join the chat directory automatically)`,
924
+ ` autoRegister: ${prefs?.autoRegister ?? "<unset>"} (default: on; opencode sessions join the chat directory on their first idle event)`,
908
925
  ].join("\n");
909
926
  }
910
927
 
@@ -977,7 +994,8 @@ const configSetDef: ToolDef = {
977
994
  ),
978
995
  autoRegister: z.boolean().optional().describe(
979
996
  "Whether opencode sessions join the chat directory automatically " +
980
- "(assigned names). Unset or true is the default; false restores " +
997
+ "(assigned names, on the session's first idle event - not at " +
998
+ "startup). Unset or true is the default; false restores " +
981
999
  "opt-in joining via chat_register only.",
982
1000
  ),
983
1001
  }).optional().describe(
@@ -1129,6 +1147,13 @@ const sessionSearchDef: ToolDef = {
1129
1147
  },
1130
1148
  opencodeOnly: true,
1131
1149
  async execute(args) {
1150
+ // The schema marks query required, but a caller that cross-wired this
1151
+ // tool with another server's search (different param name) gets args
1152
+ // without it - fail with a usage line instead of a raw dereference
1153
+ // crash ("undefined is not an object (evaluating 'query.toLowerCase')").
1154
+ if (typeof args.query !== "string" || !args.query.trim()) {
1155
+ return `Usage: session_search requires a "query" string (e.g. {"query": "PR 7310 review"}); optional: regex, session_id, limit.`;
1156
+ }
1132
1157
  const dbPath = resolveOpencodeDbPath();
1133
1158
  if (!dbPath) {
1134
1159
  return "Session search is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
@@ -1181,6 +1206,9 @@ const sessionGetDef: ToolDef = {
1181
1206
  },
1182
1207
  opencodeOnly: true,
1183
1208
  async execute(args) {
1209
+ if (typeof args.id !== "string" || !args.id.trim()) {
1210
+ return `Usage: session_get requires an "id" - a part id (prt_...) or message id (msg_...) from session_search results.`;
1211
+ }
1184
1212
  const dbPath = resolveOpencodeDbPath();
1185
1213
  if (!dbPath) {
1186
1214
  return "Session retrieval is unavailable: no opencode database was found (set OPENCODE_DB to point at one).";
@@ -1508,10 +1536,13 @@ function chatDisabledReply(): string {
1508
1536
  }
1509
1537
 
1510
1538
  /**
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.
1539
+ * Resolves the chat identity for a tool call, by priority:
1540
+ * 1. opencode supplies the host-injected session (cannot be claimed).
1541
+ * 2. The MCP server's derived identity (hook-recorded host-pid mapping on
1542
+ * Claude Code - the process fact the model cannot spoof).
1543
+ * 3. The caller-claimed `as` argument (Cursor; documented limit).
1544
+ * Returns either a usable identity or the failure text the tool should
1545
+ * return.
1515
1546
  */
1516
1547
  async function resolveChatIdentity(
1517
1548
  ctx: CoreContext,
@@ -1522,6 +1553,11 @@ async function resolveChatIdentity(
1522
1553
  return { ok: false, error: chatDisabledReply() };
1523
1554
  }
1524
1555
  if (host) return { ok: true, sessionID: host.sessionID, kind: "opencode" };
1556
+ const derived = ctx.chatDerivedIdentity?.() ?? null;
1557
+ if (derived) {
1558
+ const row = ctx.db.findChatSession(derived);
1559
+ if (row) return { ok: true, sessionID: row.session_id, kind: row.host_kind };
1560
+ }
1525
1561
  if (typeof as !== "string" || !as.trim()) {
1526
1562
  return {
1527
1563
  ok: false,
@@ -1529,14 +1565,14 @@ async function resolveChatIdentity(
1529
1565
  "Chat needs an identity: pass `as` with your registered display name (MCP hosts have no session context).",
1530
1566
  };
1531
1567
  }
1532
- const row = ctx.db.findChatSession(as.trim());
1533
- if (!row) {
1568
+ const claimed = ctx.db.findChatSession(as.trim());
1569
+ if (!claimed) {
1534
1570
  return {
1535
1571
  ok: false,
1536
1572
  error: `No registered session named "${as.trim()}" - call chat_register first (or chat_status to check).`,
1537
1573
  };
1538
1574
  }
1539
- return { ok: true, sessionID: row.session_id, kind: row.host_kind };
1575
+ return { ok: true, sessionID: claimed.session_id, kind: claimed.host_kind };
1540
1576
  }
1541
1577
 
1542
1578
  /** The zod arg shared by chat tools whose MCP mode needs the caller's
@@ -1577,9 +1613,9 @@ const chatRegisterDef: ToolDef = {
1577
1613
  "Join the cross-session chat directory so other sessions on this " +
1578
1614
  "machine can message you and you can message them (works from opencode, " +
1579
1615
  "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 " +
1616
+ "chosen: a name drawn from a built-in pool plus a never-reused counter, " +
1617
+ "so a name always refers to the same session and cannot be claimed by " +
1618
+ "anyone " +
1583
1619
  "else. Call with no arguments to join, to rejoin after chat_unregister, " +
1584
1620
  "or to check your assigned name (opencode sessions are usually " +
1585
1621
  "registered automatically already). On hosts without session context, " +
@@ -1594,6 +1630,27 @@ const chatRegisterDef: ToolDef = {
1594
1630
  async execute(args, ctx, host) {
1595
1631
  if (!chatEnabled(loadConfig().config)) return chatDisabledReply();
1596
1632
  const kind: ChatHostKind = host ? "opencode" : "mcp";
1633
+ // Derived identity (hook-recorded host-pid mapping): the conversation is
1634
+ // already anchored by its own hooks, so this is an idempotent ensure -
1635
+ // the same contract the hook path uses. An explicit join clears the
1636
+ // leave tombstone first: "I want back in" is the model's explicit
1637
+ // intent, and the tombstone must not override it.
1638
+ const derived = !host ? ctx.chatDerivedIdentity?.() ?? null : null;
1639
+ if (derived) {
1640
+ ctx.db.clearChatLeaveTombstone?.(derived);
1641
+ const res = ctx.db.registerChatSession(derived, ctx.defaultStore, null, kind, null, detectWorktreeKind(ctx.projectDir));
1642
+ if (!res.ok) return `Registration failed: ${res.error}`;
1643
+ return (
1644
+ `[registered] ${res.name}\n` +
1645
+ `session_id: ${derived}\n` +
1646
+ `project: ${ctx.defaultStore}\n` +
1647
+ degradedDeliveryNote() +
1648
+ `\n` +
1649
+ `Your identity comes from your host's hook anchor, so it is stable across prompts. ` +
1650
+ `Other sessions can message you by name with chat_send, or reach everyone at once with ` +
1651
+ `chat_broadcast; use chat_list to see who else is available.`
1652
+ );
1653
+ }
1597
1654
  // MCP reclaim: `as` must name an EXISTING registration - the identity
1598
1655
  // printed by the host's thatch hook for this conversation. Names are
1599
1656
  // assigned, so there is no claim path; a stale name from an old
@@ -1676,11 +1733,10 @@ const chatListDef: ToolDef = {
1676
1733
  return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
1677
1734
  }
1678
1735
  const unread = ctx.db.unreadChatCount(identity.sessionID);
1679
- const lines = sessions.map((s) => {
1736
+ const { active, stale } = splitChatRoster(sortChatRoster(sessions));
1737
+ const line = (s: (typeof sessions)[number]) => {
1680
1738
  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";
1739
+ const liveness = chatLiveness(s);
1684
1740
  const project = s.project ? ` project:${s.project}` : "";
1685
1741
  // The age turns the liveness bucket into a concrete last-check-in;
1686
1742
  // the checkout kind tells sessions on a shared tree who sits in a
@@ -1690,14 +1746,26 @@ const chatListDef: ToolDef = {
1690
1746
  const topic = s.topic ? ` topic:${s.topic}` : "";
1691
1747
  const mailbox = self ? (unread > 0 ? ` ${unread} unread` : "") : "";
1692
1748
  return `- ${s.name} (${s.session_id.slice(0, 12)})${project}${age}${loc} ${liveness}${topic}${self ? " [you]" : ""}${mailbox}`;
1693
- });
1749
+ };
1750
+ const sections: string[] = [];
1751
+ if (active.length > 0) {
1752
+ sections.push(`# Active Sessions\n\n${active.map(line).join("\n")}`);
1753
+ }
1754
+ if (stale.length > 0) {
1755
+ sections.push(
1756
+ `# Stale Sessions\n\n` +
1757
+ `_These sessions have missed their heartbeat for over ${Math.round(CHAT_STALE_MS / 1000)} seconds. ` +
1758
+ `Their harnesses may no longer be running (stopped, or the machine slept); mail waits until they resume._\n\n` +
1759
+ `${stale.map(line).join("\n")}`,
1760
+ );
1761
+ }
1694
1762
  // The name-stability note closes the roster because it answers the
1695
1763
  // question a reader asking "is this the same X as before?" needs:
1696
1764
  // assigned names are unique forever; only hook-anchored MCP identities
1697
1765
  // can be revisited by an old name.
1698
1766
  return (
1699
- `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n` +
1700
- `${lines.join("\n")}\n` +
1767
+ `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n\n` +
1768
+ `${sections.join("\n\n")}\n\n` +
1701
1769
  `Names are assigned by thatch and never reused or reassigned, so the ` +
1702
1770
  `same name is always the same session. A name whose row shows stale or ` +
1703
1771
  `idle has not been seen recently.`
@@ -1728,11 +1796,21 @@ const chatSendDef: ToolDef = {
1728
1796
  // The store returns the recipient it resolved, so no second lookup can
1729
1797
  // race a concurrent unregister between send and confirmation.
1730
1798
  const { name, session_id } = result.recipient;
1799
+ // State the recipient's ACTUAL liveness: the boilerplate "waits until
1800
+ // idle" is only true for a live host. A stale recipient's harness is
1801
+ // probably gone - say so, so the sender can reroute instead of waiting
1802
+ // on a wake that will never fire.
1803
+ const row = ctx.db.findChatSession(session_id);
1804
+ const liveness = row ? chatLiveness(row) : "stale";
1805
+ const delivery =
1806
+ liveness === "fresh"
1807
+ ? `The recipient is fresh (last seen ${row ? humanAge(row.last_seen) : "just now"}) and will be woken when its session goes idle.`
1808
+ : liveness === "active"
1809
+ ? `The recipient is an MCP-host session; it sees this message at its next prompt.`
1810
+ : `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
1811
  return (
1732
1812
  `[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.`
1813
+ `${delivery}`
1736
1814
  );
1737
1815
  },
1738
1816
  };
@@ -1805,7 +1883,7 @@ const chatBroadcastDef: ToolDef = {
1805
1883
  "Broadcast one message to every other registered session on this " +
1806
1884
  "machine at once - for announcements and open questions (\"which of " +
1807
1885
  "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 " +
1886
+ "it\"). Stale opencode sessions (harness stopped heartbeating) are skipped and " +
1809
1887
  "reported, since they will never read the mail; MCP sessions receive " +
1810
1888
  "it at their next prompt. Each recipient is woken like any chat " +
1811
1889
  "message, so broadcast sparingly: every live session spends a model " +
@@ -1858,6 +1936,13 @@ const chatStatusDef: ToolDef = {
1858
1936
  if (!identity.ok) return identity.error;
1859
1937
  const status = ctx.db.chatMessageStatus(identity.sessionID);
1860
1938
  if (!status.registered) {
1939
+ // Distinguish "not YET registered" from "broken": opencode sessions
1940
+ // auto-register when the turn goes idle, so a mid-turn check here is
1941
+ // normal and must not read as a failure: a bare "Not registered" is
1942
+ // indistinguishable from auto-registration being broken.
1943
+ if (host) {
1944
+ return "Not registered YET - this session auto-registers when the turn goes idle (or join now with chat_register).";
1945
+ }
1861
1946
  return "Not registered - call chat_register to join the cross-session chat directory.";
1862
1947
  }
1863
1948
  return (