@jeffober/thatch 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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, isDefaultSessionTitle, slugifyTitle } from "./chat";
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 = async (sessionID: string): Promise<boolean> => {
123
- if (compacting.has(sessionID)) return false;
124
- if (sessionStatus.get(sessionID) !== "idle") return false;
125
- try {
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
- // The server's status map only carries ACTIVE sessions - it deletes
128
- // the entry when a session goes idle (session/status.ts), so absent
129
- // means idle. Fail closed on busy/retry, pass on idle-or-absent.
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
- return false;
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 proactive-prompt gate, shared by both delivery registries
191
- // (watchers and chat). Two layers, in cost order: the compacting set and
192
- // the event-fed sessionStatus map answer cheaply, and a final check
193
- // verifies against the server's live status - the map is only as fresh
194
- // as the event stream, and a stale "idle" turns a wake into mid-turn
195
- // context injection (observed live: a session read mail mid-burst,
196
- // contradicted its own in-flight plan, and looped).
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
- // Sub-agent children share this process's sessionStatus (their
201
- // status events land here), but they are never legitimate chat
202
- // participants - a registered child would be heartbeat-ed
203
- // fresh-forever and burn nudge budget on undeliverable wake
204
- // prompts. Keep them out of the hosted set even when guidance
205
- // against registering them is ignored.
206
- const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
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
- // slug of the session title plus a never-reused counter - and the
970
- // live title rides the topic column, refreshed on every idle so the
971
- // roster tracks what the session is actually working on. Titles are
972
- // usually placeholders at first idle and real within a turn or two,
973
- // so both name and topic converge fast. Fire-and-forget: a failed
974
- // registration or title fetch must never block event delivery.
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
- const base = title && !isDefaultSessionTitle(title) ? slugifyTitle(title) : null;
981
- db.registerChatSession(sessionID, repo, base ? title : null, "opencode", base, detectWorktreeKind(worktree));
982
- if (base) db.refreshChatTopic(sessionID, title);
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. Thatch is a tools-only
47
- * server - no resources, prompts, or subscriptions.
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 slug of your
24
- session title plus a counter, e.g. fix-auth-bug-00001) and is never
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 subAgentPrompt = `Call ${fetchTool} with session_id "${sessionID}" to retrieve the queued tool interactions, then run the thatch-fact-extractor skill to extract durable facts. Call ${drainTool} with session_id "${sessionID}" when finished, even if nothing was worth saving.`;
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. ` +