@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/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createDebugLog } from "./debug";
|
|
2
4
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
3
5
|
import { ThatchDB } from "./db";
|
|
4
6
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
@@ -24,7 +26,7 @@ import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
|
24
26
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
25
27
|
import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
|
|
26
28
|
import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
27
|
-
import { ChatPoller,
|
|
29
|
+
import { ChatPoller, createWakeGate, isDefaultSessionTitle } from "./chat";
|
|
28
30
|
import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
|
|
29
31
|
import pkg from "../package.json";
|
|
30
32
|
|
|
@@ -62,6 +64,104 @@ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit"
|
|
|
62
64
|
"thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Parses the session id a harness was started to continue from an argument
|
|
69
|
+
* list (`opencode -s <id>` / `--session <id>` / `--session=<id>`). Pure:
|
|
70
|
+
* the caller supplies the list (see startupSessionId for where it comes
|
|
71
|
+
* from). The framework passes no session id to plugins and resuming a
|
|
72
|
+
* session fires no events, so the command line is the only signal that a
|
|
73
|
+
* fresh harness is the new home of a continued session.
|
|
74
|
+
*/
|
|
75
|
+
export function startupSessionIdFromArgv(argv: string[]): string | null {
|
|
76
|
+
for (let i = 0; i < argv.length; i++) {
|
|
77
|
+
const arg = argv[i];
|
|
78
|
+
if (arg === "-s" || arg === "--session") return argv[i + 1] ?? null;
|
|
79
|
+
if (arg.startsWith("--session=")) return arg.slice("--session=".length) || null;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the harness was started with `-c`/`--continue` (continue the most
|
|
86
|
+
* recent session). Pure. Yargs-style boolean flags only: `--continue=false`
|
|
87
|
+
* reads as the negation, everything else as a plain switch. The session id
|
|
88
|
+
* itself is not on the command line - the harness resolves "most recent
|
|
89
|
+
* top-level session" at startup, which the plugin replicates via the SDK
|
|
90
|
+
* (continuesLastSessionId).
|
|
91
|
+
*/
|
|
92
|
+
export function continuesLastSessionFromArgv(argv: string[]): boolean {
|
|
93
|
+
for (const arg of argv) {
|
|
94
|
+
if (arg === "-c" || arg === "--continue") return true;
|
|
95
|
+
if (arg.startsWith("--continue=")) return arg.slice("--continue=".length) !== "false";
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Picks the session `opencode -c` continues: the most recently updated
|
|
102
|
+
* top-level session. Pure; the caller supplies the session list from
|
|
103
|
+
* client.session.list() (directory-scoped, like the plugin's own client).
|
|
104
|
+
* The server's list order is not trusted - the TUI re-sorts by
|
|
105
|
+
* time.updated before picking, and so does this.
|
|
106
|
+
*/
|
|
107
|
+
export function continuesLastSessionId(
|
|
108
|
+
sessions: { id: string; parentID?: string; time?: { updated?: number } }[],
|
|
109
|
+
): string | null {
|
|
110
|
+
return (
|
|
111
|
+
[...sessions]
|
|
112
|
+
.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
|
113
|
+
.find((s) => !s.parentID)?.id ?? null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The plugin does not run in the process's main thread: opencode's TUI
|
|
118
|
+
// spawns a worker thread (cli/tui/worker.js) to host the server, and worker
|
|
119
|
+
// threads get their own argv (just the worker script) with none of the CLI
|
|
120
|
+
// flags. The thread still shares the process's pid, so the OS-level command
|
|
121
|
+
// line for our own pid is the real `opencode -s ses_...` invocation. Read it
|
|
122
|
+
// from /proc on Linux or via ps on macOS. Returns [] when unavailable;
|
|
123
|
+
// callers treat that as "no session flag". The readers are injectable so
|
|
124
|
+
// tests can drive each branch without a real /proc or ps.
|
|
125
|
+
export interface OsArgsDeps {
|
|
126
|
+
/** Reads a file as UTF-8, throwing when it does not exist. */
|
|
127
|
+
readFile: (path: string) => string;
|
|
128
|
+
/** Runs `ps -p <pid> -o args=`; returns its exit code and stdout. */
|
|
129
|
+
ps: (pid: number) => { exitCode: number; stdout: string };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const defaultOsArgsDeps: OsArgsDeps = {
|
|
133
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
134
|
+
ps: (pid) => {
|
|
135
|
+
const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "args="]);
|
|
136
|
+
return { exitCode: res.exitCode, stdout: res.stdout.toString() };
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function osProcessArgs(deps: OsArgsDeps = defaultOsArgsDeps, pid = process.pid): string[] {
|
|
141
|
+
try {
|
|
142
|
+
// Linux: cmdline is NUL-separated argv, exact and cheap.
|
|
143
|
+
return deps.readFile("/proc/self/cmdline").split("\0").filter(Boolean);
|
|
144
|
+
} catch {
|
|
145
|
+
// macOS has no /proc; ask ps for this pid's full command. Splitting on
|
|
146
|
+
// whitespace is safe for our purpose: session ids never contain spaces.
|
|
147
|
+
try {
|
|
148
|
+
const res = deps.ps(pid);
|
|
149
|
+
if (res.exitCode !== 0) return [];
|
|
150
|
+
return res.stdout.trim().split(/\s+/).filter(Boolean);
|
|
151
|
+
} catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Resolve the session id this harness was launched to resume, if any:
|
|
158
|
+
// prefer the thread-local argv (covers tests and any non-worker host), then
|
|
159
|
+
// fall back to the process's OS-level command line (the real harness path).
|
|
160
|
+
// The caller passes the OS command line in so it is read once per init.
|
|
161
|
+
export function startupSessionId(osArgs: string[]): string | null {
|
|
162
|
+
return startupSessionIdFromArgv(process.argv) ?? startupSessionIdFromArgv(osArgs);
|
|
163
|
+
}
|
|
164
|
+
|
|
65
165
|
export const server: Plugin = async ({ client, worktree }) => {
|
|
66
166
|
// The opencode server's cwd is wherever the server happened to start;
|
|
67
167
|
// `worktree` is the project this plugin instance actually serves.
|
|
@@ -78,6 +178,9 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
78
178
|
const chatOn = chatEnabled(loadConfig(dbPath).config);
|
|
79
179
|
|
|
80
180
|
const db = new ThatchDB(dbPath);
|
|
181
|
+
// Opt-in diagnostics (THATCH_DEBUG), written to debug.log beside the db.
|
|
182
|
+
// See debug.ts for the tag filter semantics.
|
|
183
|
+
const debug = createDebugLog(dbPath);
|
|
81
184
|
const model = new BgeEmbeddingModel(modelName);
|
|
82
185
|
const extraction = new ExtractionPipeline();
|
|
83
186
|
|
|
@@ -119,22 +222,17 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
119
222
|
// event stream, and a stale "idle" turns a wake into mid-turn context
|
|
120
223
|
// injection (observed live: a session read mail mid-burst, contradicted
|
|
121
224
|
// its own in-flight plan, and looped).
|
|
122
|
-
const canPromptSession =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
225
|
+
const canPromptSession = createWakeGate({
|
|
226
|
+
isCompacting: (sessionID) => compacting.has(sessionID),
|
|
227
|
+
mappedStatus: (sessionID) => sessionStatus.get(sessionID),
|
|
228
|
+
fetchStatuses: async () => {
|
|
126
229
|
const { data } = await client.session.status();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const live = data?.[sessionID];
|
|
131
|
-
return !live || live.type === "idle";
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// Unreachable server: do not deliver (the mail stays pending).
|
|
230
|
+
return data ?? {};
|
|
231
|
+
},
|
|
232
|
+
onStatusError: (sessionID, err) => {
|
|
134
233
|
console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
234
|
+
},
|
|
235
|
+
});
|
|
138
236
|
|
|
139
237
|
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
140
238
|
// and branch watching, plus local command watches). Deliberately
|
|
@@ -187,24 +285,30 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
187
285
|
});
|
|
188
286
|
watchers.start();
|
|
189
287
|
|
|
190
|
-
// The
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
288
|
+
// The session this harness was launched to resume, resolved at startup:
|
|
289
|
+
// `-s <id>` names it on the command line, `-c` means "most recent in this
|
|
290
|
+
// directory" and resolves through the SDK. Either way the poller hosts it
|
|
291
|
+
// from the first beat, so the heartbeat and delivery are live before the
|
|
292
|
+
// user's first prompt.
|
|
293
|
+
let resumedSession: string | null = null;
|
|
294
|
+
|
|
295
|
+
// Chat names assigned by the startup resume paths (-s and -c) wait here
|
|
296
|
+
// until the session's first message or idle event, which toast them. At
|
|
297
|
+
// plugin-init time the TUI is not yet connected, so an immediate toast
|
|
298
|
+
// would be dropped silently.
|
|
299
|
+
const chatStartupNames = new Map<string, string>();
|
|
300
|
+
|
|
197
301
|
const chatPoller = new ChatPoller({
|
|
198
302
|
store: db,
|
|
199
303
|
hostedSessions: () => {
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// fresh-forever and burn
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return hosted;
|
|
304
|
+
// Hosting = the sessions this harness serves: those seen as events
|
|
305
|
+
// here (used in this TUI) plus the resumed startup session. Sub-agent
|
|
306
|
+
// children are excluded even if someone registered one - a
|
|
307
|
+
// registered child would be heartbeat-ed fresh-forever and burn
|
|
308
|
+
// nudge budget on undeliverable wake prompts.
|
|
309
|
+
const hosted = [...sessionStatus.keys()];
|
|
310
|
+
if (resumedSession) hosted.push(resumedSession);
|
|
311
|
+
return hosted.filter((id) => !childToParent.has(id));
|
|
208
312
|
},
|
|
209
313
|
deliver: async (sessionID, senders, count) => {
|
|
210
314
|
await client.session.promptAsync({
|
|
@@ -231,6 +335,72 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
231
335
|
// sessions and burn wake budget against a feature the user turned off.
|
|
232
336
|
if (chatOn) chatPoller.start();
|
|
233
337
|
|
|
338
|
+
// Startup registration: `opencode -s <id>` continues a session whose
|
|
339
|
+
// directory row may predate this process (its harness died, or the
|
|
340
|
+
// machine restarted); `opencode -c` continues the most recent session in
|
|
341
|
+
// this directory. The framework passes no session id to plugins and
|
|
342
|
+
// resuming fires no events, so the command line is the source (see
|
|
343
|
+
// startupSessionId). Registering here RECLAIMS the row - the name is
|
|
344
|
+
// owned by the session id and never changes - and refreshes its
|
|
345
|
+
// heartbeat: synchronously for -s (local SQLite, it cannot fail on a
|
|
346
|
+
// server that is still starting); for -c after an SDK list call resolves
|
|
347
|
+
// the target. The async tail (title fetch, asleep-mail delivery) is
|
|
348
|
+
// best-effort - the poller retries whatever it misses, and the first
|
|
349
|
+
// idle converges the topic.
|
|
350
|
+
const reclaimTail = (sessionID: string) => {
|
|
351
|
+
void (async () => {
|
|
352
|
+
try {
|
|
353
|
+
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
354
|
+
const title = data?.title ?? "";
|
|
355
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
356
|
+
if (topic) db.refreshChatTopic(sessionID, topic);
|
|
357
|
+
} catch {
|
|
358
|
+
// Server may still be starting; the topic converges on the
|
|
359
|
+
// session's first idle either way.
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
// Asleep-mail: deliver what queued while this session was away,
|
|
363
|
+
// through the normal path (gate, nudge, toast, stamps).
|
|
364
|
+
await chatPoller.deliverPending();
|
|
365
|
+
} catch {
|
|
366
|
+
// The poller cycle retries delivery; nothing to do here.
|
|
367
|
+
}
|
|
368
|
+
})();
|
|
369
|
+
};
|
|
370
|
+
const osArgs = osProcessArgs();
|
|
371
|
+
const startupSession = startupSessionId(osArgs);
|
|
372
|
+
const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
|
|
373
|
+
const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
|
|
374
|
+
const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
|
|
375
|
+
debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} continue=${continueLast} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
|
|
376
|
+
if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
|
|
377
|
+
resumedSession = startupSession;
|
|
378
|
+
const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
379
|
+
debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
380
|
+
if (res.ok) {
|
|
381
|
+
chatStartupNames.set(startupSession, res.name);
|
|
382
|
+
reclaimTail(startupSession);
|
|
383
|
+
}
|
|
384
|
+
} else if (chatOn && autoRegisterOn && continueLast) {
|
|
385
|
+
void (async () => {
|
|
386
|
+
try {
|
|
387
|
+
const { data } = await client.session.list();
|
|
388
|
+
const target = continuesLastSessionId(data ?? []);
|
|
389
|
+
debug("chat:startup", `-c resolved: ${target ?? "none"}`);
|
|
390
|
+
if (!target || db.hasChatLeaveTombstone(target)) return;
|
|
391
|
+
resumedSession = target;
|
|
392
|
+
const res = db.registerChatSession(target, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
393
|
+
debug("chat:startup", `registration for ${target}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
394
|
+
if (res.ok) {
|
|
395
|
+
chatStartupNames.set(target, res.name);
|
|
396
|
+
reclaimTail(target);
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error(`[thatch] chat -c startup registration failed: ${err}`);
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
402
|
+
}
|
|
403
|
+
|
|
234
404
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
235
405
|
// a session is in this set - the agent can't call tools during summary
|
|
236
406
|
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
@@ -592,6 +762,42 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
592
762
|
// nothing (and fire spurious toasts when thresholds cross). Skip
|
|
593
763
|
// them entirely.
|
|
594
764
|
if (isChatEchoParts(output.parts)) return;
|
|
765
|
+
|
|
766
|
+
// Prompt-path registration: a brand-new session joins the chat
|
|
767
|
+
// directory the moment its first real user message arrives - before
|
|
768
|
+
// the model runs - so it is addressable within that first turn (the
|
|
769
|
+
// first-idle path alone leaves a turn-long dark window). Local
|
|
770
|
+
// SQLite, so it runs inline; on an already-registered session this
|
|
771
|
+
// is a free mid-turn heartbeat touch. Sub-agent children are excluded
|
|
772
|
+
// (they register only by mistake, and their deletion tombstones the
|
|
773
|
+
// row), as are sessions that left via chat_unregister. The idle path
|
|
774
|
+
// below stays as the backstop and converges the topic.
|
|
775
|
+
if (
|
|
776
|
+
chatOn &&
|
|
777
|
+
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
778
|
+
input.sessionID &&
|
|
779
|
+
!childToParent.has(input.sessionID) &&
|
|
780
|
+
!db.hasChatLeaveTombstone(input.sessionID)
|
|
781
|
+
) {
|
|
782
|
+
try {
|
|
783
|
+
const res = db.registerChatSession(input.sessionID, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
784
|
+
// First registration - or a startup resume reclaiming its name,
|
|
785
|
+
// announced here because the TUI was not connected at init - is
|
|
786
|
+
// the one moment the user should notice: a quiet toast, not a
|
|
787
|
+
// conversation message.
|
|
788
|
+
if (res.ok && (res.created || chatStartupNames.has(input.sessionID))) {
|
|
789
|
+
chatStartupNames.delete(input.sessionID);
|
|
790
|
+
try {
|
|
791
|
+
await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
792
|
+
} catch {
|
|
793
|
+
// Headless or disconnected TUI - registration stands.
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} catch (err) {
|
|
797
|
+
console.error(`[thatch] chat prompt-register failed for ${input.sessionID}: ${err}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
595
801
|
if (compacting.has(input.sessionID)) {
|
|
596
802
|
// The session is marked as compacting. If this message is the
|
|
597
803
|
// compaction summary generation itself (has a compaction-type part),
|
|
@@ -966,20 +1172,37 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
966
1172
|
// Auto-registration: every top-level opencode session joins the
|
|
967
1173
|
// chat directory on its first idle event (unless the user turned
|
|
968
1174
|
// auto-registration off). The name is assigned, never chosen - a
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
1175
|
+
// pool draw plus a never-reused counter, kept short because it
|
|
1176
|
+
// appears in wake prompts and rosters. The live session title rides
|
|
1177
|
+
// the topic column instead (refreshed on every idle), so the name
|
|
1178
|
+
// and the description stay separate identities. Fire-and-forget: a
|
|
1179
|
+
// failed registration or title fetch must never block event
|
|
1180
|
+
// delivery.
|
|
975
1181
|
if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
|
|
976
1182
|
void (async () => {
|
|
977
1183
|
try {
|
|
978
1184
|
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
979
1185
|
const title = data?.title ?? "";
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1186
|
+
// Placeholder titles never become topics (the real one
|
|
1187
|
+
// converges on a later idle via refreshChatTopic).
|
|
1188
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
1189
|
+
const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
|
|
1190
|
+
// The topic converges as the auto-titler lands a real title:
|
|
1191
|
+
// registration is once-per-session, so later idles must
|
|
1192
|
+
// refresh it explicitly.
|
|
1193
|
+
if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
|
|
1194
|
+
// First registration - or a startup resume reclaiming its
|
|
1195
|
+
// name, announced here because the TUI was not connected at
|
|
1196
|
+
// init - is the one moment the user should notice: a quiet
|
|
1197
|
+
// toast, not a conversation message.
|
|
1198
|
+
if (res.ok && (res.created || chatStartupNames.has(sessionID))) {
|
|
1199
|
+
chatStartupNames.delete(sessionID);
|
|
1200
|
+
try {
|
|
1201
|
+
await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
1202
|
+
} catch {
|
|
1203
|
+
// Headless or disconnected TUI - registration stands.
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
983
1206
|
} catch (err) {
|
|
984
1207
|
console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
|
|
985
1208
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ThatchDB } from "./db";
|
|
4
|
+
import { mcpSessionID } from "./chat";
|
|
4
5
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
5
6
|
import { detectRepo } from "./git";
|
|
6
7
|
import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
|
|
7
8
|
import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
|
|
9
|
+
import { actionDefs, type ActionDef } from "./commands";
|
|
10
|
+
import { mcpToolName } from "./prompts";
|
|
8
11
|
import { SidebandServer, sidebandSocketPath } from "./sideband";
|
|
9
12
|
import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
|
|
10
13
|
import { buildExtractionPayload } from "./extraction";
|
|
@@ -43,11 +46,13 @@ const SERVER_NAME = "thatch";
|
|
|
43
46
|
const SERVER_VERSION = pkg.version;
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
* MCP capabilities declared in the initialize response.
|
|
47
|
-
*
|
|
49
|
+
* MCP capabilities declared in the initialize response. Tools plus the
|
|
50
|
+
* prompts that Cursor surfaces as slash commands; no resources or
|
|
51
|
+
* subscriptions.
|
|
48
52
|
*/
|
|
49
53
|
const CAPABILITIES = {
|
|
50
54
|
tools: { listChanged: false },
|
|
55
|
+
prompts: { listChanged: false },
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
// ---------------------------------------------------------------------------
|
|
@@ -76,6 +81,25 @@ export function compileTools(): Map<string, CompiledTool> {
|
|
|
76
81
|
return map;
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Prompt index - the on-demand actions Cursor surfaces as slash commands
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The prompts the MCP server exposes: the shared actions minus the
|
|
90
|
+
* opencode-only ones. Prompts render the same instruction bodies as the
|
|
91
|
+
* command files (src/commands.ts) without the $ARGUMENTS section - the
|
|
92
|
+
* optional focus argument stands in for it.
|
|
93
|
+
*/
|
|
94
|
+
export function compilePrompts(): Map<string, ActionDef> {
|
|
95
|
+
const map = new Map<string, ActionDef>();
|
|
96
|
+
for (const def of actionDefs(mcpToolName)) {
|
|
97
|
+
if (def.opencodeOnly) continue;
|
|
98
|
+
map.set(def.name, def);
|
|
99
|
+
}
|
|
100
|
+
return map;
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
// ---------------------------------------------------------------------------
|
|
80
104
|
// MCP server
|
|
81
105
|
// ---------------------------------------------------------------------------
|
|
@@ -134,8 +158,24 @@ export async function runMcpServer(): Promise<void> {
|
|
|
134
158
|
defaultStore: repo,
|
|
135
159
|
extractionPayloadProvider,
|
|
136
160
|
drainExtractionQueue,
|
|
161
|
+
// Claude Code identity anchor, two model-proof layers:
|
|
162
|
+
// 1. The hooks record (parent pid -> session) from payloads the model
|
|
163
|
+
// cannot influence, and this server is a child of the same Claude
|
|
164
|
+
// Code process - current even after a session fork.
|
|
165
|
+
// 2. CLAUDE_CODE_SESSION_ID, set by the host in this server's env at
|
|
166
|
+
// spawn ("retains the ID it was spawned with") - covers the case
|
|
167
|
+
// where hook and server parent pids diverge, but goes stale after a
|
|
168
|
+
// fork/continue, so it is the fallback, not the primary.
|
|
169
|
+
// A miss on both falls back to the caller-claimed `as` (Cursor).
|
|
170
|
+
chatDerivedIdentity: () => {
|
|
171
|
+
const mapped = db.findChatSessionByHostPid(process.ppid, 600);
|
|
172
|
+
if (mapped) return mapped;
|
|
173
|
+
const envID = process.env.CLAUDE_CODE_SESSION_ID;
|
|
174
|
+
return envID ? db.findChatSession(mcpSessionID(envID))?.session_id ?? null : null;
|
|
175
|
+
},
|
|
137
176
|
};
|
|
138
177
|
const tools = compileTools();
|
|
178
|
+
const prompts = compilePrompts();
|
|
139
179
|
|
|
140
180
|
// Check whether `thatch setup` was run for the current host. If not, or if
|
|
141
181
|
// markers are broken, surface a warning on the first tools/call response so
|
|
@@ -232,7 +272,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
232
272
|
|
|
233
273
|
// Notifications (no id) are fire-and-forget - no response expected.
|
|
234
274
|
const isNotification = req.id === null || req.id === undefined;
|
|
235
|
-
const res = await dispatch(req, tools, ctx);
|
|
275
|
+
const res = await dispatch(req, tools, prompts, ctx);
|
|
236
276
|
if (res === null || isNotification) continue;
|
|
237
277
|
|
|
238
278
|
// Surface the setup warning on the first tools/call response so the
|
|
@@ -274,6 +314,7 @@ export async function runMcpServer(): Promise<void> {
|
|
|
274
314
|
async function dispatch(
|
|
275
315
|
req: JsonRpcRequest,
|
|
276
316
|
tools: Map<string, CompiledTool>,
|
|
317
|
+
prompts: Map<string, ActionDef>,
|
|
277
318
|
ctx: CoreContext,
|
|
278
319
|
): Promise<JsonRpcResponse | null> {
|
|
279
320
|
const { id, method } = req;
|
|
@@ -339,6 +380,40 @@ async function dispatch(
|
|
|
339
380
|
}
|
|
340
381
|
}
|
|
341
382
|
|
|
383
|
+
case "prompts/list":
|
|
384
|
+
return ok(id, {
|
|
385
|
+
prompts: [...prompts.values()].map((p) => ({
|
|
386
|
+
name: p.name,
|
|
387
|
+
description: p.description,
|
|
388
|
+
arguments: [
|
|
389
|
+
{
|
|
390
|
+
name: "focus",
|
|
391
|
+
description: "Optional scope for the action, e.g. which store to work on",
|
|
392
|
+
required: false,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
})),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
case "prompts/get": {
|
|
399
|
+
const prompt = prompts.get(req.params?.name);
|
|
400
|
+
if (!prompt) {
|
|
401
|
+
return {
|
|
402
|
+
jsonrpc: "2.0",
|
|
403
|
+
id,
|
|
404
|
+
error: { code: METHOD_NOT_FOUND, message: `Unknown prompt: ${req.params?.name}` },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const focus = req.params?.arguments?.focus;
|
|
408
|
+
const text = focus
|
|
409
|
+
? `${prompt.body}\n\nFocus: ${focus}`
|
|
410
|
+
: prompt.body;
|
|
411
|
+
return ok(id, {
|
|
412
|
+
description: prompt.description,
|
|
413
|
+
messages: [{ role: "user", content: { type: "text", text } }],
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
342
417
|
case "ping":
|
|
343
418
|
return ok(id, {});
|
|
344
419
|
|
package/src/prompts.ts
CHANGED
|
@@ -20,8 +20,8 @@ const chatPromptSection = `
|
|
|
20
20
|
|
|
21
21
|
Sessions on this machine - opencode, Claude Code, and Cursor alike - can
|
|
22
22
|
message each other through thatch. opencode sessions join the directory
|
|
23
|
-
automatically: your display name is assigned by thatch (a
|
|
24
|
-
|
|
23
|
+
automatically: your display name is assigned by thatch (a name drawn from
|
|
24
|
+
a built-in pool plus a counter, e.g. al-go-rithm-00001) and is never
|
|
25
25
|
reassigned or reused, so a given name is always the same session.
|
|
26
26
|
thatch_chat_register with no arguments reports your name or rejoins after
|
|
27
27
|
unregistering. On MCP hosts, pass the name your thatch hook printed as the
|
|
@@ -620,6 +620,59 @@ export function versionWarningNudge(message: string): string {
|
|
|
620
620
|
return `[thatch] ${message} This is a system nudge, not user input. After informing the user, continue with their request.`;
|
|
621
621
|
}
|
|
622
622
|
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Prompt cores - behavior instruction bodies shared by nudges and /thatch
|
|
625
|
+
// command files (src/commands.ts). A core is the host-agnostic instruction
|
|
626
|
+
// body for one behavior. The nudge envelope adds escalation tiers and the
|
|
627
|
+
// stop-and-wait ending; the command envelope adds frontmatter and the
|
|
628
|
+
// user-message section. The stop-and-wait ending is nudge-only: a /thatch
|
|
629
|
+
// command IS user input, so it must not inherit it.
|
|
630
|
+
//
|
|
631
|
+
// Cores take a ToolNamer because tool spellings differ per host
|
|
632
|
+
// (thatch_find_duplicates on opencode, mcp__thatch__find_duplicates on MCP
|
|
633
|
+
// hosts); the renderer for each host passes its own namer.
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
/** Maps a bare tool name to the host's spelling. */
|
|
637
|
+
export type ToolNamer = (name: string) => string;
|
|
638
|
+
|
|
639
|
+
export const opencodeToolName: ToolNamer = (name) => `thatch_${name}`;
|
|
640
|
+
export const mcpToolName: ToolNamer = (name) => `mcp__thatch__${name}`;
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The extraction instruction body. sessionIdArg is either ` with session_id
|
|
644
|
+
* "X"` (the nudge path: the sub-agent's session differs from the parent's)
|
|
645
|
+
* or an omit note (the invoking session's ID is the default).
|
|
646
|
+
*/
|
|
647
|
+
export function extractionCore(fetchTool: string, drainTool: string, sessionIdArg: string): string {
|
|
648
|
+
return `Call ${fetchTool}${sessionIdArg} to retrieve the queued tool interactions, then run the thatch-fact-extractor skill to extract durable facts. Call ${drainTool}${sessionIdArg} when finished, even if nothing was worth saving.`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The memory consolidation (defrag) instruction body. Shared by the
|
|
653
|
+
* /thatch/defrag command file and the MCP prompt of the same name.
|
|
654
|
+
*/
|
|
655
|
+
export function defragCore(tool: ToolNamer): string {
|
|
656
|
+
return `Run ${tool("find_duplicates")} to surface duplicate-candidate memory pairs and clusters. For each pair or cluster, read every memory with ${tool("memory_show")}, classify the relationship, and consolidate per the thatch-dedup-classifier skill: merge duplicates (${tool("memory_remember")} with overwrite: true, then ${tool("memory_forget")} the rest), update supplements, keep contradictions, and call ${tool("dedup_mark_checked")} on every pair you are not deleting so it stops being re-reported. Call ${tool("find_duplicates")} again at the end to verify the store is clean, and summarize what you merged, kept, and deleted.`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The memory hygiene instruction body. Shared by the /thatch/hygiene command
|
|
661
|
+
* file and the MCP prompt of the same name. Drives the thatch CLI (not
|
|
662
|
+
* memory tools), so it needs no tool namer.
|
|
663
|
+
*/
|
|
664
|
+
export function hygieneCore(): string {
|
|
665
|
+
return `Run the shell command "thatch hygiene" to print the hygiene report for the current repo store. It lists duplicate-candidate pairs pending review, memories gone stale, and memories scoped to deleted branches. For each finding, act: consolidate duplicates with the thatch-dedup-classifier workflow, correct or overwrite stale memories that are simply out of date, and consolidate branch-scoped memories into one archived record when their branch is gone. Ask the user before forgetting anything that may still matter. Re-run "thatch hygiene" at the end to confirm the store is healthy, and summarize what you changed.`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The session reflection instruction body. Shared by the /thatch/reflect
|
|
670
|
+
* command file and the MCP prompt of the same name.
|
|
671
|
+
*/
|
|
672
|
+
export function reflectCore(tool: ToolNamer): string {
|
|
673
|
+
return `Run the thatch-session-reflection skill to persist what this session learned: follow the skill's instructions and record each distinct learning with ${tool("memory_remember")}. When the skill's checklist is worked through, report a one-line summary of what you saved.`;
|
|
674
|
+
}
|
|
675
|
+
|
|
623
676
|
/**
|
|
624
677
|
* Extraction nudge with escalation. Both the opencode plugin (src/index.ts)
|
|
625
678
|
* and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
|
|
@@ -682,7 +735,8 @@ function buildExtractionNudge(
|
|
|
682
735
|
sessionID: string,
|
|
683
736
|
): string {
|
|
684
737
|
const acknowledge = `After dispatching, call ${drainTool} to acknowledge.`;
|
|
685
|
-
const
|
|
738
|
+
const sessionIdArg = ` with session_id "${sessionID}"`;
|
|
739
|
+
const subAgentPrompt = extractionCore(fetchTool, drainTool, sessionIdArg);
|
|
686
740
|
|
|
687
741
|
if (missedCount >= 3) {
|
|
688
742
|
return `[thatch] YOU ARE IGNORING EXTRACTION INSTRUCTIONS. ` +
|