@jeffober/thatch 0.1.40 → 0.1.42

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/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,11 +4,13 @@ import {
4
4
  PredictionEngine,
5
5
  } from "./prediction";
6
6
  import { BehaviorEngine } from "./behavior";
7
+ import { ChatStore, type ChatHostKind } from "./chat";
7
8
  import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
8
9
 
9
10
  export { cosineSimilarity } from "./vector-math";
10
11
  export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
11
12
  export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
13
+ export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow } from "./chat";
12
14
 
13
15
  export interface MemoryRow {
14
16
  slug: string;
@@ -53,6 +55,7 @@ export class ThatchDB {
53
55
  #db: Database;
54
56
  #predictions: PredictionEngine;
55
57
  #behaviors: BehaviorEngine;
58
+ #chat: ChatStore;
56
59
 
57
60
  constructor(path: string) {
58
61
  this.#db = new Database(path, { create: true });
@@ -66,6 +69,7 @@ export class ThatchDB {
66
69
  this.#db.run("PRAGMA foreign_keys = ON");
67
70
  this.#predictions = new PredictionEngine(this.#db);
68
71
  this.#behaviors = new BehaviorEngine(this.#db);
72
+ this.#chat = new ChatStore(this.#db);
69
73
  this.#initSchema();
70
74
  }
71
75
 
@@ -218,9 +222,112 @@ export class ThatchDB {
218
222
  )
219
223
  `);
220
224
 
225
+ // Cross-session chat: the machine-wide session directory and message
226
+ // inbox, shared by every opencode process (delivery itself stays local
227
+ // to each host process - see src/chat.ts). Display-name uniqueness is
228
+ // case-insensitive ("Landru" and "landru" are one name), so two visually
229
+ // identical identities cannot coexist. Message endpoints are plain
230
+ // columns, not foreign keys, on purpose: unregistering a session must
231
+ // not be blocked by message history, and a departed sender degrades to
232
+ // an unknown name in the reader's view rather than vanishing rows.
233
+ this.#db.run(`
234
+ CREATE TABLE IF NOT EXISTS chat_sessions (
235
+ session_id TEXT PRIMARY KEY,
236
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
237
+ topic TEXT,
238
+ project TEXT,
239
+ host_kind TEXT NOT NULL DEFAULT ('opencode'),
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')),
242
+ auto INTEGER NOT NULL DEFAULT 0
243
+ )
244
+ `);
245
+
246
+ // delivered_at is the last wake-prompt stamp: set when the recipient's
247
+ // host accepts a nudge, restamped on re-nudges (which resets the
248
+ // re-nudge timer). read_at is set when the recipient drains its inbox.
249
+ // via_broadcast marks rows a chat_broadcast fan-out created, so the
250
+ // tail feed can render one broadcast line instead of N direct sends.
251
+ this.#db.run(`
252
+ CREATE TABLE IF NOT EXISTS chat_messages (
253
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
254
+ from_session TEXT NOT NULL,
255
+ to_session TEXT NOT NULL,
256
+ body TEXT NOT NULL,
257
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
258
+ delivered_at TEXT,
259
+ read_at TEXT,
260
+ via_broadcast INTEGER NOT NULL DEFAULT 0
261
+ )
262
+ `);
263
+
221
264
  this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
222
265
 
266
+ // Per-base name counters for assigned chat names. A row only ever
267
+ // increments (never on prune), so a name is minted exactly once.
268
+ this.#db.run(`
269
+ CREATE TABLE IF NOT EXISTS chat_name_counters (
270
+ base TEXT PRIMARY KEY,
271
+ next INTEGER NOT NULL DEFAULT 1
272
+ )
273
+ `);
274
+
275
+ // Explicit leaves (chat_unregister). Suppresses auto-registration so
276
+ // the next idle event cannot silently re-register the session; cleared
277
+ // when the session explicitly rejoins via chat_register. Survives the
278
+ // directory-row delete, which is the point.
279
+ this.#db.run(`
280
+ CREATE TABLE IF NOT EXISTS chat_leave_tombstones (
281
+ session_id TEXT PRIMARY KEY,
282
+ left_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
283
+ )
284
+ `);
285
+
223
286
  this.#migrateColumns();
287
+ this.#migrateChatNameCollation();
288
+ this.#migrateChatTopic();
289
+ this.#migrateChatHostKind();
290
+ this.#migrateChatBroadcastFlag();
291
+ this.#migrateChatAuto();
292
+ }
293
+
294
+ // chat_sessions tables created before the auto column lack it; the ALTER
295
+ // adds it, and existing rows read as manual (which they were - auto is
296
+ // only set by the assigned-name registration path).
297
+ #migrateChatAuto(): void {
298
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
299
+ if (cols.length > 0 && !cols.includes("auto")) {
300
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN auto INTEGER NOT NULL DEFAULT 0");
301
+ }
302
+ }
303
+
304
+ // chat_sessions tables created before the host_kind column lack it; the
305
+ // ALTER adds it, and existing rows read as opencode (which they were).
306
+ #migrateChatHostKind(): void {
307
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
308
+ if (cols.length > 0 && !cols.includes("host_kind")) {
309
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN host_kind TEXT NOT NULL DEFAULT 'opencode'");
310
+ }
311
+ }
312
+
313
+ // chat_messages tables created before the via_broadcast column lack it;
314
+ // the ALTER adds it, and existing rows read as direct sends (which they
315
+ // were).
316
+ #migrateChatBroadcastFlag(): void {
317
+ const cols = (this.#db.query("PRAGMA table_info(chat_messages)").all() as any[]).map((r) => r.name);
318
+ if (cols.length > 0 && !cols.includes("via_broadcast")) {
319
+ this.#db.run("ALTER TABLE chat_messages ADD COLUMN via_broadcast INTEGER NOT NULL DEFAULT 0");
320
+ }
321
+ }
322
+
323
+ // chat_sessions tables created before the topic column lack it; the
324
+ // ALTER adds it, and rows degrade to a NULL topic (rendered as no topic
325
+ // in chat_list).
326
+ #migrateChatTopic(): void {
327
+ const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
328
+ if (cols.length > 0 && !cols.includes("topic")) {
329
+ this.#db.run("ALTER TABLE chat_sessions ADD COLUMN topic TEXT");
330
+ }
224
331
  }
225
332
 
226
333
  // Databases created before recall telemetry lack these columns; the CREATE
@@ -241,6 +348,43 @@ export class ThatchDB {
241
348
  }
242
349
  }
243
350
 
351
+ // A chat_sessions table created before the NOCASE uniqueness change has a
352
+ // case-SENSITIVE unique constraint, which let "Landru" and "landru"
353
+ // coexist as distinct identities. SQLite cannot ALTER a column
354
+ // constraint, so the repair is a table rebuild. Detection reads the
355
+ // stored CREATE statement from sqlite_master: the NOCASE schema text
356
+ // contains COLLATE NOCASE, the older one does not. Colliding rows
357
+ // collapse via INSERT OR IGNORE (one row wins per case group; scan order
358
+ // decides which). chat_messages has no foreign key into this table, so
359
+ // message history survives untouched.
360
+ //
361
+ // ORDERING TRAP: the rebuild's CREATE and SELECT are pinned to the
362
+ // columns the OLDEST chat_sessions schema had, because a legacy table
363
+ // only has those columns - a SELECT naming a newer column would throw.
364
+ // This migration must run BEFORE any ALTER-based chat_sessions migration
365
+ // (which add newer columns afterward); when adding a column to
366
+ // chat_sessions, extend the ALTER-based migrations, not this SELECT.
367
+ #migrateChatNameCollation(): void {
368
+ const row = this.#db
369
+ .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'chat_sessions'")
370
+ .get() as any;
371
+ if (!row?.sql || /COLLATE\s+NOCASE/i.test(row.sql)) return;
372
+ this.transaction(() => {
373
+ this.#db.run(`
374
+ CREATE TABLE chat_sessions_migrated (
375
+ session_id TEXT PRIMARY KEY,
376
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
377
+ project TEXT,
378
+ registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
379
+ last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
380
+ )
381
+ `);
382
+ this.#db.run("INSERT OR IGNORE INTO chat_sessions_migrated SELECT session_id, name, project, registered_at, last_seen FROM chat_sessions");
383
+ this.#db.run("DROP TABLE chat_sessions");
384
+ this.#db.run("ALTER TABLE chat_sessions_migrated RENAME TO chat_sessions");
385
+ });
386
+ }
387
+
244
388
  // ---------------------------------------------------------------------------
245
389
  // Stores
246
390
  // ---------------------------------------------------------------------------
@@ -708,6 +852,89 @@ export class ThatchDB {
708
852
  return this.#behaviors.listBehaviors(store);
709
853
  }
710
854
 
855
+ // ---------------------------------------------------------------------------
856
+ // Cross-session chat: delegates to ChatStore
857
+ // ---------------------------------------------------------------------------
858
+
859
+ registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null) {
860
+ return this.#chat.register(sessionID, project, topic, kind, nameBase);
861
+ }
862
+
863
+ refreshChatTopic(sessionID: string, title: string) {
864
+ return this.#chat.refreshAutoTopic(sessionID, title);
865
+ }
866
+
867
+ pruneStaleChatAuto(cutoff: string) {
868
+ return this.#chat.pruneStaleAuto(cutoff);
869
+ }
870
+
871
+ hasChatLeaveTombstone(sessionID: string) {
872
+ return this.#chat.hasLeaveTombstone(sessionID);
873
+ }
874
+
875
+ clearChatLeaveTombstone(sessionID: string) {
876
+ return this.#chat.clearLeaveTombstone(sessionID);
877
+ }
878
+
879
+ chatLeaveSuperseded(sessionID: string, project: string | null) {
880
+ return this.#chat.leaveSuperseded(sessionID, project);
881
+ }
882
+
883
+ broadcastChatMessage(fromSession: string, body: string) {
884
+ return this.#chat.broadcast(fromSession, body);
885
+ }
886
+
887
+ chatMessageStatus(sessionID: string) {
888
+ return this.#chat.status(sessionID);
889
+ }
890
+
891
+ chatPendingByProject(project: string) {
892
+ return this.#chat.pendingByProject(project);
893
+ }
894
+
895
+ chatMessageFeed() {
896
+ return this.#chat.messageFeed();
897
+ }
898
+
899
+ unregisterChatSession(sessionID: string) {
900
+ return this.#chat.unregister(sessionID);
901
+ }
902
+
903
+ listChatSessions() {
904
+ return this.#chat.list();
905
+ }
906
+
907
+ findChatSession(nameOrID: string) {
908
+ // Facade surface: production resolves recipients inside send()/find();
909
+ // this delegation exists so callers (and tests) can resolve a session
910
+ // by name or ID without reaching past the ThatchDB API.
911
+ return this.#chat.find(nameOrID);
912
+ }
913
+
914
+ sendChatMessage(fromSession: string, toNameOrID: string, body: string) {
915
+ return this.#chat.send(fromSession, toNameOrID, body);
916
+ }
917
+
918
+ readChatMessages(sessionID: string) {
919
+ return this.#chat.read(sessionID);
920
+ }
921
+
922
+ unreadChatCount(sessionID: string) {
923
+ return this.#chat.unreadCount(sessionID);
924
+ }
925
+
926
+ heartbeatChatSessions(sessionIDs: string[]) {
927
+ return this.#chat.heartbeat(sessionIDs);
928
+ }
929
+
930
+ pendingChatNotifications(sessionIDs: string[], renudgeCutoff: string) {
931
+ return this.#chat.pendingNotifications(sessionIDs, renudgeCutoff);
932
+ }
933
+
934
+ markChatDelivered(ids: number[]) {
935
+ return this.#chat.markDelivered(ids);
936
+ }
937
+
711
938
  // ---------------------------------------------------------------------------
712
939
  // Hygiene - signals for the session-start heartbeat. Staleness means
713
940
  // neither written nor recalled since the cutoff; recall telemetry keeps
package/src/index.ts CHANGED
@@ -22,7 +22,9 @@ import { hygieneReport } from "./hygiene";
22
22
  import { seedDefaultBehaviors } from "./seed-behaviors";
23
23
  import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
24
24
  import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
25
- import { watcherNotificationNudge } from "./prompts";
25
+ import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
26
+ import { ChatPoller, isDefaultSessionTitle, slugifyTitle } from "./chat";
27
+ import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
26
28
  import pkg from "../package.json";
27
29
 
28
30
  // ---------------------------------------------------------------------------
@@ -59,6 +61,12 @@ export const server: Plugin = async ({ client, worktree }) => {
59
61
  const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
60
62
  const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
61
63
 
64
+ // Whether cross-session chat is on (chat.enabled, default on). Read once
65
+ // at init: it gates the chat poller and the prompt's chat section. The
66
+ // chat tools re-read the file per call, so a toggle takes effect there
67
+ // without a restart.
68
+ const chatOn = chatEnabled(loadConfig(dbPath).config);
69
+
62
70
  const db = new ThatchDB(dbPath);
63
71
  const model = new BgeEmbeddingModel(modelName);
64
72
  const extraction = new ExtractionPipeline();
@@ -86,28 +94,57 @@ export const server: Plugin = async ({ client, worktree }) => {
86
94
  const runningVersion = pkg.version;
87
95
 
88
96
  // Latest observed opencode session status per session ("busy" | "idle" |
89
- // "retry"). The event hook records these; the watcher registry's
90
- // canDeliver gate reads them so proactive notification prompts only fire
91
- // into idle sessions - never into a turn that is already running.
97
+ // "retry"). Keys enter from session.status events and leave only in
98
+ // session.deleted (which shrinks the chat poller's hosted set). Readers:
99
+ // the watcher registry's and the chat poller's canDeliver gates
100
+ // (proactive prompts only fire into idle sessions, never into a running
101
+ // turn), and the chat poller's hostedSessions set (the sessions this
102
+ // process may deliver chat mail to - the map's keys).
92
103
  const sessionStatus = new Map<string, string>();
93
104
 
105
+ // The proactive-prompt gate, shared by both delivery registries (watchers
106
+ // and chat). Two layers, in cost order: the compacting set and the
107
+ // event-fed sessionStatus map answer cheaply, and a final check verifies
108
+ // against the server's live status - the map is only as fresh as the
109
+ // event stream, and a stale "idle" turns a wake into mid-turn context
110
+ // injection (observed live: a session read mail mid-burst, contradicted
111
+ // its own in-flight plan, and looped).
112
+ const canPromptSession = async (sessionID: string): Promise<boolean> => {
113
+ if (compacting.has(sessionID)) return false;
114
+ if (sessionStatus.get(sessionID) !== "idle") return false;
115
+ try {
116
+ const { data } = await client.session.status();
117
+ // The server's status map only carries ACTIVE sessions - it deletes
118
+ // the entry when a session goes idle (session/status.ts), so absent
119
+ // means idle. Fail closed on busy/retry, pass on idle-or-absent.
120
+ const live = data?.[sessionID];
121
+ return !live || live.type === "idle";
122
+ } catch (err) {
123
+ // Unreachable server: do not deliver (the mail stays pending).
124
+ console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
125
+ return false;
126
+ }
127
+ };
128
+
94
129
  // In-memory watcher registry for proactive event notifications (GitHub PR
95
- // watching today). Deliberately process-scoped: no SQLite, no cross-restart
96
- // state. See src/watchers.ts for the rationale.
130
+ // and branch watching, plus local command watches). Deliberately
131
+ // process-scoped: no SQLite, no cross-restart state. See src/watchers.ts
132
+ // for the rationale.
97
133
  //
98
134
  // Delivery prompts the session with a synthetic part - the same mechanism
99
135
  // opencode uses for background task completions - so a watched event
100
- // triggers a model turn even when the user is away. Events only carry
101
- // pointer data; the model fetches details itself with gh.
136
+ // triggers a model turn even when the user is away. Events carry pointer
137
+ // data plus machine status (check conclusions, exit codes); the model
138
+ // fetches logs and further details itself with gh.
102
139
  const watchers = new WatcherRegistry({
103
140
  deliver: async (sessionID, events) => {
104
141
  // Events carry their watch's target label from the registry, so the
105
142
  // notification header is correct for every source (PRs, branches,
106
- // CI events whose URLs would not parse).
143
+ // commands, CI events whose URLs would not parse).
107
144
  await client.session.promptAsync({
108
145
  path: { id: sessionID },
109
146
  body: {
110
- parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
147
+ parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true }],
111
148
  },
112
149
  });
113
150
  // Toast: the notification part is TUI-hidden, so without this the
@@ -129,17 +166,61 @@ export const server: Plugin = async ({ client, worktree }) => {
129
166
  // TUI may not be connected. Best-effort.
130
167
  }
131
168
  },
132
- canDeliver: (sessionID) =>
133
- !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
169
+ canDeliver: canPromptSession,
134
170
  ghRunner: ghApiRun,
135
171
  });
136
- // gh presence decides whether watch_create works; checked lazily by the
137
- // tool, but log once at startup so misconfiguration is visible in debug logs.
172
+ // gh presence decides whether watch_create and watch_branch_create work;
173
+ // checked lazily by the tools, but log once at startup so misconfiguration
174
+ // is visible in debug logs. watch_command_create needs only bash, not gh.
138
175
  void ghAvailable().then((ok) => {
139
- if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
176
+ if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
140
177
  });
141
178
  watchers.start();
142
179
 
180
+ // The proactive-prompt gate, shared by both delivery registries
181
+ // (watchers and chat). Two layers, in cost order: the compacting set and
182
+ // the event-fed sessionStatus map answer cheaply, and a final check
183
+ // verifies against the server's live status - the map is only as fresh
184
+ // as the event stream, and a stale "idle" turns a wake into mid-turn
185
+ // context injection (observed live: a session read mail mid-burst,
186
+ // contradicted its own in-flight plan, and looped).
187
+ const chatPoller = new ChatPoller({
188
+ store: db,
189
+ hostedSessions: () => {
190
+ // Sub-agent children share this process's sessionStatus (their
191
+ // status events land here), but they are never legitimate chat
192
+ // participants - a registered child would be heartbeat-ed
193
+ // fresh-forever and burn nudge budget on undeliverable wake
194
+ // prompts. Keep them out of the hosted set even when guidance
195
+ // against registering them is ignored.
196
+ const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
197
+ return hosted;
198
+ },
199
+ deliver: async (sessionID, senders, count) => {
200
+ await client.session.promptAsync({
201
+ path: { id: sessionID },
202
+ body: {
203
+ parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
204
+ },
205
+ });
206
+ try {
207
+ await client.tui.showToast({
208
+ body: {
209
+ message: `\u{1F4AC} chat: ${count} unread from ${senders.join(", ")}`,
210
+ variant: "info",
211
+ duration: 5000,
212
+ },
213
+ });
214
+ } catch {
215
+ // TUI may not be connected. Best-effort.
216
+ }
217
+ },
218
+ canDeliver: canPromptSession,
219
+ });
220
+ // With chat disabled the tools refuse, so the poller would only heartbeat
221
+ // sessions and burn wake budget against a feature the user turned off.
222
+ if (chatOn) chatPoller.start();
223
+
143
224
  // Sessions currently being compacted. chat.message nudges are skipped while
144
225
  // a session is in this set - the agent can't call tools during summary
145
226
  // generation, so a recall or extraction nudge would cause a blocked-tool
@@ -211,7 +292,7 @@ export const server: Plugin = async ({ client, worktree }) => {
211
292
  console.error(`[thatch] skill install failed: ${err}`);
212
293
  }
213
294
 
214
- const sys = systemPrompt(repo);
295
+ const sys = systemPrompt(repo, chatOn);
215
296
  const compact = compactionContext(repo);
216
297
 
217
298
  // Direct extraction: create a child session linked to the parent and prompt
@@ -300,6 +381,7 @@ export const server: Plugin = async ({ client, worktree }) => {
300
381
  return extraction.buildPayload(all, repo);
301
382
  },
302
383
  watcherRegistry: watchers,
384
+ projectDir: worktree,
303
385
  }),
304
386
 
305
387
  // 1. System prompt - always in context.
@@ -407,6 +489,40 @@ export const server: Plugin = async ({ client, worktree }) => {
407
489
  childMetrics.set(input.sessionID, metrics);
408
490
  return;
409
491
  }
492
+ // Chat conversational events echo back into the transcript as visible
493
+ // message parts. Plugin tools render as muted one-line entries with
494
+ // the output hidden behind a default-off TUI toggle, so without this
495
+ // the human watching a session never sees the conversation happen.
496
+ // Delivery is a non-synthetic noReply promptAsync: the part renders
497
+ // as a visible bubble and starts no model turn (the server's prompt
498
+ // path returns before the completion loop). Non-synthetic also means
499
+ // later turns see the echo in context - a small duplication of the
500
+ // tool call it mirrors, accepted for visibility. Fire-and-forget: an
501
+ // echo failure must never fail the tool call it follows.
502
+ if (input.tool.startsWith("thatch_chat_")) {
503
+ const echo = chatEchoText(
504
+ input.tool,
505
+ (input.args ?? {}) as Record<string, unknown>,
506
+ typeof output.output === "string" ? output.output : "",
507
+ );
508
+ if (echo) {
509
+ void client.session
510
+ .promptAsync({
511
+ path: { id: input.sessionID },
512
+ body: {
513
+ noReply: true,
514
+ parts: [{ type: "text", text: echo }],
515
+ },
516
+ })
517
+ .catch((err) => {
518
+ // The echo must never fail the tool call, but a silent catch
519
+ // would hide a systemic promptAsync failure (bubbles would
520
+ // stop appearing everywhere with no diagnostics).
521
+ console.error(`[thatch] chat echo delivery failed: ${err}`);
522
+ });
523
+ }
524
+ return;
525
+ }
410
526
  if (input.tool.startsWith("thatch_") || input.tool === "skill" || input.tool === "task") return;
411
527
  extraction.push({
412
528
  tool: input.tool,
@@ -434,6 +550,12 @@ export const server: Plugin = async ({ client, worktree }) => {
434
550
  // a summary, so a nudge that says "use thatch_memory_recall" triggers a
435
551
  // blocked-tool error.
436
552
  "chat.message": async (input, output) => {
553
+ // Chat transcript echoes are non-synthetic noReply parts, so they
554
+ // arrive here like any user message - but no model turn ever reads
555
+ // them, and the nudge machinery would embed and scan their text for
556
+ // nothing (and fire spurious toasts when thresholds cross). Skip
557
+ // them entirely.
558
+ if (isChatEchoParts(output.parts)) return;
437
559
  if (compacting.has(input.sessionID)) {
438
560
  // The session is marked as compacting. If this message is the
439
561
  // compaction summary generation itself (has a compaction-type part),
@@ -732,6 +854,28 @@ export const server: Plugin = async ({ client, worktree }) => {
732
854
  }
733
855
  return;
734
856
  }
857
+ // Auto-registration: every top-level opencode session joins the
858
+ // chat directory on its first idle event (unless the user turned
859
+ // auto-registration off). The name is assigned, never chosen - a
860
+ // slug of the session title plus a never-reused counter - and the
861
+ // live title rides the topic column, refreshed on every idle so the
862
+ // roster tracks what the session is actually working on. Titles are
863
+ // usually placeholders at first idle and real within a turn or two,
864
+ // so both name and topic converge fast. Fire-and-forget: a failed
865
+ // registration or title fetch must never block event delivery.
866
+ if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
867
+ void (async () => {
868
+ try {
869
+ const { data } = await client.session.get({ path: { id: sessionID } });
870
+ const title = data?.title ?? "";
871
+ const base = title && !isDefaultSessionTitle(title) ? slugifyTitle(title) : null;
872
+ db.registerChatSession(sessionID, repo, base ? title : null, "opencode", base);
873
+ if (base) db.refreshChatTopic(sessionID, title);
874
+ } catch (err) {
875
+ console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
876
+ }
877
+ })();
878
+ }
735
879
  // Parent went idle - trigger direct extraction if there are pending
736
880
  // tool interactions and no extraction is already running. Falls
737
881
  // back to the nudge path on the next chat.message if this throws.
@@ -757,10 +901,29 @@ export const server: Plugin = async ({ client, worktree }) => {
757
901
  } catch (err) {
758
902
  console.error(`[thatch] watcher delivery on idle failed: ${err}`);
759
903
  }
904
+ // Same for chat messages: mail that arrived mid-turn lands now
905
+ // instead of waiting for the next poll cycle.
906
+ try {
907
+ await chatPoller.deliverPending();
908
+ } catch (err) {
909
+ console.error(`[thatch] chat delivery on idle failed: ${err}`);
910
+ }
760
911
  return;
761
912
  }
762
913
  if (event.type === "session.deleted") {
763
914
  const id = event.properties.info.id;
915
+ // Shrink the chat poller's hosted set FIRST: the rest of this
916
+ // branch runs extraction calls that could throw on a transient DB
917
+ // error, and a leaked status key would keep heartbeat-ing a dead
918
+ // session as permanently fresh.
919
+ sessionStatus.delete(id);
920
+ // Tombstone the auto-registerer: an idle IIFE already in flight
921
+ // (title fetch pending) would otherwise re-insert a directory row
922
+ // for a session the user just deleted - a week-long roster zombie
923
+ // whose host is gone. The unregister below tombstones (see
924
+ // ChatStore.unregister), and the tombstone is cleared only by an
925
+ // explicit chat_register rejoin, so a genuinely new session is
926
+ // unaffected.
764
927
  // A child deleted before completing never processed its payload.
765
928
  const parentID = childToParent.get(id);
766
929
  if (parentID) {
@@ -776,8 +939,15 @@ export const server: Plugin = async ({ client, worktree }) => {
776
939
  // notifications no longer exists.
777
940
  extraction.completeAccepted(id);
778
941
  extracting.delete(id);
779
- sessionStatus.delete(id);
780
942
  watchers.cancelSession(id);
943
+ // Leaving the chat directory is the graceful-exit fast path; a
944
+ // crashed process never fires session.deleted, so the heartbeat
945
+ // staleness in chat_list is the covering signal for that case.
946
+ try {
947
+ db.unregisterChatSession(id);
948
+ } catch (err) {
949
+ console.error(`[thatch] chat unregister on delete failed: ${err}`);
950
+ }
781
951
  return;
782
952
  }
783
953
 
@@ -816,6 +986,7 @@ export const server: Plugin = async ({ client, worktree }) => {
816
986
  dispose: async () => {
817
987
  stopVersionChecker();
818
988
  watchers.dispose();
989
+ chatPoller.dispose();
819
990
  // Release native ONNX sessions while the worker is still healthy. Left
820
991
  // to Bun's teardown, their NAPI finalizers panic the process (see
821
992
  // BgeEmbeddingModel.dispose).