@jeffober/thatch 0.1.48 → 0.2.1

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.
Files changed (50) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-fact-extractor.md +1 -1
  3. package/artifacts/skills/thatch-memory-verify.md +2 -2
  4. package/bin/thatch +31 -16
  5. package/docs/dev/README.md +39 -13
  6. package/docs/dev/features/behavior-engine.md +3 -2
  7. package/docs/dev/features/cicd.md +1 -1
  8. package/docs/dev/features/compaction-recovery.md +1 -1
  9. package/docs/dev/features/cross-session-chat.md +12 -3
  10. package/docs/dev/features/extraction.md +10 -2
  11. package/docs/dev/features/hygiene.md +3 -1
  12. package/docs/dev/features/memory-store.md +1 -1
  13. package/docs/dev/features/multi-host.md +4 -2
  14. package/docs/dev/features/notifications.md +121 -4
  15. package/docs/dev/features/nudge-pipeline.md +16 -2
  16. package/docs/dev/features/opencode-plugin.md +133 -0
  17. package/docs/dev/features/qa-system.md +43 -0
  18. package/docs/dev/features/repo-identity.md +1 -1
  19. package/docs/dev/features/session-lifecycle.md +6 -3
  20. package/docs/dev/features/watchers.md +12 -5
  21. package/docs/dev/gotchas.md +94 -4
  22. package/docs/dev/mcp-parity.md +4 -3
  23. package/docs/dev/setup-and-hooks.md +7 -2
  24. package/docs/plans/llm-alerts.md +287 -0
  25. package/docs/plans/prediction-consolidation.md +3 -3
  26. package/docs/user/README.md +8 -4
  27. package/docs/user/memory.md +1 -1
  28. package/docs/user/notifications.md +63 -4
  29. package/docs/user/setup.md +4 -0
  30. package/docs/user/watchers.md +15 -7
  31. package/package.json +3 -1
  32. package/src/alerts.ts +360 -0
  33. package/src/capabilities.ts +174 -0
  34. package/src/chat.ts +80 -5
  35. package/src/commands.ts +44 -5
  36. package/src/config.ts +32 -1
  37. package/src/db.ts +157 -12
  38. package/src/embeddings.ts +43 -0
  39. package/src/extraction.ts +210 -0
  40. package/src/index.ts +77 -1339
  41. package/src/opencode/v1.ts +54 -0
  42. package/src/opencode/v2.ts +605 -0
  43. package/src/os-args.ts +99 -0
  44. package/src/prompts.ts +181 -31
  45. package/src/runtime.ts +2004 -0
  46. package/src/seed-behaviors.ts +1 -1
  47. package/src/session-db.ts +32 -0
  48. package/src/tool-defs.ts +340 -46
  49. package/src/tools.ts +16 -35
  50. package/src/watchers.ts +109 -14
package/src/alerts.ts ADDED
@@ -0,0 +1,360 @@
1
+ // LLM alerts: desktop notification when the LLM pauses for human input or
2
+ // finishes a round of real work. SDK-free by the isolation rule in
3
+ // src/index.ts - the runtime (src/runtime.ts) feeds it events, and delivery
4
+ // goes through the same per-platform dispatcher as notify_user (src/notify.ts),
5
+ // so one code path serves both opencode lines.
6
+ //
7
+ // The brain is deliberately a plain in-memory state machine owned by the
8
+ // plugin runtime. The runtime is a single instance per project directory in
9
+ // the shared server no matter how many TUIs are attached, so per-session
10
+ // deduplication is exact by construction - delivering from the TUI side
11
+ // instead would fire once per attached TUI. State is rebuilt empty on
12
+ // plugin setup: a v2 reload mid-turn loses that turn's transition and the
13
+ // alert stays silent (accepted; see docs/plans/llm-alerts.md).
14
+ //
15
+ // What makes a round "real work" is split across two sources, on purpose:
16
+ // - the TOOL side comes from live bookkeeping: the runtime marks real work
17
+ // exactly where the extraction buffer pushes (the buffer's non-bufferable
18
+ // filter already knows the meta tools and unwraps execute-wrapped thatch
19
+ // calls), so the execute-unwrap lesson lives in one place;
20
+ // - the TRIGGER side (was the turn started by a real user prompt, or by a
21
+ // synthetic delivery?) and the round's error come from the message list,
22
+ // via deriveRoundShape. Messages cannot answer the tool side (tool args -
23
+ // the execute code - are not in message parts), and live bookkeeping
24
+ // cannot answer the trigger side. Neither source alone is sufficient.
25
+
26
+ import { alertMode, type AlertEventKind, type Config } from "./config";
27
+
28
+ /**
29
+ * What the message list says about the round that just ended. Derived by
30
+ * deriveRoundShape from the capabilities layer's message view; null means
31
+ * the shape is unknown (empty/failed fetch) and the alert stays silent.
32
+ */
33
+ export interface RoundShape {
34
+ /**
35
+ * The turn was triggered by synthetic input only - a thatch nudge, a
36
+ * background-task completion, a watcher wake-up. Such rounds are
37
+ * bookkeeping, not work the user asked for, and never notify - even when
38
+ * they end up doing real tool work (e.g. a watcher wake that merges a
39
+ * branch: the watcher already announced itself).
40
+ */
41
+ syntheticTrigger: boolean;
42
+ /**
43
+ * The round ended in an error (message-level: abort, stall, provider
44
+ * failure). Routes to the error alert instead of done. Null when the
45
+ * round ended cleanly.
46
+ */
47
+ roundError?: string | null;
48
+ }
49
+
50
+ /** One outbound alert; the injected notify implementation delivers it. */
51
+ export interface AlertInput {
52
+ kind: AlertEventKind;
53
+ title: string;
54
+ message: string;
55
+ }
56
+
57
+ /** Structural message view - the capabilities layer's sessionMessages shape. */
58
+ export interface RoundMessage {
59
+ info: { role: string; error?: string };
60
+ parts?: { type: string; text?: string; tool?: string; synthetic?: boolean }[];
61
+ }
62
+
63
+ export interface AlertsDeps {
64
+ /** Current user config (the alerts section decides the channel). */
65
+ config(): Config;
66
+ /** Derive the finished round's shape from the message list. */
67
+ roundShape(sessionID: string): Promise<RoundShape | null>;
68
+ /** Session title for the source label; undefined when unknown. */
69
+ sessionTitle(sessionID: string): Promise<string | undefined>;
70
+ /**
71
+ * Delivery. The production impl wraps sendNotification; tests inject a
72
+ * synchronous spy. Fire-and-forget from the machine's side - a voice
73
+ * delivery awaits `say` for seconds and must never delay the event pump.
74
+ */
75
+ notify(input: AlertInput): Promise<void>;
76
+ }
77
+
78
+ interface SessionAlertState {
79
+ /** Seen busy since the last verdict. */
80
+ active: boolean;
81
+ /** Real tool work observed this busy period (the extraction-push signal). */
82
+ realWork: boolean;
83
+ /** Error recorded by session.error / execution failure, by error name. */
84
+ lastError: string | null;
85
+ /** Pause requests already notified, awaiting their reply event. */
86
+ pendingAsks: Set<string>;
87
+ }
88
+
89
+ /**
90
+ * Error names that never alert: the user aborted the turn themselves
91
+ * (MessageAbortedError, and v2's user-reason interrupt translation), the
92
+ * process is shutting down and there is nobody left to notify
93
+ * (SessionShutdownError), or the execution was superseded by a newer one
94
+ * (SessionInterruptedError catch-all - a new busy period is taking over).
95
+ */
96
+ function isSilentError(error: string | null | undefined): boolean {
97
+ return !!error && /abort|shutdown|interrupt/i.test(error);
98
+ }
99
+
100
+ /**
101
+ * Which tool calls count as real work for the ALERT classifier, on top of
102
+ * the extraction buffer's push (which already excludes isMetaToolName and
103
+ * execute-wrapped thatch calls). todowrite and question are intentionally
104
+ * NOT in isMetaToolName - the extraction buffer must buffer them (they
105
+ * carry extractable facts) - but a round made of only them did nothing the
106
+ * user is waiting for: a todo write has no outcome to announce, and a
107
+ * question is announced by its own pause alert. Do not move them into the
108
+ * shared helper; that would silently stop the buffer from queueing them.
109
+ */
110
+ export function isNoWorkTool(tool: string): boolean {
111
+ const t = tool.toLowerCase();
112
+ return t === "todowrite" || t === "question";
113
+ }
114
+
115
+ /**
116
+ * Derive the round shape from the message list (oldest first, the
117
+ * capabilities layer's sessionMessages order).
118
+ *
119
+ * Round delimiter: on v2 the host appends an `{type: "idle"}` marker
120
+ * message at every turn end (SessionMessage.Idle, projected before
121
+ * subscribers are notified), so the round is everything after the previous
122
+ * idle marker - the host's own turn definition, immune to mid-round
123
+ * steers. V1 has no markers: fall back to "the newest non-assistant message
124
+ * triggered the round" (a mid-round steer under-classifies the round there;
125
+ * accepted v1 limitation).
126
+ */
127
+ export function deriveRoundShape(messages: RoundMessage[] | null | undefined): RoundShape | null {
128
+ if (!messages?.length) return null;
129
+ // The CURRENT turn's idle marker is the last message on v2 (projected
130
+ // before subscribers see the terminal event) - it terminates the round,
131
+ // so trim trailing markers first, then the round is everything after the
132
+ // PREVIOUS marker.
133
+ let end = messages.length;
134
+ while (end > 0 && messages[end - 1].info.role === "idle") end--;
135
+ let start = -1;
136
+ for (let i = end - 1; i >= 0; i--) {
137
+ if (messages[i].info.role === "idle") {
138
+ start = i + 1;
139
+ break;
140
+ }
141
+ }
142
+ if (start < 0) {
143
+ // No markers (v1): the newest non-assistant message triggered the round.
144
+ for (let i = end - 1; i >= 0; i--) {
145
+ if (messages[i].info.role !== "assistant") {
146
+ start = i;
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ if (start < 0) return null; // only assistant messages - no trigger, no round
152
+ const round = messages.slice(start, end);
153
+ const trigger = round.find((m) => m.info.role !== "assistant" && m.info.role !== "idle");
154
+ const lastAssistant = [...round].reverse().find((m) => m.info.role === "assistant");
155
+ const textParts = (trigger?.parts ?? []).filter((p) => p.type === "text");
156
+ const syntheticTrigger =
157
+ trigger?.info.role === "synthetic" ||
158
+ (trigger?.info.role === "user" && textParts.length > 0 && textParts.every((p) => p.synthetic === true));
159
+ return { syntheticTrigger: !!syntheticTrigger, roundError: lastAssistant?.info.error ?? null };
160
+ }
161
+
162
+ /**
163
+ * Whether the finished round did real work and deserves a done alert.
164
+ * Unknown shape stays silent (alerts are best-effort; the same fetch
165
+ * failure also breaks the wrap-up greenlight check, so the session
166
+ * degrades consistently). A round triggered by synthetic input stays
167
+ * silent even when it did real work - see the RoundShape.syntheticTrigger
168
+ * doc for why.
169
+ */
170
+ export function roundDidRealWork(shape: RoundShape | null, realWork: boolean): boolean {
171
+ if (!shape || shape.syntheticTrigger || shape.roundError) return false;
172
+ return realWork;
173
+ }
174
+
175
+ /**
176
+ * The reply-event bookkeeping: all reply events on both lines carry
177
+ * requestID (verified against v1.18.9's schema - despite the v1 SDK's
178
+ * stale types showing a legacy permissionID spelling, the v1 host's active
179
+ * permission publisher uses the v1 schema's requestID).
180
+ */
181
+ export function replyRequestID(properties: Record<string, unknown> | undefined): string | undefined {
182
+ if (!properties) return undefined;
183
+ const id = properties.requestID;
184
+ return typeof id === "string" ? id : undefined;
185
+ }
186
+
187
+ export function createAlerts(deps: AlertsDeps) {
188
+ const sessions = new Map<string, SessionAlertState>();
189
+
190
+ function stateFor(sessionID: string): SessionAlertState {
191
+ let state = sessions.get(sessionID);
192
+ if (!state) {
193
+ state = { active: false, realWork: false, lastError: null, pendingAsks: new Set() };
194
+ sessions.set(sessionID, state);
195
+ }
196
+ return state;
197
+ }
198
+
199
+ function titleFor(sessionID: string): Promise<string> {
200
+ return deps
201
+ .sessionTitle(sessionID)
202
+ .catch(() => undefined)
203
+ .then((t) => t ?? "opencode");
204
+ }
205
+
206
+ function deliver(input: AlertInput): void {
207
+ // One INFO line per actual delivery - a notification feature must be
208
+ // able to answer "did the alert fire?" from the server log, and the
209
+ // verdict log below covers the suppressed cases.
210
+ console.info(`[thatch] alert: ${input.kind} -> "${input.message}" (${input.title})`);
211
+ // Fire-and-forget: a voice delivery awaits the sentence for seconds,
212
+ // and the caller (the event pump) must not stall other sessions'
213
+ // events behind it. Delivery failures are logged, never surfaced.
214
+ deps.notify(input).catch((err) => console.error(`[thatch] alert delivery failed: ${err}`));
215
+ }
216
+
217
+ /** One ask of any kind. Deduped per request id; duplicates stay silent. */
218
+ async function asked(sessionID: string, requestID: string | undefined, message: string): Promise<void> {
219
+ try {
220
+ const state = stateFor(sessionID);
221
+ // No id (never observed in the wild): dedup per session+kind. Two
222
+ // concurrent id-less asks of the same kind dedup to one alert -
223
+ // over-clearing on reply is the accepted cost.
224
+ const key = requestID ?? `anon:${message}`;
225
+ if (state.pendingAsks.has(key)) return;
226
+ state.pendingAsks.add(key);
227
+ if (alertMode(deps.config(), "pause") === "none") return;
228
+ deliver({ kind: "pause", title: await titleFor(sessionID), message });
229
+ } catch (err) {
230
+ console.error(`[thatch] pause alert failed: ${err}`);
231
+ }
232
+ }
233
+
234
+ function resolved(sessionID: string, requestID: string | undefined): void {
235
+ const state = sessions.get(sessionID);
236
+ if (!state) return;
237
+ if (requestID) state.pendingAsks.delete(requestID);
238
+ // No id: clear all - the reply event dropped the request payload, and
239
+ // a stuck pending ask would suppress a future genuine alert.
240
+ else state.pendingAsks.clear();
241
+ }
242
+
243
+ return {
244
+ /** Interactive question blocking the LLM (question.asked). */
245
+ questionAsked: (sessionID: string, requestID: string | undefined) =>
246
+ asked(sessionID, requestID, "Question needs your answer"),
247
+
248
+ /** Permission prompt blocking the LLM (permission.asked). */
249
+ permissionAsked: (sessionID: string, requestID: string | undefined) =>
250
+ asked(sessionID, requestID, "Permission needs your approval"),
251
+
252
+ /** The ask was answered; re-asks of the same id may notify again. */
253
+ questionResolved: resolved,
254
+ permissionResolved: resolved,
255
+
256
+ /** The session started work (status busy). Resets the round. */
257
+ sessionBusy(sessionID: string): void {
258
+ const state = stateFor(sessionID);
259
+ state.active = true;
260
+ state.realWork = false;
261
+ state.lastError = null;
262
+ },
263
+
264
+ /**
265
+ * A retry (status retry, v1 only - v2 never publishes session.error
266
+ * for a retried attempt). Clears a recorded error - opencode recovered
267
+ * on its own - but PRESERVES the real-work flag: the tool calls before
268
+ * the retry happened, and the recovered tail is often text-only, so a
269
+ * reset here would make the done alert under-fire.
270
+ */
271
+ sessionRetry(sessionID: string): void {
272
+ const state = stateFor(sessionID);
273
+ state.active = true;
274
+ state.lastError = null;
275
+ },
276
+
277
+ /**
278
+ * Real tool work happened this busy period. The runtime calls this
279
+ * exactly where the extraction buffer pushes - the buffer's filter is
280
+ * the single classification of "this call was not bookkeeping" (meta
281
+ * tools excluded, execute-wrapped thatch calls unwrapped). todowrite
282
+ * and question are additionally excluded here (see isNoWorkTool).
283
+ */
284
+ sessionRealWork(sessionID: string): void {
285
+ stateFor(sessionID).realWork = true;
286
+ },
287
+
288
+ /** The session failed (session.error / execution failure). */
289
+ sessionError(sessionID: string, errorName: string | null | undefined): void {
290
+ const state = stateFor(sessionID);
291
+ state.active = true;
292
+ state.lastError = errorName ?? "unknown";
293
+ },
294
+
295
+ /**
296
+ * The session went idle (busy -> idle transition). The verdict: error
297
+ * alert, done alert, or silence. On both lines every busy period ends
298
+ * with an idle event - v2's execution.failed is translated to
299
+ * session.error + idle by the adapter, so this is the single verdict
300
+ * path.
301
+ */
302
+ async sessionIdle(sessionID: string): Promise<void> {
303
+ const state = sessions.get(sessionID);
304
+ if (!state?.active) return;
305
+ state.active = false;
306
+ try {
307
+ const recorded = state.lastError;
308
+ state.lastError = null;
309
+ // A recorded error delivers its verdict WITHOUT the message fetch:
310
+ // the fetch rides the same host API whose failure plausibly
311
+ // accompanied the execution failure, and silence must not land
312
+ // exactly when the needs-attention alert matters most.
313
+ if (recorded) {
314
+ if (isSilentError(recorded)) return; // user abort or shutdown
315
+ if (alertMode(deps.config(), "error") !== "none") {
316
+ deliver({
317
+ kind: "error",
318
+ title: await titleFor(sessionID),
319
+ message: `Session needs attention - the last round failed (${recorded})`,
320
+ });
321
+ }
322
+ return;
323
+ }
324
+ const shape = await deps.roundShape(sessionID).catch(() => null);
325
+ if (!shape) {
326
+ console.info(`[thatch] alert verdict ${sessionID}: silent (round shape unavailable)`);
327
+ return; // unknown shape: silent (best-effort)
328
+ }
329
+ if (isSilentError(shape.roundError)) return; // user abort or shutdown
330
+ if (shape.roundError) {
331
+ if (alertMode(deps.config(), "error") !== "none") {
332
+ deliver({
333
+ kind: "error",
334
+ title: await titleFor(sessionID),
335
+ message: `Session needs attention - the last round failed (${shape.roundError})`,
336
+ });
337
+ }
338
+ return;
339
+ }
340
+ if (!roundDidRealWork(shape, state.realWork)) {
341
+ console.info(
342
+ `[thatch] alert verdict ${sessionID}: silent (${shape.syntheticTrigger ? "synthetic-triggered round" : "no real tool work"})`,
343
+ );
344
+ return;
345
+ }
346
+ if (alertMode(deps.config(), "done") === "none") return;
347
+ deliver({ kind: "done", title: await titleFor(sessionID), message: "Work finished" });
348
+ } catch (err) {
349
+ console.error(`[thatch] idle alert failed: ${err}`);
350
+ }
351
+ },
352
+
353
+ /** The session is gone; drop its state. */
354
+ sessionDeleted(sessionID: string): void {
355
+ sessions.delete(sessionID);
356
+ },
357
+ };
358
+ }
359
+
360
+ export type Alerts = ReturnType<typeof createAlerts>;
@@ -0,0 +1,174 @@
1
+ // HostCapabilities: the plugin-lifecycle operations the shared runtime needs
2
+ // from whatever opencode version hosts it. The v1 adapter implements it over
3
+ // the SDK client from PluginInput; the v2 adapter implements it over the v2
4
+ // promise-context domains, degrading (catch-and-ignore or null) where the v2
5
+ // surface lacks an operation. This is the lifecycle-level sibling of
6
+ // CoreContext (src/tool-defs.ts), which is the per-tool-call seam; they stay
7
+ // separate on purpose.
8
+ //
9
+ // Typed structurally so test doubles and both adapters satisfy it without a
10
+ // runtime dependency on either SDK package; the v1 bridge alone imports the
11
+ // v1 SDK types (type-only, erased at runtime) so the compiler checks its
12
+ // mapping against the real client.
13
+
14
+ import type { PluginInput } from "@opencode-ai/plugin";
15
+
16
+ /** Minimal text-part shape used for session prompts and nudges. */
17
+ export interface PromptPart {
18
+ type: "text";
19
+ text: string;
20
+ synthetic?: boolean;
21
+ }
22
+
23
+ export interface ToastInput {
24
+ message: string;
25
+ variant: "info" | "success" | "warning" | "error";
26
+ duration: number;
27
+ }
28
+
29
+ export interface HostCapabilities {
30
+ /**
31
+ * Whether promptSession honors noReply (deliver without starting a model
32
+ * turn). v1: true (promptAsync with noReply renders a visible bubble and
33
+ * starts nothing). v2: false - the prompt endpoint has no noReply
34
+ * semantics, so a noReply delivery would start a real model turn. The
35
+ * runtime gates its noReply callers (chat echoes, session-start
36
+ * reminder) on this; turning it on for a host without support creates a
37
+ * model-turn feedback loop.
38
+ */
39
+ readonly noReplyDelivery: boolean;
40
+ /**
41
+ * The host lets plugins register slash commands programmatically (v2's
42
+ * CommandEditor). When true the runtime installs only the action command
43
+ * FILES and the adapter registers the wrap-up commands in code (arming
44
+ * the greenlight check via onCommandExecuteBefore) - a registered
45
+ * command and an installed file with the same name would collide.
46
+ */
47
+ readonly nativeCommands: boolean;
48
+ /** Live session statuses, as client.session.status() returns. */
49
+ fetchStatuses(): Promise<Record<string, { type: string }> | null>;
50
+ /**
51
+ * Create a child session. Returns its id. v1 passes the parentID through
52
+ * (the host links the child); v2 has no parentID on its create API, so
53
+ * the child is a top-level session in the parent's project directory and
54
+ * only the title carries over.
55
+ */
56
+ sessionCreate(input: { parentID: string; title: string }): Promise<{ id: string }>;
57
+ /** Delete a session (child-session cleanup). Degrades to a no-op resolve on v2. */
58
+ sessionDelete(id: string): Promise<void>;
59
+ /**
60
+ * Prompt a session with text parts. `sync` mode blocks until the child
61
+ * finishes (v1 client.session.prompt); async mode returns immediately
62
+ * (v1 client.session.promptAsync). Both may reject; callers own
63
+ * fire-and-forget vs await.
64
+ */
65
+ promptSession(
66
+ sessionID: string,
67
+ body: { parts: PromptPart[]; noReply?: boolean },
68
+ mode: "sync" | "async",
69
+ ): Promise<void>;
70
+ /** Fetch one session's info (title, location). Null when the host rejects. */
71
+ sessionGet(id: string): Promise<{ title?: string } | null>;
72
+ /** List sessions in this directory. Null when unavailable (v2 degrade). */
73
+ sessionList(): Promise<{ id: string; parentID?: string; time?: { updated?: number } }[] | null>;
74
+ /**
75
+ * Fetch a session's message list (wrap-up greenlight check, alert round
76
+ * classification). Null when unavailable. v2 reads them via
77
+ * session.context, mapped into this shape. Parts carry the fields the
78
+ * round classifier needs: tool parts name their tool, text parts carry
79
+ * the synthetic flag, and info.error holds the message-level error name
80
+ * (abort / stall detection).
81
+ */
82
+ sessionMessages(
83
+ id: string,
84
+ ): Promise<
85
+ { info: { role: string; error?: string }; parts?: { type: string; text?: string; tool?: string; synthetic?: boolean }[] }[]
86
+ | null
87
+ >;
88
+ /** TUI toast. The runtime's call sites catch-and-ignore; the adapter just delivers. */
89
+ showToast(toast: ToastInput): Promise<void>;
90
+ /**
91
+ * Trigger the host's compaction for a session (the wrap-up compact
92
+ * action). v1 dispatches the TUI's session_compact command (the legacy
93
+ * alias route; the id is ignored). v2 has no compaction trigger reachable
94
+ * from the promise context and degrades to a logged no-op.
95
+ */
96
+ compactSession(sessionID: string): Promise<void>;
97
+ /**
98
+ * Exit the host application (the wrap-up exit action). v1 publishes the
99
+ * TUI's app.exit command; v2 has no TUI surface and degrades to a no-op.
100
+ */
101
+ exitHost(): Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * v1 implementation: the SDK client from PluginInput. The mapping mirrors
106
+ * the v1 hook surface one call at a time, so the client-mocking tests
107
+ * observe identical call patterns.
108
+ */
109
+ export function capabilitiesFromClient(client: PluginInput["client"]): HostCapabilities {
110
+ return {
111
+ noReplyDelivery: true,
112
+ nativeCommands: false,
113
+ fetchStatuses: async () => {
114
+ const { data } = await client.session.status();
115
+ return data ?? {};
116
+ },
117
+ sessionCreate: async (input) => {
118
+ const result = await client.session.create({ body: input });
119
+ return { id: result.data!.id };
120
+ },
121
+ sessionDelete: async (id) => {
122
+ await client.session.delete({ path: { id } });
123
+ },
124
+ promptSession: async (sessionID, body, mode) => {
125
+ const call = { path: { id: sessionID }, body };
126
+ if (mode === "async") await client.session.promptAsync(call);
127
+ else await client.session.prompt(call);
128
+ },
129
+ sessionGet: async (id) => {
130
+ const { data } = await client.session.get({ path: { id } });
131
+ return data ?? null;
132
+ },
133
+ sessionList: async () => {
134
+ const { data } = await client.session.list();
135
+ return data ?? null;
136
+ },
137
+ sessionMessages: async (id) => {
138
+ const { data } = await client.session.messages({ path: { id } });
139
+ // Map the message-level error (if any) to its name - the alert
140
+ // classifier matches aborts by name, and v1 message infos carry the
141
+ // full error object.
142
+ return (
143
+ data?.map((m) => ({
144
+ ...m,
145
+ info: {
146
+ ...m.info,
147
+ // Name-then-type fallback (mirroring the v2 mapping's
148
+ // type-then-name), so an error carrying only one of the two
149
+ // still reaches the alert classifier on both lines.
150
+ error:
151
+ (m.info as { error?: { name?: string; type?: string } } | undefined)?.error?.name ??
152
+ (m.info as { error?: { type?: string } } | undefined)?.error?.type,
153
+ },
154
+ })) ?? null
155
+ );
156
+ },
157
+ showToast: async (toast) => {
158
+ await client.tui.showToast({ body: toast });
159
+ },
160
+ compactSession: async () => {
161
+ // executeCommand only accepts legacy alias names; "session_compact"
162
+ // maps to the TUI's session.compact action, the same thing the
163
+ // built-in /compact command runs.
164
+ await client.tui.executeCommand({ body: { command: "session_compact" } });
165
+ },
166
+ exitHost: async () => {
167
+ // No exit alias exists, so publish the TUI keymap command directly -
168
+ // the same dispatch as the /exit slash command.
169
+ await client.tui.publish({
170
+ body: { type: "tui.command.execute", properties: { command: "app.exit" } },
171
+ } as Parameters<typeof client.tui.publish>[0]);
172
+ },
173
+ };
174
+ }
package/src/chat.ts CHANGED
@@ -229,7 +229,7 @@ const MAX_TOPIC_LEN = 80;
229
229
  // many days are pruned from the directory. Counters never decrement, so a
230
230
  // pruned session's name is never reissued - pruning cannot create identity
231
231
  // confusion, only roster silence.
232
- const CHAT_AUTO_TTL_DAYS = 7;
232
+ export const CHAT_AUTO_TTL_DAYS = 7;
233
233
 
234
234
  /**
235
235
  * Slugifies a session title into a name base: lowercase, letter/number runs
@@ -398,6 +398,9 @@ export class ChatStore {
398
398
  * random pool name (chat-names.ts) and the per-base counter only ever
399
399
  * increments - so a name is minted exactly once, machine-wide, and
400
400
  * pruning an old session can never reissue its name to someone else. The
401
+ * one exception is the session itself: a reaped row leaves a claim, and
402
+ * the same session id re-registering reclaims its old name (crash-and-
403
+ * resume keeps its identity). The
401
404
  * session title never feeds the name; it rides along as the topic, so a
402
405
  * placeholder or later-edited title cannot leave a misleading name
403
406
  * behind. Idempotent for an already-registered session: it keeps its
@@ -438,6 +441,21 @@ export class ChatStore {
438
441
  const gate = this.#tombstoneGate(sessionID, kind);
439
442
  if (gate) return gate;
440
443
  const cleanTopic = this.#cleanTopic(topic);
444
+ // A reaped row leaves a name claim: this session owned that name before
445
+ // its harness died, so reclaim it instead of minting a new one. The
446
+ // claim is cleared on success (a later reap re-records it); if the name
447
+ // is somehow taken, fall through to the counter draw.
448
+ const claim = this.#db
449
+ .query("SELECT name FROM chat_name_claims WHERE session_id = ?")
450
+ .get(sessionID) as { name: string } | undefined;
451
+ if (claim) {
452
+ const reclaimed = this.#insertSession(sessionID, claim.name, project, cleanTopic, kind, worktree);
453
+ if (reclaimed.ok) {
454
+ this.#db.run("DELETE FROM chat_name_claims WHERE name = ?", [claim.name]);
455
+ return { ok: true, name: claim.name, topic: cleanTopic, created: true };
456
+ }
457
+ this.#db.run("DELETE FROM chat_name_claims WHERE name = ?", [claim.name]);
458
+ }
441
459
  const base =
442
460
  nameBase ??
443
461
  slugifyTitle(CHAT_NAME_POOL[Math.floor(Math.random() * CHAT_NAME_POOL.length)]) ??
@@ -551,7 +569,19 @@ export class ChatStore {
551
569
  * Returns the number of directory rows pruned.
552
570
  */
553
571
  pruneStaleAuto(cutoff: string): number {
572
+ // Record the name bindings of the rows about to be reaped, so the same
573
+ // session can reclaim its name when it re-registers (a crash-and-resume
574
+ // keeps its identity instead of churning to a fresh counter draw).
575
+ const dying = this.#db
576
+ .query("SELECT session_id, name FROM chat_sessions WHERE auto = 1 AND last_seen < ?")
577
+ .all(cutoff) as any[];
554
578
  const result = this.#db.run("DELETE FROM chat_sessions WHERE auto = 1 AND last_seen < ?", [cutoff]);
579
+ for (const row of dying) {
580
+ this.#db.run(
581
+ "INSERT INTO chat_name_claims (name, session_id) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET session_id = excluded.session_id",
582
+ [row.name, row.session_id],
583
+ );
584
+ }
555
585
  this.#db.run(
556
586
  "DELETE FROM chat_messages WHERE read_at IS NULL AND to_session NOT IN (SELECT session_id FROM chat_sessions) AND created_at < ?",
557
587
  [cutoff],
@@ -1308,11 +1338,56 @@ export interface ChatPollerOptions {
1308
1338
 
1309
1339
  /**
1310
1340
  * Background poller: heartbeats hosted sessions, delivers wake prompts for
1311
- * pending chat messages. Mirrors the watcher registry's shape (start/stop/
1312
- * dispose, re-entrant guards, pending-retry semantics) but its pending state
1313
- * lives in SQLite rather than memory, because the sender may be a different
1314
- * process than the deliverer.
1341
+ * pending chat messages. Mirrors the watcher registry's lifecycle shape
1342
+ * (start/stop/dispose, re-entrant guards, pending-retry semantics) but its
1343
+ * pending state lives in SQLite rather than memory, because the sender may
1344
+ * be a different process than the deliverer. (The watcher registry now
1345
+ * journals its DEFINITIONS to runtime_state; its live poll/delivery state
1346
+ * remains in-memory.)
1347
+ */
1348
+ /**
1349
+ * Computes the chat poller's hosted set - the sessions this plugin instance
1350
+ * may heartbeat and deliver mail to.
1351
+ *
1352
+ * - Event-fed sessions (status keys): always hosted by the instance that
1353
+ * saw them.
1354
+ * - Startup-resumed sessions (-s/-c): hosted by their own instance before
1355
+ * any event arrives.
1356
+ * - Re-hosted sessions (a reload rehydration): the instance's own journaled
1357
+ * hosted set, restored so the poller can wake a session the reload left
1358
+ * asleep instead of waiting for the user to type.
1359
+ *
1360
+ * Child sessions are never hosted (heartbeating one fresh-forever burns
1361
+ * nudge budget on undeliverable wake prompts).
1362
+ */
1363
+ export function hostedSessionIds(options: {
1364
+ statusKeys: Iterable<string>;
1365
+ resumedSessions: Iterable<string>;
1366
+ rehostedSessions: Iterable<string>;
1367
+ exclude: Iterable<string>;
1368
+ }): string[] {
1369
+ const hosted = [...options.statusKeys, ...options.resumedSessions, ...options.rehostedSessions];
1370
+ const excluded = new Set(options.exclude);
1371
+ return [...new Set(hosted)].filter((id) => !excluded.has(id));
1372
+ }
1373
+
1374
+ /**
1375
+ * Grace-rehost ids still inside their rehost window, pruning expired
1376
+ * entries as a side effect (the map is the runtime's live restart-grace
1377
+ * state - see CHAT_REHOST_GRACE_MS in runtime.ts). A restart re-hosts the
1378
+ * dead daemon's hosted set under this grace: open TUI tabs reconnect but
1379
+ * emit no events until the user types, so without it they go falsely
1380
+ * stale while alive-idle.
1315
1381
  */
1382
+ export function unexpiredGraceRehosts(grace: Map<string, number>, now: number): string[] {
1383
+ const live: string[] = [];
1384
+ for (const [id, deadline] of grace) {
1385
+ if (now >= deadline) grace.delete(id);
1386
+ else live.push(id);
1387
+ }
1388
+ return live;
1389
+ }
1390
+
1316
1391
  export class ChatPoller {
1317
1392
  #opts: Required<ChatPollerOptions>;
1318
1393
  #timer: ReturnType<typeof setInterval> | null = null;