@jeffober/thatch 0.1.41 → 0.1.43

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.
@@ -0,0 +1,97 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Thatch's wrap-up slash commands for opencode, shipped as command markdown
6
+ * files synced into the global opencode config dir (~/.config/opencode/command/
7
+ * thatch/). The plugin writes them on every load so template updates self-heal
8
+ * without a reinstall. Config is loaded before plugins during server startup,
9
+ * so a first-ever install becomes visible on the next start.
10
+ */
11
+
12
+ /** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
13
+ export const COMPACT_READY_TOKEN = "THATCH_COMPACT_READY";
14
+
15
+ /** Greenlight token ending /thatch/exit's assistant response when exiting is safe. */
16
+ export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
17
+
18
+ /**
19
+ * The plugin triggers compaction by running the TUI's compact action. The
20
+ * execute-command route only accepts legacy alias names, and "session_compact"
21
+ * maps to the same session.compact action the built-in /compact command runs.
22
+ * There is no exit alias, so the exit path publishes the TUI keymap command
23
+ * ("app.exit") directly via /tui/publish.
24
+ */
25
+
26
+ const sharedChecklist = `1. Flush pending persistence. Call thatch_get_extraction_payload; if it returns buffered tool interactions, process them now (write any memories worth keeping), then call thatch_extraction_done to mark them complete. Complete any memory writes you promised earlier but have not made.
27
+ 2. Check for loose ends. Review the conversation for todos, follow-ups, or open questions raised earlier that were never addressed, and surface anything the user should address before the wrap-up completes.
28
+
29
+ Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:`;
30
+
31
+ const COMPACT_TEMPLATE = `---
32
+ description: Flush thatch persistence, check for loose ends, then compact if clear
33
+ ---
34
+ $ARGUMENTS
35
+
36
+ Pre-compaction wrap-up. Work through this checklist before responding:
37
+
38
+ ${sharedChecklist}
39
+
40
+ ${COMPACT_READY_TOKEN}
41
+
42
+ If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
43
+
44
+ const EXIT_TEMPLATE = `---
45
+ description: Flush thatch persistence, check for loose ends, then exit opencode if clear
46
+ ---
47
+ $ARGUMENTS
48
+
49
+ Pre-exit wrap-up. Work through this checklist before responding:
50
+
51
+ ${sharedChecklist}
52
+ 3. Leave the chat directory. Call thatch_chat_unregister for this session - the session is exiting, so other sessions must stop addressing mail to it.
53
+
54
+ Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:
55
+
56
+ ${EXIT_READY_TOKEN}
57
+
58
+ If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. opencode will only exit when the token is present.`;
59
+
60
+ /** One wrap-up command: the command name (minus the thatch/ prefix) and its full markdown file content. */
61
+ export interface CommandDef {
62
+ name: string;
63
+ content: string;
64
+ }
65
+
66
+ /** The wrap-up command set, as full command-file contents ready to write. */
67
+ export function opencodeCommandDefs(): CommandDef[] {
68
+ return [
69
+ { name: "compact", content: COMPACT_TEMPLATE },
70
+ { name: "exit", content: EXIT_TEMPLATE },
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * Sync the wrap-up commands into <configHome>/opencode/command/thatch/.
76
+ * Idempotent: each file is written only when its on-disk content differs.
77
+ * Returns the paths that were written (empty when everything was current).
78
+ */
79
+ export function installOpencodeCommands(configHome: string): string[] {
80
+ const dir = join(configHome, "opencode", "command", "thatch");
81
+ const written: string[] = [];
82
+ for (const def of opencodeCommandDefs()) {
83
+ const path = join(dir, `${def.name}.md`);
84
+ let current: string | null = null;
85
+ try {
86
+ current = readFileSync(path, "utf8");
87
+ } catch {
88
+ current = null;
89
+ }
90
+ if (current !== def.content) {
91
+ mkdirSync(dir, { recursive: true });
92
+ writeFileSync(path, def.content);
93
+ written.push(path);
94
+ }
95
+ }
96
+ return written;
97
+ }
package/src/config.ts CHANGED
@@ -16,15 +16,22 @@ export const notificationPrefsSchema = z.strictObject({
16
16
  sound: z.string().optional(),
17
17
  });
18
18
 
19
+ export const chatPrefsSchema = z.strictObject({
20
+ enabled: z.boolean().optional(),
21
+ autoRegister: z.boolean().optional(),
22
+ });
23
+
19
24
  export const configSchema = z.strictObject({
20
25
  notifications: notificationPrefsSchema.optional(),
26
+ chat: chatPrefsSchema.optional(),
21
27
  });
22
28
 
23
29
  export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
30
+ export type ChatPrefs = z.infer<typeof chatPrefsSchema>;
24
31
  export type Config = z.infer<typeof configSchema>;
25
32
 
26
33
  /** Sections in presentation order. config_get / config_set iterate this. */
27
- export const CONFIG_SECTIONS = ["notifications"] as const;
34
+ export const CONFIG_SECTIONS = ["notifications", "chat"] as const;
28
35
  export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
29
36
 
30
37
  /**
@@ -90,6 +97,32 @@ export function mergeNotificationPrefs(
90
97
  return { ...current, ...patch };
91
98
  }
92
99
 
100
+ /** Field-level merge for one section: omitted fields keep their values. */
101
+ export function mergeChatPrefs(current: ChatPrefs | undefined, patch: ChatPrefs): ChatPrefs {
102
+ return { ...current, ...patch };
103
+ }
104
+
105
+ /**
106
+ * Whether cross-session chat is on. Unset means on - chat is the default,
107
+ * and the toggle exists for users who want the feature entirely dark: every
108
+ * chat tool refuses, wake delivery never starts, and the system prompt
109
+ * omits the chat section.
110
+ */
111
+ export function chatEnabled(config: Config): boolean {
112
+ return config.chat?.enabled !== false;
113
+ }
114
+
115
+ /**
116
+ * Whether opencode sessions join the chat directory automatically (first
117
+ * idle event, name assigned by thatch). Unset means on - the directory is
118
+ * only useful when peers are in it, and a session that must be told to
119
+ * register never is. Turning it off restores opt-in behavior: sessions
120
+ * stay out of the directory until they call chat_register.
121
+ */
122
+ export function chatAutoRegister(config: Config): boolean {
123
+ return config.chat?.autoRegister !== false;
124
+ }
125
+
93
126
  /**
94
127
  * Platform fallbacks shown by config_get and applied by notify_user when the
95
128
  * user has not set a preference. Only darwin has opinionated defaults; other
package/src/db.ts CHANGED
@@ -4,13 +4,13 @@ import {
4
4
  PredictionEngine,
5
5
  } from "./prediction";
6
6
  import { BehaviorEngine } from "./behavior";
7
- import { ChatStore, type ChatHostKind } from "./chat";
7
+ import { ChatStore, type ChatHostKind, type ChatWorktreeKind } from "./chat";
8
8
  import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
9
9
 
10
10
  export { cosineSimilarity } from "./vector-math";
11
11
  export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
12
12
  export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
13
- export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatResult } from "./chat";
13
+ export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
14
14
 
15
15
  export interface MemoryRow {
16
16
  slug: string;
@@ -238,7 +238,9 @@ export class ThatchDB {
238
238
  project TEXT,
239
239
  host_kind TEXT NOT NULL DEFAULT ('opencode'),
240
240
  registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
241
- last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
241
+ last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
242
+ auto INTEGER NOT NULL DEFAULT 0,
243
+ worktree TEXT NOT NULL DEFAULT ('')
242
244
  )
243
245
  `);
244
246
 
@@ -262,11 +264,43 @@ export class ThatchDB {
262
264
 
263
265
  this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
264
266
 
267
+ // Per-base name counters for assigned chat names. A row only ever
268
+ // increments (never on prune), so a name is minted exactly once.
269
+ this.#db.run(`
270
+ CREATE TABLE IF NOT EXISTS chat_name_counters (
271
+ base TEXT PRIMARY KEY,
272
+ next INTEGER NOT NULL DEFAULT 1
273
+ )
274
+ `);
275
+
276
+ // Explicit leaves (chat_unregister). Suppresses auto-registration so
277
+ // the next idle event cannot silently re-register the session; cleared
278
+ // when the session explicitly rejoins via chat_register. Survives the
279
+ // directory-row delete, which is the point.
280
+ this.#db.run(`
281
+ CREATE TABLE IF NOT EXISTS chat_leave_tombstones (
282
+ session_id TEXT PRIMARY KEY,
283
+ left_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
284
+ )
285
+ `);
286
+
265
287
  this.#migrateColumns();
266
288
  this.#migrateChatNameCollation();
267
289
  this.#migrateChatTopic();
268
290
  this.#migrateChatHostKind();
269
291
  this.#migrateChatBroadcastFlag();
292
+ this.#migrateChatAuto();
293
+ this.#migrateChatWorktree();
294
+ }
295
+
296
+ // chat_sessions tables created before the auto column lack it; the ALTER
297
+ // adds it, and existing rows read as manual (which they were - auto is
298
+ // only set by the assigned-name registration path).
299
+ #migrateChatAuto(): void {
300
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
301
+ if (cols.length > 0 && !cols.includes("auto")) {
302
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN auto INTEGER NOT NULL DEFAULT 0");
303
+ }
270
304
  }
271
305
 
272
306
  // chat_sessions tables created before the host_kind column lack it; the
@@ -278,6 +312,16 @@ export class ThatchDB {
278
312
  }
279
313
  }
280
314
 
315
+ // chat_sessions tables created before the worktree column lack it; the
316
+ // ALTER adds it, and existing rows read as undetected (rendered as no
317
+ // loc token in chat_list) - they registered before the capture existed.
318
+ #migrateChatWorktree(): void {
319
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
320
+ if (cols.length > 0 && !cols.includes("worktree")) {
321
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN worktree TEXT NOT NULL DEFAULT ''");
322
+ }
323
+ }
324
+
281
325
  // chat_messages tables created before the via_broadcast column lack it;
282
326
  // the ALTER adds it, and existing rows read as direct sends (which they
283
327
  // were).
@@ -824,12 +868,28 @@ export class ThatchDB {
824
868
  // Cross-session chat: delegates to ChatStore
825
869
  // ---------------------------------------------------------------------------
826
870
 
827
- registerChatSession(sessionID: string, name: string, project: string | null, topic: string | null, kind: ChatHostKind) {
828
- return this.#chat.register(sessionID, name, project, topic, kind);
871
+ registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null, worktree: ChatWorktreeKind = null) {
872
+ return this.#chat.register(sessionID, project, topic, kind, nameBase, worktree);
873
+ }
874
+
875
+ refreshChatTopic(sessionID: string, title: string) {
876
+ return this.#chat.refreshAutoTopic(sessionID, title);
877
+ }
878
+
879
+ pruneStaleChatAuto(cutoff: string) {
880
+ return this.#chat.pruneStaleAuto(cutoff);
881
+ }
882
+
883
+ hasChatLeaveTombstone(sessionID: string) {
884
+ return this.#chat.hasLeaveTombstone(sessionID);
885
+ }
886
+
887
+ clearChatLeaveTombstone(sessionID: string) {
888
+ return this.#chat.clearLeaveTombstone(sessionID);
829
889
  }
830
890
 
831
- assignChatName(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind) {
832
- return this.#chat.assign(sessionID, project, topic, kind);
891
+ chatLeaveSuperseded(sessionID: string, project: string | null) {
892
+ return this.#chat.leaveSuperseded(sessionID, project);
833
893
  }
834
894
 
835
895
  broadcastChatMessage(fromSession: string, body: string) {
package/src/git.ts CHANGED
@@ -1,4 +1,26 @@
1
1
  import { $ } from "bun";
2
+ import { statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Classifies the checkout the given directory serves: "worktree" for a
7
+ * linked git worktree (its .git is a FILE pointing at the main repo's
8
+ * gitdir), "root" for the project's main checkout (.git is a DIRECTORY),
9
+ * and null when undetectable (no .git at all, or no directory given).
10
+ * Synchronous and cheap: callers capture this once at chat registration,
11
+ * so the roster can show which sessions share the main tree.
12
+ */
13
+ export function detectWorktreeKind(dir?: string | null): "root" | "worktree" | null {
14
+ if (!dir) return null;
15
+ try {
16
+ const st = statSync(join(dir, ".git"));
17
+ if (st.isFile()) return "worktree";
18
+ if (st.isDirectory()) return "root";
19
+ return null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
2
24
 
3
25
  /**
4
26
  * Extracts an `owner/repo` slug from a git remote URL. Handles the common
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import type { Plugin } from "@opencode-ai/plugin";
3
3
  import { ThatchDB } from "./db";
4
4
  import { BgeEmbeddingModel } from "./embeddings";
5
- import { detectRepo } from "./git";
5
+ import { detectRepo, detectWorktreeKind } from "./git";
6
6
  import { createTools } from "./tools";
7
7
  import {
8
8
  systemPrompt,
@@ -18,12 +18,14 @@ import {
18
18
  } from "./prompts";
19
19
  import { ExtractionPipeline, type ToolInteraction } from "./extraction";
20
20
  import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
21
+ import { installOpencodeCommands, COMPACT_READY_TOKEN, EXIT_READY_TOKEN } from "./commands";
21
22
  import { hygieneReport } from "./hygiene";
22
23
  import { seedDefaultBehaviors } from "./seed-behaviors";
23
24
  import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
24
25
  import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
25
26
  import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
26
- import { ChatPoller } from "./chat";
27
+ import { ChatPoller, isDefaultSessionTitle, slugifyTitle } from "./chat";
28
+ import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
27
29
  import pkg from "../package.json";
28
30
 
29
31
  // ---------------------------------------------------------------------------
@@ -51,6 +53,15 @@ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD
51
53
  // situation genuinely matches.
52
54
  const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
53
55
 
56
+ // Wrap-up command registry: command name as opencode derives it from the
57
+ // installed file path (~/.config/opencode/command/thatch/<name>.md), the
58
+ // greenlight token the command instructs the model to end its response with,
59
+ // and which TUI action the greenlight triggers.
60
+ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit" }> = {
61
+ "thatch/compact": { token: COMPACT_READY_TOKEN, kind: "compact" },
62
+ "thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
63
+ };
64
+
54
65
  export const server: Plugin = async ({ client, worktree }) => {
55
66
  // The opencode server's cwd is wherever the server happened to start;
56
67
  // `worktree` is the project this plugin instance actually serves.
@@ -60,6 +71,12 @@ export const server: Plugin = async ({ client, worktree }) => {
60
71
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
61
72
  const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
62
73
 
74
+ // Whether cross-session chat is on (chat.enabled, default on). Read once
75
+ // at init: it gates the chat poller and the prompt's chat section. The
76
+ // chat tools re-read the file per call, so a toggle takes effect there
77
+ // without a restart.
78
+ const chatOn = chatEnabled(loadConfig(dbPath).config);
79
+
63
80
  const db = new ThatchDB(dbPath);
64
81
  const model = new BgeEmbeddingModel(modelName);
65
82
  const extraction = new ExtractionPipeline();
@@ -95,23 +112,49 @@ export const server: Plugin = async ({ client, worktree }) => {
95
112
  // process may deliver chat mail to - the map's keys).
96
113
  const sessionStatus = new Map<string, string>();
97
114
 
115
+ // The proactive-prompt gate, shared by both delivery registries (watchers
116
+ // and chat). Two layers, in cost order: the compacting set and the
117
+ // event-fed sessionStatus map answer cheaply, and a final check verifies
118
+ // against the server's live status - the map is only as fresh as the
119
+ // event stream, and a stale "idle" turns a wake into mid-turn context
120
+ // injection (observed live: a session read mail mid-burst, contradicted
121
+ // 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 {
126
+ 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).
134
+ console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
135
+ return false;
136
+ }
137
+ };
138
+
98
139
  // In-memory watcher registry for proactive event notifications (GitHub PR
99
- // watching today). Deliberately process-scoped: no SQLite, no cross-restart
100
- // state. See src/watchers.ts for the rationale.
140
+ // and branch watching, plus local command watches). Deliberately
141
+ // process-scoped: no SQLite, no cross-restart state. See src/watchers.ts
142
+ // for the rationale.
101
143
  //
102
144
  // Delivery prompts the session with a synthetic part - the same mechanism
103
145
  // opencode uses for background task completions - so a watched event
104
- // triggers a model turn even when the user is away. Events only carry
105
- // pointer data; the model fetches details itself with gh.
146
+ // triggers a model turn even when the user is away. Events carry pointer
147
+ // data plus machine status (check conclusions, exit codes); the model
148
+ // fetches logs and further details itself with gh.
106
149
  const watchers = new WatcherRegistry({
107
150
  deliver: async (sessionID, events) => {
108
151
  // Events carry their watch's target label from the registry, so the
109
152
  // notification header is correct for every source (PRs, branches,
110
- // CI events whose URLs would not parse).
153
+ // commands, CI events whose URLs would not parse).
111
154
  await client.session.promptAsync({
112
155
  path: { id: sessionID },
113
156
  body: {
114
- parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
157
+ parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true }],
115
158
  },
116
159
  });
117
160
  // Toast: the notification part is TUI-hidden, so without this the
@@ -133,25 +176,24 @@ export const server: Plugin = async ({ client, worktree }) => {
133
176
  // TUI may not be connected. Best-effort.
134
177
  }
135
178
  },
136
- canDeliver: (sessionID) =>
137
- !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
179
+ canDeliver: canPromptSession,
138
180
  ghRunner: ghApiRun,
139
181
  });
140
- // gh presence decides whether watch_create works; checked lazily by the
141
- // tool, but log once at startup so misconfiguration is visible in debug logs.
182
+ // gh presence decides whether watch_create and watch_branch_create work;
183
+ // checked lazily by the tools, but log once at startup so misconfiguration
184
+ // is visible in debug logs. watch_command_create needs only bash, not gh.
142
185
  void ghAvailable().then((ok) => {
143
- if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
186
+ if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
144
187
  });
145
188
  watchers.start();
146
189
 
147
- // Cross-session chat delivery. The inbox is shared SQLite state (any
148
- // process reads/writes it), but this poller only ever delivers to sessions
149
- // hosted by THIS process - the recipient's host is the single deliverer,
150
- // so there is no cross-process double-delivery race. Wake prompts use the
151
- // same synthetic-part mechanism as watcher notifications; the toast
152
- // covers the TUI-hidden part so the user sees why the model woke up.
153
- // canDeliver closes over `compacting` (declared below): the closure reads
154
- // it at call time, after full initialization, same as the watcher gate.
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).
155
197
  const chatPoller = new ChatPoller({
156
198
  store: db,
157
199
  hostedSessions: () => {
@@ -183,10 +225,11 @@ export const server: Plugin = async ({ client, worktree }) => {
183
225
  // TUI may not be connected. Best-effort.
184
226
  }
185
227
  },
186
- canDeliver: (sessionID) =>
187
- !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
228
+ canDeliver: canPromptSession,
188
229
  });
189
- chatPoller.start();
230
+ // With chat disabled the tools refuse, so the poller would only heartbeat
231
+ // sessions and burn wake budget against a feature the user turned off.
232
+ if (chatOn) chatPoller.start();
190
233
 
191
234
  // Sessions currently being compacted. chat.message nudges are skipped while
192
235
  // a session is in this set - the agent can't call tools during summary
@@ -247,6 +290,15 @@ export const server: Plugin = async ({ client, worktree }) => {
247
290
  // Keyed by child session ID. Cleaned up on child idle, error, or deletion.
248
291
  const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
249
292
 
293
+ // Wrap-up slash commands (/thatch/compact, /thatch/exit) awaiting their
294
+ // greenlight check. command.execute.before marks the session when one of
295
+ // these commands runs; when the session next goes idle, the plugin reads
296
+ // the final assistant message for the ready token (the greenlight) and
297
+ // triggers the TUI action. Token absence means the model listed blockers
298
+ // instead, so nothing fires beyond a toast. Cleared on resolution or on
299
+ // session deletion.
300
+ const pendingWrapUp = new Map<string, { token: string; kind: "compact" | "exit" }>();
301
+
250
302
  // Skills always install to the global opencode config - installing into the
251
303
  // worktree would mutate the user's repo (untracked files in git status).
252
304
  // A failed install degrades the nudge workflow but must not kill the plugin.
@@ -259,7 +311,16 @@ export const server: Plugin = async ({ client, worktree }) => {
259
311
  console.error(`[thatch] skill install failed: ${err}`);
260
312
  }
261
313
 
262
- const sys = systemPrompt(repo);
314
+ // Same for the wrap-up slash commands: synced into the global config dir on
315
+ // every load so template updates self-heal. Config is loaded before plugins
316
+ // during server startup, so a first-ever install lands on the next start.
317
+ try {
318
+ installOpencodeCommands(configHome);
319
+ } catch (err) {
320
+ console.error(`[thatch] command install failed: ${err}`);
321
+ }
322
+
323
+ const sys = systemPrompt(repo, chatOn);
263
324
  const compact = compactionContext(repo);
264
325
 
265
326
  // Direct extraction: create a child session linked to the parent and prompt
@@ -348,6 +409,7 @@ export const server: Plugin = async ({ client, worktree }) => {
348
409
  return extraction.buildPayload(all, repo);
349
410
  },
350
411
  watcherRegistry: watchers,
412
+ projectDir: worktree,
351
413
  }),
352
414
 
353
415
  // 1. System prompt - always in context.
@@ -515,6 +577,14 @@ export const server: Plugin = async ({ client, worktree }) => {
515
577
  // Skipped during compaction: the agent can't call tools while generating
516
578
  // a summary, so a nudge that says "use thatch_memory_recall" triggers a
517
579
  // blocked-tool error.
580
+ // Wrap-up commands mark their session here; the actual trigger happens
581
+ // on the session's next idle event, once the model's greenlight response
582
+ // is complete.
583
+ "command.execute.before": async (input) => {
584
+ const wrapUp = WRAPUP_COMMANDS[input.command];
585
+ if (wrapUp) pendingWrapUp.set(input.sessionID, wrapUp);
586
+ },
587
+
518
588
  "chat.message": async (input, output) => {
519
589
  // Chat transcript echoes are non-synthetic noReply parts, so they
520
590
  // arrive here like any user message - but no model turn ever reads
@@ -820,6 +890,101 @@ export const server: Plugin = async ({ client, worktree }) => {
820
890
  }
821
891
  return;
822
892
  }
893
+ // Wrap-up command resolution: the session went idle right after
894
+ // /thatch/compact or /thatch/exit. The final assistant message's
895
+ // trailing token is the greenlight - the command instructs the model
896
+ // to end with it only after flushing persistence and confirming no
897
+ // loose ends. Token absence means the model listed blockers, so the
898
+ // action never fires; a toast points the user at the blockers above.
899
+ const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
900
+ if (wrapUp) {
901
+ pendingWrapUp.delete(sessionID);
902
+ let ready = false;
903
+ try {
904
+ const { data } = await client.session.messages({ path: { id: sessionID } });
905
+ const last = [...(data ?? [])].reverse().find((m) => m.info.role === "assistant");
906
+ const text = (last?.parts ?? [])
907
+ .filter((p) => p.type === "text")
908
+ .map((p) => p.text ?? "")
909
+ .join("\n")
910
+ .trimEnd();
911
+ ready = text.endsWith(wrapUp.token);
912
+ } catch (err) {
913
+ console.error(`[thatch] wrap-up message fetch failed: ${err}`);
914
+ }
915
+ if (ready) {
916
+ // An exit-greenlit session is leaving the process: leave the
917
+ // chat directory too, so other sessions stop addressing mail to
918
+ // a roster entry whose host is about to vanish. The unregister
919
+ // tombstone also stops any straggler auto-register from
920
+ // resurrecting the row during shutdown. Compact keeps the
921
+ // session alive, so only the exit path does this.
922
+ if (wrapUp.kind === "exit") {
923
+ try {
924
+ db.unregisterChatSession(sessionID);
925
+ } catch (err) {
926
+ console.error(`[thatch] chat unregister on exit failed: ${err}`);
927
+ }
928
+ }
929
+ try {
930
+ if (wrapUp.kind === "compact") {
931
+ // executeCommand only accepts legacy alias names;
932
+ // "session_compact" maps to the TUI's session.compact action,
933
+ // the same thing the built-in /compact command runs.
934
+ await client.tui.executeCommand({ body: { command: "session_compact" } });
935
+ } else {
936
+ // No exit alias exists, so publish the TUI keymap command
937
+ // directly - the same dispatch as the /exit slash command.
938
+ await client.tui.publish({
939
+ body: { type: "tui.command.execute", properties: { command: "app.exit" } },
940
+ });
941
+ }
942
+ } catch (err) {
943
+ console.error(`[thatch] wrap-up action failed: ${err}`);
944
+ }
945
+ // Skip extraction and pending deliveries: compaction is starting
946
+ // (the checklist drained the buffer) or the process is exiting.
947
+ return;
948
+ }
949
+ try {
950
+ await client.tui.showToast({
951
+ body: {
952
+ message:
953
+ wrapUp.kind === "compact"
954
+ ? "\u23F8 Not compacting - resolve the items listed above, then run /thatch/compact again"
955
+ : "\u23F8 Not exiting - resolve the items listed above, then run /thatch/exit again",
956
+ variant: "warning",
957
+ duration: 8000,
958
+ },
959
+ });
960
+ } catch {
961
+ // TUI may not be connected (e.g. headless mode).
962
+ }
963
+ // Blocked: fall through so extraction and pending deliveries still
964
+ // run - the model may have buffered tool interactions to flush.
965
+ }
966
+ // Auto-registration: every top-level opencode session joins the
967
+ // chat directory on its first idle event (unless the user turned
968
+ // 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.
975
+ if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
976
+ void (async () => {
977
+ try {
978
+ const { data } = await client.session.get({ path: { id: sessionID } });
979
+ 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);
983
+ } catch (err) {
984
+ console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
985
+ }
986
+ })();
987
+ }
823
988
  // Parent went idle - trigger direct extraction if there are pending
824
989
  // tool interactions and no extraction is already running. Falls
825
990
  // back to the nudge path on the next chat.message if this throws.
@@ -861,6 +1026,13 @@ export const server: Plugin = async ({ client, worktree }) => {
861
1026
  // error, and a leaked status key would keep heartbeat-ing a dead
862
1027
  // session as permanently fresh.
863
1028
  sessionStatus.delete(id);
1029
+ // Tombstone the auto-registerer: an idle IIFE already in flight
1030
+ // (title fetch pending) would otherwise re-insert a directory row
1031
+ // for a session the user just deleted - a week-long roster zombie
1032
+ // whose host is gone. The unregister below tombstones (see
1033
+ // ChatStore.unregister), and the tombstone is cleared only by an
1034
+ // explicit chat_register rejoin, so a genuinely new session is
1035
+ // unaffected.
864
1036
  // A child deleted before completing never processed its payload.
865
1037
  const parentID = childToParent.get(id);
866
1038
  if (parentID) {
@@ -871,6 +1043,7 @@ export const server: Plugin = async ({ client, worktree }) => {
871
1043
  parentSnapshots.delete(id);
872
1044
  childMetrics.delete(id);
873
1045
  extractionChildren.delete(id);
1046
+ pendingWrapUp.delete(id);
874
1047
  // A deleted parent takes its accepted entries with it, and its
875
1048
  // watchers die with it - the session that would receive their
876
1049
  // notifications no longer exists.