@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/README.md +4 -2
- package/bin/thatch +270 -93
- package/docs/dev/README.md +8 -1
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/cross-session-chat.md +111 -42
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +12 -0
- package/docs/dev/mcp-parity.md +1 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +6 -4
- package/docs/user/cli.md +35 -43
- package/docs/user/cross-session-chat.md +89 -21
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +344 -185
- package/src/commands.ts +16 -6
- package/src/db.ts +37 -0
- package/src/debug.ts +44 -0
- package/src/index.ts +153 -39
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +2 -2
- package/src/setup.ts +203 -33
- package/src/tool-defs.ts +98 -24
- package/src/watchers.ts +51 -29
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
107
|
-
|
|
125
|
+
legacyStartMarker: string,
|
|
126
|
+
legacyEndMarker: string,
|
|
108
127
|
): void {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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()
|
|
193
|
+
appendBlock(path, claudeInstructions(), THATCH_MARKER, THATCH_END_MARKER);
|
|
136
194
|
}
|
|
137
195
|
|
|
138
196
|
function appendCursorInstructions(path: string): void {
|
|
139
|
-
appendBlock(path, cursorInstructions()
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
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
|
|
1533
|
-
if (!
|
|
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:
|
|
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
|
|
1581
|
-
"
|
|
1582
|
-
"
|
|
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
|
|
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
|
|
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
|
-
`${
|
|
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
|
-
`
|
|
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 (
|
|
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 (
|