@jeffober/thatch 0.1.44 → 0.1.46

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
@@ -70,9 +70,7 @@ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit"
70
70
  * the caller supplies the list (see startupSessionId for where it comes
71
71
  * from). The framework passes no session id to plugins and resuming a
72
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. `-c/--continue`
74
- * (continue last) resolves no id here - that session registers on its
75
- * first idle like any other.
73
+ * fresh harness is the new home of a continued session.
76
74
  */
77
75
  export function startupSessionIdFromArgv(argv: string[]): string | null {
78
76
  for (let i = 0; i < argv.length; i++) {
@@ -83,6 +81,39 @@ export function startupSessionIdFromArgv(argv: string[]): string | null {
83
81
  return null;
84
82
  }
85
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
+
86
117
  // The plugin does not run in the process's main thread: opencode's TUI
87
118
  // spawns a worker thread (cli/tui/worker.js) to host the server, and worker
88
119
  // threads get their own argv (just the worker script) with none of the CLI
@@ -254,16 +285,29 @@ export const server: Plugin = async ({ client, worktree }) => {
254
285
  });
255
286
  watchers.start();
256
287
 
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
+
257
301
  const chatPoller = new ChatPoller({
258
302
  store: db,
259
303
  hostedSessions: () => {
260
304
  // Hosting = the sessions this harness serves: those seen as events
261
- // here (used in this TUI) plus the `-s` startup session. Sub-agent
305
+ // here (used in this TUI) plus the resumed startup session. Sub-agent
262
306
  // children are excluded even if someone registered one - a
263
307
  // registered child would be heartbeat-ed fresh-forever and burn
264
308
  // nudge budget on undeliverable wake prompts.
265
309
  const hosted = [...sessionStatus.keys()];
266
- if (startupSession) hosted.push(startupSession);
310
+ if (resumedSession) hosted.push(resumedSession);
267
311
  return hosted.filter((id) => !childToParent.has(id));
268
312
  },
269
313
  deliver: async (sessionID, senders, count) => {
@@ -293,42 +337,68 @@ export const server: Plugin = async ({ client, worktree }) => {
293
337
 
294
338
  // Startup registration: `opencode -s <id>` continues a session whose
295
339
  // directory row may predate this process (its harness died, or the
296
- // machine restarted). The framework passes no session id to plugins and
340
+ // machine restarted); `opencode -c` continues the most recent session in
341
+ // this directory. The framework passes no session id to plugins and
297
342
  // resuming fires no events, so the command line is the source (see
298
343
  // startupSessionId). Registering here RECLAIMS the row - the name is
299
344
  // owned by the session id and never changes - and refreshes its
300
- // heartbeat synchronously: local SQLite, it cannot fail on a server that
301
- // is still starting. The async tail (title fetch, asleep-mail delivery)
302
- // is best-effort - the poller retries whatever it misses, and the first
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
303
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
+ };
304
370
  const osArgs = osProcessArgs();
305
371
  const startupSession = startupSessionId(osArgs);
372
+ const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
306
373
  const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
307
374
  const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
308
- debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
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"}`);
309
376
  if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
377
+ resumedSession = startupSession;
310
378
  const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
311
379
  debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
312
380
  if (res.ok) {
313
- void (async () => {
314
- try {
315
- const { data } = await client.session.get({ path: { id: startupSession } });
316
- const title = data?.title ?? "";
317
- const topic = title && !isDefaultSessionTitle(title) ? title : null;
318
- if (topic) db.refreshChatTopic(startupSession, topic);
319
- } catch {
320
- // Server may still be starting; the topic converges on the
321
- // session's first idle either way.
322
- }
323
- try {
324
- // Asleep-mail: deliver what queued while this session was away,
325
- // through the normal path (gate, nudge, toast, stamps).
326
- await chatPoller.deliverPending();
327
- } catch {
328
- // The poller cycle retries delivery; nothing to do here.
329
- }
330
- })();
381
+ chatStartupNames.set(startupSession, res.name);
382
+ reclaimTail(startupSession);
331
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
+ })();
332
402
  }
333
403
 
334
404
  // Sessions currently being compacted. chat.message nudges are skipped while
@@ -692,6 +762,42 @@ export const server: Plugin = async ({ client, worktree }) => {
692
762
  // nothing (and fire spurious toasts when thresholds cross). Skip
693
763
  // them entirely.
694
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
+
695
801
  if (compacting.has(input.sessionID)) {
696
802
  // The session is marked as compacting. If this message is the
697
803
  // compaction summary generation itself (has a compaction-type part),
@@ -1085,11 +1191,14 @@ export const server: Plugin = async ({ client, worktree }) => {
1085
1191
  // registration is once-per-session, so later idles must
1086
1192
  // refresh it explicitly.
1087
1193
  if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
1088
- if (res.ok && res.created) {
1089
- // First registration is the one moment the user should
1090
- // notice: a quiet toast, not a conversation message.
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);
1091
1200
  try {
1092
- await client.tui.showToast({ body: { message: `registered in chat as ${res.name}`, variant: "info", duration: 4000 } });
1201
+ await client.tui.showToast({ body: { message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 } });
1093
1202
  } catch {
1094
1203
  // Headless or disconnected TUI - registration stands.
1095
1204
  }
package/src/mcp.ts CHANGED
@@ -6,6 +6,8 @@ import { BgeEmbeddingModel } from "./embeddings";
6
6
  import { detectRepo } from "./git";
7
7
  import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
8
8
  import { TOOL_DEFS, type CoreContext, type ToolDef } from "./tool-defs";
9
+ import { actionDefs, type ActionDef } from "./commands";
10
+ import { mcpToolName } from "./prompts";
9
11
  import { SidebandServer, sidebandSocketPath } from "./sideband";
10
12
  import { peekQueue, consumeQueue, resetMissedCount } from "./extract-queue";
11
13
  import { buildExtractionPayload } from "./extraction";
@@ -44,11 +46,13 @@ const SERVER_NAME = "thatch";
44
46
  const SERVER_VERSION = pkg.version;
45
47
 
46
48
  /**
47
- * MCP capabilities declared in the initialize response. Thatch is a tools-only
48
- * 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.
49
52
  */
50
53
  const CAPABILITIES = {
51
54
  tools: { listChanged: false },
55
+ prompts: { listChanged: false },
52
56
  };
53
57
 
54
58
  // ---------------------------------------------------------------------------
@@ -77,6 +81,25 @@ export function compileTools(): Map<string, CompiledTool> {
77
81
  return map;
78
82
  }
79
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
+
80
103
  // ---------------------------------------------------------------------------
81
104
  // MCP server
82
105
  // ---------------------------------------------------------------------------
@@ -152,6 +175,7 @@ export async function runMcpServer(): Promise<void> {
152
175
  },
153
176
  };
154
177
  const tools = compileTools();
178
+ const prompts = compilePrompts();
155
179
 
156
180
  // Check whether `thatch setup` was run for the current host. If not, or if
157
181
  // markers are broken, surface a warning on the first tools/call response so
@@ -248,7 +272,7 @@ export async function runMcpServer(): Promise<void> {
248
272
 
249
273
  // Notifications (no id) are fire-and-forget - no response expected.
250
274
  const isNotification = req.id === null || req.id === undefined;
251
- const res = await dispatch(req, tools, ctx);
275
+ const res = await dispatch(req, tools, prompts, ctx);
252
276
  if (res === null || isNotification) continue;
253
277
 
254
278
  // Surface the setup warning on the first tools/call response so the
@@ -290,6 +314,7 @@ export async function runMcpServer(): Promise<void> {
290
314
  async function dispatch(
291
315
  req: JsonRpcRequest,
292
316
  tools: Map<string, CompiledTool>,
317
+ prompts: Map<string, ActionDef>,
293
318
  ctx: CoreContext,
294
319
  ): Promise<JsonRpcResponse | null> {
295
320
  const { id, method } = req;
@@ -355,6 +380,40 @@ async function dispatch(
355
380
  }
356
381
  }
357
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
+
358
417
  case "ping":
359
418
  return ok(id, {});
360
419
 
package/src/prompts.ts CHANGED
@@ -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. ` +
package/src/setup.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
2
2
  import { join } from "node:path";
3
3
  import { claudeInstructions, cursorInstructions } from "./prompts";
4
4
  import { installSkills, type InstallReport } from "./skills";
5
+ import { installClaudeCommands } from "./commands";
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // Path resolution
@@ -320,6 +321,8 @@ export interface SetupResult {
320
321
  settings: string;
321
322
  /** Skill install report for the directory this run wrote to. */
322
323
  skills: InstallReport;
324
+ /** Command files (the /thatch/* actions) written by this run, if any. */
325
+ commands: string[];
323
326
  /** Thatch skills in the opposite scope's dir, if any; never written by this run. */
324
327
  otherScopeSkills: OtherScopeSkills | null;
325
328
  global: boolean;
@@ -360,6 +363,11 @@ export function setupClaudeCode(
360
363
  writeHooks(paths.settingsPath, thatchBin);
361
364
  const skills = installSkills(paths.skillsDir);
362
365
 
366
+ // Command files (the /thatch/* actions Claude Code reads as slash
367
+ // commands) live beside skills under the .claude dir, same scope rules.
368
+ const claudeDir = global ? claudeConfigDir(home) : join(projectDir, ".claude");
369
+ const commands = installClaudeCommands(claudeDir);
370
+
363
371
  // The scope this run did NOT write to. Local runs leave user-scope copies
364
372
  // behind (pre-existing global installs); global runs leave project-local
365
373
  // copies in the repo. Either way the user should hear about them.
@@ -372,6 +380,7 @@ export function setupClaudeCode(
372
380
  claudeMd: paths.claudeMdPath,
373
381
  settings: paths.settingsPath,
374
382
  skills,
383
+ commands,
375
384
  otherScopeSkills: otherScopeInfo(otherDir),
376
385
  global,
377
386
  mcpAddCommand: global
package/src/tool-defs.ts CHANGED
@@ -20,7 +20,7 @@ import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } fr
20
20
  import { predictionVerb, chatInboxFrame } from "./prompts";
21
21
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
22
22
  import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
23
- import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, splitChatRoster, type ChatHostKind } from "./chat";
23
+ import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
24
24
  import { detectWorktreeKind } from "./git";
25
25
 
26
26
  // Near-duplicate thresholds for matcher/prediction/behavior dedup at
@@ -452,18 +452,29 @@ const getExtractionPayloadDef: ToolDef = {
452
452
  "Retrieve the queued tool interactions for extraction. Call this with " +
453
453
  "the session_id from the extraction nudge to get the JSON payload " +
454
454
  "(interactions, projectStore, globalStore). Then run the " +
455
- "thatch-fact-extractor skill on the returned payload.",
455
+ "thatch-fact-extractor skill on the returned payload. Omit session_id " +
456
+ "when acting on the session you are running in.",
456
457
  args: {
457
- session_id: z.string().describe(
458
- "The session ID from the extraction nudge. This is the parent session " +
459
- "whose tool interactions are queued for extraction.",
458
+ session_id: z.string().optional().describe(
459
+ "The session whose tool interactions are queued for extraction. " +
460
+ "Omit when acting on the session you are running in - the invoking " +
461
+ "session's ID is used. Pass the parent session's ID when running " +
462
+ "inside a sub-agent dispatched to process another session's queue.",
460
463
  ),
461
464
  },
462
- async execute(args, ctx) {
465
+ async execute(args, ctx, host) {
463
466
  if (!ctx.extractionPayloadProvider) {
464
467
  return "Extraction payload retrieval is not available in this host.";
465
468
  }
466
- const payload = ctx.extractionPayloadProvider(args.session_id as string);
469
+ // The zod shape is shared across hosts, so optionality cannot be
470
+ // per-host at the schema level: MCP hosts have no session context to
471
+ // fall back on, so an omitted session_id only resolves on the
472
+ // opencode path, where the wrapper supplies the invoking session.
473
+ const sessionID = (args.session_id as string | undefined) ?? host?.sessionID;
474
+ if (!sessionID) {
475
+ return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
476
+ }
477
+ const payload = ctx.extractionPayloadProvider(sessionID);
467
478
  if (!payload) {
468
479
  return "No queued tool interactions found for this session.";
469
480
  }
@@ -1722,7 +1733,7 @@ const chatListDef: ToolDef = {
1722
1733
  return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
1723
1734
  }
1724
1735
  const unread = ctx.db.unreadChatCount(identity.sessionID);
1725
- const { active, stale } = splitChatRoster(sessions);
1736
+ const { active, stale } = splitChatRoster(sortChatRoster(sessions));
1726
1737
  const line = (s: (typeof sessions)[number]) => {
1727
1738
  const self = s.session_id === identity.sessionID;
1728
1739
  const liveness = chatLiveness(s);
@@ -1,89 +0,0 @@
1
- # Nomenclater
2
-
3
- ## Synopsis
4
-
5
- Assigns a geeky/sci-fi name to the agent at the start of each new opencode
6
- session. The name is generated by a fast LLM call, cached for the session, and
7
- injected into the system prompt so the agent knows its name. opencode-only;
8
- requires plugin hooks. Pure cosmetic, no functional dependency.
9
-
10
- ## Background
11
-
12
- Fnord's Nomenclater (backed by a GenServer name pool) generates whimsical names
13
- for sub-agents so their actions are distinguishable in logs. Jeff explicitly
14
- wants this for thatch: "I'd love to have this!" It builds agent identity and
15
- rapport, complementing the personality instructions in OPENCODE.md.
16
-
17
- The name must not change mid-conversation and must not trigger on continued
18
- or resumed sessions.
19
-
20
- ## Session detection
21
-
22
- Same mechanism as the intuition-drives plan. See that plan for full detail.
23
-
24
- `session.created` fires only on brand-new session creation (fresh or fork). It
25
- does not fire when resuming an existing conversation. Detection: track
26
- `session.created` for top-level sessions (no `parentID`) in a `Set<string>`,
27
- generate the name on first `chat.message` for sessions in that set, remove
28
- from set after generating. Continued/resumed sessions never enter the set.
29
-
30
- ## What it does
31
-
32
- ```
33
- session.created (top-level) → add sessionID to newSessions Set
34
-
35
- chat.message (first message for sessionID in newSessions)
36
- → remove from newSessions
37
- → generate name via fast model → cache in Map<sessionID, name>
38
-
39
- experimental.chat.system.transform
40
- → if name cached for sessionID: append "Your name for this session is <name>."
41
-
42
- session.deleted
43
- → delete from name cache, newSessions set
44
- ```
45
-
46
- Name generation uses fnord's theme list (Jargon File, Zork, Futurama,
47
- Discworld, D&D, Klingons, Sandman, etc.) and the same JSON-schema response
48
- format. One LLM call with a fast model. Themes are shuffled and 3 are selected
49
- per batch to avoid clustering.
50
-
51
- ## Constraints
52
-
53
- - **opencode-only.** Requires plugin hook injection. The name is generated by a
54
- fast LLM call and injected via `experimental.chat.system.transform`.
55
- - **First-message-only.** Same session-detection mechanism. The name must not
56
- change mid-conversation.
57
- - **No persistent state needed.** The name is generated fresh per session and
58
- held in a per-session map for the duration of that session. No DB writes.
59
- - **Must not trigger on continued sessions.** A resumed conversation keeps
60
- whatever name the agent had (or had none, if the feature was added after the
61
- session started). The `session.created` gate handles this.
62
-
63
- ## Open question
64
-
65
- 1. **Where does the name surface?** System prompt injection means the agent
66
- knows its name but the user only sees it if the agent chooses to use it. Is
67
- that enough, or should the name appear in the TUI somehow (e.g., a toast on
68
- session start)?
69
-
70
- ## Decisions
71
-
72
- | Decision | Rationale |
73
- |----------|-----------|
74
- | opencode-only | Requires plugin hooks for generation and injection |
75
- | One name per session, cached in memory | No DB writes; name is ephemeral, tied to session lifecycle |
76
- | Gated to first message of new sessions | Must not rename mid-conversation or trigger on resumed sessions |
77
- | Detection via session.created + chat.message | Same mechanism as the intuition-drives plan |
78
-
79
- ## Dependencies
80
-
81
- ### Runtime
82
-
83
- - Fast model for name generation (one LLM call per new session)
84
-
85
- ### Built-in
86
-
87
- - Plugin hooks: `event` (session.created, session.deleted),
88
- `chat.message`, `experimental.chat.system.transform`
89
- - In-process `Map` for per-session name caching