@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/README.md +84 -9
- package/bin/thatch +307 -95
- package/docs/dev/README.md +12 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/commands.md +74 -0
- package/docs/dev/features/cross-session-chat.md +128 -45
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/extraction.md +5 -0
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +20 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +20 -8
- package/docs/plans/thatch-actions.md +179 -0
- package/docs/user/README.md +10 -4
- package/docs/user/cli.md +45 -44
- package/docs/user/commands.md +50 -0
- package/docs/user/cross-session-chat.md +94 -22
- package/docs/user/setup.md +9 -3
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +372 -185
- package/src/commands.ts +138 -19
- package/src/db.ts +46 -0
- package/src/debug.ts +44 -0
- package/src/embeddings.ts +42 -1
- package/src/index.ts +262 -39
- package/src/mcp.ts +78 -3
- package/src/prompts.ts +57 -3
- package/src/setup.ts +212 -33
- package/src/tool-defs.ts +115 -30
- package/src/watchers.ts +51 -29
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
126
|
+
legacyStartMarker: string,
|
|
127
|
+
legacyEndMarker: string,
|
|
108
128
|
): void {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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()
|
|
194
|
+
appendBlock(path, claudeInstructions(), THATCH_MARKER, THATCH_END_MARKER);
|
|
136
195
|
}
|
|
137
196
|
|
|
138
197
|
function appendCursorInstructions(path: string): void {
|
|
139
|
-
appendBlock(path, cursorInstructions()
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
453
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
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
|
|
1533
|
-
if (!
|
|
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:
|
|
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
|
|
1581
|
-
"
|
|
1582
|
-
"
|
|
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
|
|
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
|
|
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
|
-
`${
|
|
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
|
-
`
|
|
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 (
|
|
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 (
|