@jeffober/thatch 0.1.47 → 0.2.0

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 (47) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-plan-refinement.md +2 -0
  3. package/bin/thatch +31 -16
  4. package/docs/dev/README.md +28 -6
  5. package/docs/dev/features/behavior-engine.md +3 -2
  6. package/docs/dev/features/cicd.md +1 -1
  7. package/docs/dev/features/compaction-recovery.md +1 -1
  8. package/docs/dev/features/cross-session-chat.md +4 -2
  9. package/docs/dev/features/extraction.md +10 -2
  10. package/docs/dev/features/hygiene.md +3 -1
  11. package/docs/dev/features/multi-host.md +4 -2
  12. package/docs/dev/features/notifications.md +121 -4
  13. package/docs/dev/features/nudge-pipeline.md +4 -2
  14. package/docs/dev/features/opencode-plugin.md +133 -0
  15. package/docs/dev/features/qa-system.md +43 -0
  16. package/docs/dev/features/repo-identity.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +5 -2
  18. package/docs/dev/features/watchers.md +12 -5
  19. package/docs/dev/gotchas.md +36 -3
  20. package/docs/dev/mcp-parity.md +4 -3
  21. package/docs/dev/setup-and-hooks.md +6 -1
  22. package/docs/plans/llm-alerts.md +287 -0
  23. package/docs/plans/prediction-consolidation.md +3 -3
  24. package/docs/user/README.md +7 -3
  25. package/docs/user/notifications.md +63 -4
  26. package/docs/user/setup.md +4 -0
  27. package/docs/user/watchers.md +15 -7
  28. package/package.json +3 -1
  29. package/src/alerts.ts +360 -0
  30. package/src/capabilities.ts +174 -0
  31. package/src/chat.ts +63 -5
  32. package/src/commands.ts +44 -5
  33. package/src/config.ts +32 -1
  34. package/src/db.ts +80 -0
  35. package/src/embeddings.ts +43 -0
  36. package/src/extraction.ts +124 -0
  37. package/src/index.ts +38 -1345
  38. package/src/opencode/v1.ts +54 -0
  39. package/src/opencode/v2.ts +561 -0
  40. package/src/os-args.ts +99 -0
  41. package/src/prompts.ts +77 -1
  42. package/src/runtime.ts +1815 -0
  43. package/src/seed-behaviors.ts +1 -1
  44. package/src/session-db.ts +32 -0
  45. package/src/tool-defs.ts +185 -19
  46. package/src/tools.ts +16 -35
  47. package/src/watchers.ts +95 -14
@@ -0,0 +1,54 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { capabilitiesFromClient } from "../capabilities";
3
+ import { createRuntime } from "../runtime";
4
+ import { createTools } from "../tools";
5
+
6
+ // The opencode v1 adapter (opencode 1.x, plugin API @opencode-ai/plugin
7
+ // 1.x). Thin by design: builds HostCapabilities from the PluginInput client
8
+ // and delegates to the shared runtime. Loaded only by v1 hosts - the dual
9
+ // entry (src/index.ts) lazy-imports this module so the v1 SDK's runtime
10
+ // import never evaluates under a v2 host.
11
+
12
+ export const server: Plugin = async ({ client, worktree, directory }) => {
13
+ const runtime = await createRuntime({
14
+ capabilities: capabilitiesFromClient(client),
15
+ directory,
16
+ worktree,
17
+ });
18
+
19
+ return {
20
+ tool: createTools(runtime.coreContext),
21
+
22
+ "experimental.chat.system.transform": async (_input, output) => {
23
+ await runtime.onSystemTransform(output);
24
+ },
25
+
26
+ "experimental.session.compacting": async (input, output) => {
27
+ await runtime.onSessionCompacting(input, output);
28
+ },
29
+
30
+ "experimental.compaction.autocontinue": async (input) => {
31
+ await runtime.onCompactionAutocontinue(input);
32
+ },
33
+
34
+ "tool.execute.after": async (input, output) => {
35
+ await runtime.onToolExecuteAfter(input, output);
36
+ },
37
+
38
+ "command.execute.before": async (input) => {
39
+ await runtime.onCommandExecuteBefore(input);
40
+ },
41
+
42
+ "chat.message": async (input, output) => {
43
+ await runtime.onChatMessage(input, output);
44
+ },
45
+
46
+ event: async ({ event }) => {
47
+ await runtime.onEvent(event);
48
+ },
49
+
50
+ dispose: async () => {
51
+ await runtime.dispose();
52
+ },
53
+ };
54
+ };
@@ -0,0 +1,561 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { z } from "zod";
3
+ import type { Tool } from "@opencode/schema/tool";
4
+ import type { ToolContext as V2ToolContext } from "@opencode/plugin/promise/tool";
5
+ import type { HostCapabilities, PromptPart, ToastInput } from "../capabilities";
6
+ import { createRuntime } from "../runtime";
7
+ import { wrapUpCommandContent } from "../commands";
8
+ import { deriveTitle } from "../extraction";
9
+ import { TOOL_DEFS, trimHostContext, type HostToolContext } from "../tool-defs";
10
+
11
+ // The opencode v2 adapter (opencode 2.x, plugin API @opencode/plugin 2.x).
12
+ // Loaded only by v2 hosts - the dual entry (src/index.ts) lazy-imports this
13
+ // module so the v1 SDK's imports never evaluate under a v1 host.
14
+ //
15
+ // Mappings against the v1 hook surface; the 2026-09-23 smoke run against a
16
+ // real v2 binary plus the SDK dist types are the sources of truth
17
+ // (docs/dev/features/opencode-plugin.md has the full capability table):
18
+ // - tools: ToolEditor.add with the same thatch_ names. Input schemas are
19
+ // pre-converted to JSON Schema with our own zod, because v2's converter
20
+ // detects zod via `instanceof $ZodType` against ITS bundled zod copy and
21
+ // drops ours (leaving tools parameterless). String results wrap as
22
+ // { content }. The tool.hook execute.after hook feeds the same extraction
23
+ // buffer as v1's tool.execute.after.
24
+ // - system prompt: session.hook("context") mutates the request's system
25
+ // array; the runtime's plain strings convert to {type: "text", text}
26
+ // parts at the boundary.
27
+ // - per-message nudges: the prompt hook computes the turn's injections;
28
+ // the generate hook appends them to the outbound request's last user
29
+ // message `content` array (v2 wire Messages have no `parts` field).
30
+ // Injections stay out of the stored prompt text.
31
+ // - noReply deliveries (chat echoes, session-start reminder): v2's prompt
32
+ // endpoint cannot suppress the model turn, so the runtime gates them off
33
+ // via capabilities.noReplyDelivery.
34
+ // - compaction: session.hook("compaction") marks the session AND injects
35
+ // the re-familiarization system context - the compaction request carries
36
+ // the same mutable `system` array the context hook mutates (v1 parity:
37
+ // v1's system.transform fires on compaction summaries too).
38
+ // - events: context.event.subscribe pump; the envelope is
39
+ // {type, data, location?}. Filtered client-side the way the v1 host
40
+ // filters server-side: events resolve to a directory (their own, the
41
+ // session cache, or session.get) and drop when it is not ours.
42
+ // - toasts, tui commands, session delete/list endpoints:
43
+ // no v2 surface reachable from a plugin - degrades as no-op/null.
44
+ // fetchStatuses returns {} because the wake gate treats an unknown session
45
+ // as idle; the event-fed status map inside the runtime does the gating.
46
+ // Session MESSAGES are readable via session.context (mapped into the v1
47
+ // shape), so the wrap-up greenlight check works; the compaction TRIGGER
48
+ // does not (no session.compact on the promise domain).
49
+
50
+ type V2Context = Plugin.Context;
51
+ type V2Cleanup = Plugin.Cleanup;
52
+
53
+ // Raw setup function: the dual entry (src/index.ts) owns the plugin id and
54
+ // the merged default export; this adapter only implements the v2 setup.
55
+ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
56
+ // Verified against the live binary: Location.Info {directory, project:
57
+ // {directory, canonical}} - directory is the session dir (forwarded
58
+ // unchanged on deleted-worktree resume), project.directory the served
59
+ // project root (the v1 `worktree`). A wrong guess here corrupts repo
60
+ // identity, store scoping, and the event filter all at once.
61
+ const location = context.location as {
62
+ directory: string;
63
+ project: { directory: string };
64
+ };
65
+ const directory = location.directory;
66
+ const worktree = location.project.directory;
67
+
68
+ // Plugin-created extraction children live in the project directory, which
69
+ // differs from this instance's directory whenever opencode is launched
70
+ // below the project root. Their events would never pass the directory
71
+ // filter, so they are forwarded by ID: sessionCreate records every child
72
+ // here, and the pump lets their events through.
73
+ const childSessions = new Set<string>();
74
+
75
+ const capabilities = buildCapabilities(context, worktree, childSessions);
76
+ const runtime = await createRuntime({ capabilities, directory, worktree });
77
+ // Seed the forwarding set from rehydrated child bookkeeping: after a
78
+ // reload, an in-flight extraction child's maps come back from the journal,
79
+ // but this NEW adapter instance's childSessions set starts empty - without
80
+ // the seed, the pump's directory filter drops the child's events again
81
+ // (the below-root launch case).
82
+ for (const id of runtime.childSessionIds()) childSessions.add(id);
83
+
84
+ // Tool registration: the same CoreContext the v1 adapter feeds to
85
+ // createTools, registered through the v2 ToolEditor instead.
86
+ //
87
+ // The input schema is pre-converted to JSON Schema with our own zod:
88
+ // v2's converter detects zod via `instanceof $ZodType` against ITS bundled
89
+ // zod copy, which fails for ours and leaves the tool parameterless (the
90
+ // LLM then guesses argument names and the tools crash on undefined args).
91
+ // A plain JSON Schema object flows through v2's inputJsonSchema untouched
92
+ // and validates through v2's JSON-schema codec cache.
93
+ const registerTools = await context.tool.transform((editor) => {
94
+ for (const def of TOOL_DEFS) {
95
+ editor.add({
96
+ name: `thatch_${def.name}`,
97
+ description: def.description,
98
+ input: z.toJSONSchema(z.object(def.args)),
99
+ execute: async (input: unknown, toolContext: V2ToolContext) => {
100
+ const host: HostToolContext | undefined = trimHostContext(toolContext);
101
+ const result: string = await def.execute(input as Record<string, unknown>, runtime.coreContext, host);
102
+ return { content: result } as Tool.Result;
103
+ },
104
+ });
105
+ }
106
+ });
107
+
108
+ // Tool execution buffering: same feeder as v1's tool.execute.after. The
109
+ // hook payload carries the outcome; error-status calls are not buffered
110
+ // (matching v1, whose hook only fired on completed tool calls).
111
+ const registerToolHook = await context.tool.hook("execute.after", async (hook) => {
112
+ if (hook.status && hook.status !== "completed") return;
113
+ // The v2 execute.after hook's result is a Tool.Result: `content` may be
114
+ // a string or an array of content parts ({type: "text", text}, files,
115
+ // ...), and `output` is the tool's typed output value, not a title. v1's
116
+ // hook delivered {title, output} strings; here the title is derived
117
+ // from the tool name and args (the runtime pushes it through verbatim)
118
+ // and the content is flattened to the buffer's text line.
119
+ const result = hook.result;
120
+ const text = flattenToolContent(result?.content);
121
+ await runtime.onToolExecuteAfter(
122
+ { tool: hook.tool, sessionID: hook.sessionID, args: hook.input },
123
+ { title: deriveTitle(hook.tool, (hook.input ?? {}) as Record<string, unknown>), output: text },
124
+ );
125
+ });
126
+
127
+ // Model-request hook (kind "primary" = every normal user turn; v2 also
128
+ // has a "generate" hook, but it fires only for the plugin-invoked
129
+ // session.generate sub-request - it never fires for normal prompts,
130
+ // which the 2026-09-24 probe confirmed). Two jobs per model request:
131
+ // - system prompt: re-familiarization. The runtime pushes plain strings
132
+ // (the v1 contract); v2's system parts are { type: "text", text }
133
+ // objects, so convert at the boundary.
134
+ // - per-turn nudges: append the prompt hook's stored injections to the
135
+ // OUTBOUND request's last user message. The stored user message stays
136
+ // clean (v1 delivered nudges as TUI-hidden synthetic parts; v2's prompt
137
+ // text IS the stored message), and the request is rebuilt per round
138
+ // trip, so injecting here reproduces v1's persistent-synthetic-part
139
+ // semantics: the model sees the nudges on every call of the turn.
140
+ const pendingInjections = new Map<string, string[]>();
141
+ const registerSystem = await context.session.hook(
142
+ "context",
143
+ async (request: { sessionID: string; system: unknown[]; messages?: any[] }) => {
144
+ const pushed: string[] = [];
145
+ await runtime.onSystemTransform({ system: pushed });
146
+ for (const part of pushed) {
147
+ request.system.push(typeof part === "string" ? { type: "text", text: part } : part);
148
+ }
149
+ const injections = pendingInjections.get(request.sessionID);
150
+ if (!injections?.length) return;
151
+ const lastUser = [...(request.messages ?? [])].reverse().find((m) => m?.role === "user");
152
+ if (!lastUser) return;
153
+ // v2's wire Message carries parts in `content: Array<ContentPart>` -
154
+ // it has no `parts` field, so injecting there would write a stray
155
+ // property the provider formatter never reads (silent nudge loss).
156
+ const content = Array.isArray(lastUser.content) ? lastUser.content : (lastUser.content = []);
157
+ for (const text of injections) content.push({ type: "text", text });
158
+ runtime.debug("v2:hook:context", `session=${request.sessionID} injected=${injections.length} messages=${request.messages?.length}`);
159
+ },
160
+ );
161
+
162
+ // Per-message nudges: the prompt hook computes them once per user message
163
+ // (storing them per session); the context hook above injects them into
164
+ // every outbound model request of the turn.
165
+ const registerPrompt = await context.session.hook(
166
+ "prompt",
167
+ async (request: { sessionID: string; messageID: string; prompt: { text: string } }) => {
168
+ runtime.debug("v2:hook:prompt", `session=${request.sessionID} text=${JSON.stringify(request.prompt.text?.slice(0, 60))}`);
169
+ const parts: PromptPart[] = [{ type: "text", text: request.prompt.text }];
170
+ try {
171
+ await runtime.onChatMessage(
172
+ { sessionID: request.sessionID, messageID: request.messageID },
173
+ { parts: parts as any[], message: { id: request.messageID } },
174
+ );
175
+ } catch (err) {
176
+ console.error(`[thatch] v2 prompt hook failed: ${err}`);
177
+ return;
178
+ }
179
+ const injections = parts.slice(1).map((part) => part.text);
180
+ if (injections.length > 0) runtime.debug("v2:hook:prompt", `session=${request.sessionID} injections=${injections.length}`);
181
+ if (injections.length > 0) pendingInjections.set(request.sessionID, injections);
182
+ else pendingInjections.delete(request.sessionID);
183
+ },
184
+ );
185
+
186
+
187
+ // Compaction: the nudge-suppression flag is the only surface verified to
188
+ // exist; the context-injection surface is unverified.
189
+ const registerCompaction = await context.session.hook(
190
+ "compaction",
191
+ async (request: { sessionID: string; system: unknown[] }) => {
192
+ await runtime.onSessionCompacting({ sessionID: request.sessionID }, { context: [] });
193
+ // Re-familiarization context for the summary generation - the same
194
+ // injection the context hook gives primary requests; without it the
195
+ // compaction summary is the one model request that never sees the
196
+ // memory system's context.
197
+ const pushed: string[] = [];
198
+ await runtime.onSystemTransform({ system: pushed });
199
+ for (const part of pushed) {
200
+ request.system.push(typeof part === "string" ? { type: "text", text: part } : part);
201
+ }
202
+ },
203
+ );
204
+
205
+ // Wrap-up slash commands register in code (v2 CommandEditor): execute
206
+ // arms the greenlight check and delivers the same prompt body the v1
207
+ // command file carries. The runtime removes stale wrap-up FILES and
208
+ // installs only the action files on v2 (a file and a registered command
209
+ // with the same name collide).
210
+ const registerCommands = await context.command.transform((editor) => {
211
+ for (const kind of ["compact", "exit"] as const) {
212
+ editor.add({
213
+ name: `thatch/${kind}`,
214
+ description:
215
+ kind === "compact"
216
+ ? "Flush thatch persistence, check for loose ends, then compact if clear"
217
+ : "Flush thatch persistence, check for loose ends, then exit opencode if clear",
218
+ // The user's typed arguments ride invocation.prompt (a
219
+ // PromptInput.Prompt with a text field). v1's host expanded
220
+ // $ARGUMENTS from the command file; v2's session.prompt does no
221
+ // template expansion, so substitute here - dropping the args
222
+ // would store a bare template as the user message. The function
223
+ // replacer keeps `$` sequences in the user's text ($$ stays $$ -
224
+ // a string replacement would interpret them).
225
+ execute: async ({ sessionID, prompt }: { sessionID: string; prompt?: { text?: string } }) => {
226
+ // Same arm the v1 command.execute.before hook runs - the wrap-up
227
+ // resolution happens on the session's next idle event.
228
+ await runtime.onCommandExecuteBefore({ command: `thatch/${kind}`, sessionID });
229
+ const args = typeof prompt?.text === "string" ? prompt.text : "";
230
+ await capabilities.promptSession(
231
+ sessionID,
232
+ { parts: [{ type: "text", text: wrapUpCommandContent(kind).replace("$ARGUMENTS", () => args) }] },
233
+ "sync",
234
+ );
235
+ },
236
+ });
237
+ }
238
+ });
239
+
240
+ // Bus events: raw SSE subscription, not directory-scoped. Filter
241
+ // client-side: drop events located elsewhere. v2's execution lifecycle
242
+ // events (the idle signal) carry NO location, so the session's project is
243
+ // resolved and cached: first from any event that does carry one, then
244
+ // via the session API. A session whose directory cannot be resolved
245
+ // drops (same invisible-to-this-instance contract as v1's server filter).
246
+ const sessionDirs = new Map<string, string>();
247
+ const resolveSessionDir = async (sessionID: string): Promise<string | undefined> => {
248
+ try {
249
+ const result = await context.session.get({ sessionID });
250
+ const dir = result?.location?.directory;
251
+ if (dir) sessionDirs.set(sessionID, dir);
252
+ return dir;
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ };
257
+ const controller = new AbortController();
258
+ const pump = (async () => {
259
+ try {
260
+ for await (const event of context.event.subscribe({ signal: controller.signal })) {
261
+ // Per-event isolation: a throw inside one handler must not end the
262
+ // loop (a dead pump means no idle events, no extraction, and a
263
+ // frozen status map for the rest of the process). v1's host
264
+ // dispatched each hook invocation independently; this restores that
265
+ // blast radius.
266
+ try {
267
+ // Narrow to the fields the pump consumes: the generated V2Event
268
+ // union types each payload per-variant, and the runtime's
269
+ // translation only needs the sessionID/location pair.
270
+ const located = event as {
271
+ type: string;
272
+ data?: { sessionID?: string; location?: { directory?: string } };
273
+ location?: { directory?: string };
274
+ };
275
+ const data = located.data ?? {};
276
+ if (data.sessionID && located.location?.directory) sessionDirs.set(data.sessionID, located.location.directory);
277
+ if (data.sessionID && data.location?.directory) sessionDirs.set(data.sessionID, data.location.directory);
278
+ let eventDir = located.location?.directory ?? (data.sessionID ? sessionDirs.get(data.sessionID) : undefined);
279
+ if (!eventDir && data.sessionID) eventDir = await resolveSessionDir(data.sessionID);
280
+ runtime.debug("v2:pump", `event ${located.type} dir=${eventDir} self=${directory}`);
281
+ if (eventMatchesInstance(eventDir, data.sessionID, directory, childSessions)) {
282
+ for (const translated of translateEvent(located)) {
283
+ await runtime.onEvent(translated);
284
+ }
285
+ }
286
+ } catch (err) {
287
+ console.error(`[thatch] v2 event handler failed: ${err}`);
288
+ }
289
+ }
290
+ } catch (err) {
291
+ if (!controller.signal.aborted) console.error(`[thatch] event subscription failed: ${err}`);
292
+ }
293
+ })();
294
+
295
+ // Cleanup. Idempotent: v2 auto-reloads plugins on file change, so a
296
+ // second setup after a skipped or partial cleanup would double every
297
+ // poller, pump, and nudge. The pump await is safe: its body is fully
298
+ // try/caught and abort ends the loop, so it settles promptly.
299
+ let disposed = false;
300
+ return async () => {
301
+ if (disposed) return;
302
+ disposed = true;
303
+ controller.abort();
304
+ await pump;
305
+ registerTools.dispose();
306
+ registerToolHook.dispose();
307
+ registerSystem.dispose();
308
+ registerPrompt.dispose();
309
+ registerCompaction.dispose();
310
+ registerCommands.dispose();
311
+ await runtime.dispose();
312
+ };
313
+ }
314
+
315
+ // Map v2's SessionMessageInfo list into the v1 {info: {role}, parts} shape
316
+ // the runtime's wrap-up greenlight check and the alert round-derivation
317
+ // read. The alert classifier reads ROLES (idle markers delimit rounds),
318
+ // synthetic flags, and message-level errors from this mapping - tool names
319
+ // are irrelevant to it (the real-work signal comes from live tool
320
+ // bookkeeping), but they are preserved for other consumers. Assistant
321
+ // messages carry their text in content parts; other message kinds carry a
322
+ // plain text field (or none - those map to an empty part rather than
323
+ // dropping the message, so role ordering survives). Synthetic deliveries
324
+ // (nudges, task completions, watcher wake-ups) are their own message kind
325
+ // on v2 and keep that role.
326
+ export function mapSessionContextMessages(
327
+ messages: unknown,
328
+ ): { info: { role: string; error?: string }; parts: { type: string; text?: string; tool?: string; synthetic?: boolean }[] }[] {
329
+ return ((messages as any[]) ?? []).map((m) => ({
330
+ info: { role: m?.type, error: m?.error?.type ?? m?.error?.name },
331
+ parts:
332
+ m?.type === "assistant"
333
+ ? (m.content ?? [])
334
+ // Reasoning parts carry no wrap-up token and no tool name -
335
+ // skipping them keeps the wrap-up greenlight text identical to
336
+ // the pre-alert mapping.
337
+ .filter((c: any) => c.type === "text" || c.type === "tool")
338
+ .map((c: any) =>
339
+ c.type === "tool"
340
+ ? { type: "tool", tool: typeof c.name === "string" ? c.name : "" }
341
+ : { type: "text", text: c.text ?? "" },
342
+ )
343
+ : [{ type: "text", text: typeof m?.text === "string" ? m.text : "", synthetic: m?.type === "synthetic" }],
344
+ }));
345
+ }
346
+
347
+ // Which bus events belong to this plugin instance. An event is ours when
348
+ // its resolved directory matches the instance's own directory, or when it
349
+ // belongs to a session this instance created (extraction children - they
350
+ // live in the project directory, which differs from the instance directory
351
+ // on below-root launches).
352
+ export function eventMatchesInstance(
353
+ eventDir: string | undefined,
354
+ sessionID: string | undefined,
355
+ directory: string,
356
+ childSessions: Set<string>,
357
+ ): boolean {
358
+ if (eventDir === directory) return true;
359
+ return sessionID !== undefined && childSessions.has(sessionID);
360
+ }
361
+
362
+ // Flatten a v2 Tool.Result content field into the plain text the shared
363
+ // extraction buffer expects. Strings pass through; content-part arrays
364
+ // contribute their text parts (files and other non-text parts are dropped -
365
+ // the buffer wants a summary line, not attachments). Anything else is empty.
366
+ export function flattenToolContent(content: unknown): string {
367
+ if (typeof content === "string") return content;
368
+ if (!Array.isArray(content)) return "";
369
+ return content
370
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
371
+ .map((part) => part.text)
372
+ .join("\n");
373
+ }
374
+
375
+ // Translate a v2 bus event into the v1-shaped event the runtime consumes.
376
+ // v2's envelope is {type, data, location?} (payload in `data`); v1's was
377
+ // {type, properties}. The runtime's session event taxonomy also moved:
378
+ // - the idle signal is the execution lifecycle (session.execution.*);
379
+ // session.status exists in v2's schema but nothing publishes it
380
+ // - child-session errors surface as session.execution.failed, which v1
381
+ // called session.error
382
+ // - compaction completion is session.compaction.ended (v1: session.compacted)
383
+ // Events the runtime does not consume return null and are dropped.
384
+ // Translate one raw v2 bus event into the runtime's {type, properties}
385
+ // shape. Returns a LIST: a terminal execution event maps to the pair the
386
+ // runtime's v1-shaped flow expects (session.error to record the failure +
387
+ // session.status idle to deliver the verdict), because v2 publishes exactly
388
+ // ONE terminal event per busy period - Succeeded, Interrupted, or Failed,
389
+ // mutually exclusive (execution.ts "One terminal observation per busy
390
+ // period") - while v1's halt always publishes session.error followed by
391
+ // idle. Exported for tests: this is the seam where done/error alerts died
392
+ // silently when the mapping lagged the host's turn model.
393
+ export function translateEvent(located: { type: string; data?: any }): { type: string; properties: any }[] {
394
+ const data = located.data ?? {};
395
+ switch (located.type) {
396
+ case "session.created":
397
+ return [{ type: "session.created", properties: { info: { id: data.sessionID, parentID: data.parentID } } }];
398
+ case "session.deleted":
399
+ return [{ type: "session.deleted", properties: { info: { id: data.sessionID } } }];
400
+ case "session.execution.started":
401
+ return [{ type: "session.status", properties: { sessionID: data.sessionID, status: { type: "busy" } } }];
402
+ case "session.execution.succeeded":
403
+ return [{ type: "session.status", properties: { sessionID: data.sessionID, status: { type: "idle" } } }];
404
+ case "session.execution.interrupted": {
405
+ // Interrupts are never success: name the reason so the alert state
406
+ // machine can apply its silence rules. user = the human aborted
407
+ // (MessageAbortedError, v1's name for it - silent); shutdown = the
408
+ // process is leaving (nobody to notify); inactivity = the location-
409
+ // activity sweeper evicted a quiet directory while work was still
410
+ // executing (the one interrupt that genuinely needs a banner).
411
+ // Anything else (the schema reserves "superseded"; future reasons)
412
+ // maps to a silent catch-all name - a spurious banner costs more
413
+ // than a missed alert for an unknown lifecycle edge. The idle event
414
+ // still follows so the runtime's idle machinery (extraction trigger,
415
+ // chat auto-register) runs as before.
416
+ const reason = data.reason as string | undefined;
417
+ const name =
418
+ reason === "user"
419
+ ? "MessageAbortedError"
420
+ : reason === "shutdown"
421
+ ? "SessionShutdownError"
422
+ : reason === "inactivity"
423
+ ? "SessionInactivityError"
424
+ : "SessionInterruptedError";
425
+ return [
426
+ { type: "session.error", properties: { sessionID: data.sessionID, error: { name } } },
427
+ { type: "session.status", properties: { sessionID: data.sessionID, status: { type: "idle" } } },
428
+ ];
429
+ }
430
+ case "session.execution.failed":
431
+ // Failed is the idle signal for a failed busy period: emit the error
432
+ // AND the idle, or the recorded error would sit unverified until the
433
+ // next turn's busy cleared it (error alerts were deterministically
434
+ // dead this way).
435
+ return [
436
+ { type: "session.error", properties: { sessionID: data.sessionID, error: data.error ?? undefined } },
437
+ { type: "session.status", properties: { sessionID: data.sessionID, status: { type: "idle" } } },
438
+ ];
439
+ case "session.error":
440
+ return [
441
+ {
442
+ type: "session.error",
443
+ properties: { sessionID: data.sessionID, error: data.error ?? undefined },
444
+ },
445
+ ];
446
+ case "session.compaction.ended":
447
+ return [{ type: "session.compacted", properties: { sessionID: data.sessionID } }];
448
+ // Pause events: the LLM blocked on a human decision. V2's question and
449
+ // permission asks carry the request payload (id, sessionID, ...); the
450
+ // reply events drop it back to just the ids.
451
+ case "question.asked":
452
+ return [{ type: "question.asked", properties: { id: data.id, sessionID: data.sessionID } }];
453
+ case "question.replied":
454
+ case "question.rejected":
455
+ return [{ type: located.type, properties: { sessionID: data.sessionID, requestID: data.requestID } }];
456
+ case "permission.asked":
457
+ return [{ type: "permission.asked", properties: { id: data.id, sessionID: data.sessionID } }];
458
+ case "permission.replied":
459
+ return [{ type: "permission.replied", properties: { sessionID: data.sessionID, requestID: data.requestID } }];
460
+ default:
461
+ return [];
462
+ }
463
+ }
464
+
465
+ // The HostCapabilities implementation over the v2 promise context. Every
466
+ // operation the v2 surface lacks degrades as a no-op or null - the shared
467
+ // runtime's callers treat those results as best-effort and log, never crash.
468
+ function buildCapabilities(context: V2Context, worktree: string, childSessions: Set<string>): HostCapabilities {
469
+ // Typed against the SDK's own SessionDomain: the shape errors the
470
+ // original cast (as unknown as {...}) suppressed are exactly what the
471
+ // review caught (message.list and compact do not exist here; get takes
472
+ // {sessionID}). Let the typecheck keep holding that line.
473
+ const session = context.session;
474
+
475
+ return {
476
+ noReplyDelivery: false,
477
+ nativeCommands: true,
478
+ // The wake gate treats an unknown session as idle; the runtime's
479
+ // event-fed status map does the busy/retry gating on v2.
480
+ fetchStatuses: async () => ({}),
481
+ sessionCreate: async (input) => {
482
+ // v2's session API has no parentID: create a TOP-LEVEL session in the
483
+ // parent's project directory (the runtime sets the child mapping
484
+ // eagerly - no session.created event carries it on v2). The wrapper
485
+ // returns the created SessionInfo directly.
486
+ const result = await session.create({ location: { directory: worktree }, title: input.title });
487
+ const id = result?.id;
488
+ // A shape mismatch must fail into the runtime's extraction fallback
489
+ // (the nudge path), not silently corrupt the child-session maps.
490
+ if (!id) throw new Error(`v2 session.create returned no id: ${JSON.stringify(result)?.slice(0, 200)}`);
491
+ // Register for event forwarding: the child's events carry the project
492
+ // directory, which the pump's directory filter would otherwise drop.
493
+ childSessions.add(id);
494
+ return { id };
495
+ },
496
+ // No delete on the v2 SessionDomain: extraction child sessions are not
497
+ // cleaned up on v2 (documented gap; the bookkeeping maps still keep the
498
+ // nudge path consistent). Two user-visible consequences: the session
499
+ // picker accumulates one thatch-extraction entry per extraction, and
500
+ // "continue last session" (-c) logic that picks the newest top-level
501
+ // session will land in an extraction child after any session that
502
+ // triggered extraction, because the child is top-level and newer.
503
+ sessionDelete: async () => {},
504
+ promptSession: async (sessionID, body, _mode) => {
505
+ // v2's prompt endpoint takes text, not parts, and has no synthetic /
506
+ // noReply semantics. Nudge injections ride the prompt hook.
507
+ //
508
+ // Synthetic deliveries (watcher + chat wake nudges) route to v2's
509
+ // session.synthetic endpoint: a TUI-hidden message, matching v1's
510
+ // synthetic part. mode is v1-only (async = promptAsync vs sync =
511
+ // prompt); v2's prompt enqueues the message and returns while the
512
+ // model turn runs asynchronously, so the extraction background flag
513
+ // degrades: both modes enqueue the same way.
514
+ const text = body.parts.map((part) => part.text).join("\n\n");
515
+ if (!body.noReply && body.parts.length > 0 && body.parts.every((part) => part.synthetic)) {
516
+ await session.synthetic({ sessionID, text });
517
+ return;
518
+ }
519
+ await session.prompt({ sessionID, text });
520
+ },
521
+ sessionGet: async (id) => {
522
+ // SessionGetInput is {sessionID}, and the adapter decodes the input
523
+ // before the host call - any other key rejects. The wrapper returns
524
+ // the SessionInfo directly (the envelope is unwrapped).
525
+ return (await session.get({ sessionID: id })) ?? null;
526
+ },
527
+ sessionList: async () => null,
528
+ sessionMessages: async (id) => {
529
+ // The v2 promise domain has no message-list endpoint (no
530
+ // `message` accessor and no compact/remove in the SessionDomain Pick).
531
+ // `session.context` IS exposed and returns the session's message list
532
+ // (Array<SessionMessageInfo>); map into the v1 shape the wrap-up
533
+ // greenlight check reads.
534
+ const result = await session.context({ sessionID: id });
535
+ return mapSessionContextMessages(result);
536
+ },
537
+ showToast: async (_toast: ToastInput) => {
538
+ // No toast publish path reachable from the promise context (the
539
+ // tui.toast.show event has no producer surface here). A `tui`
540
+ // companion plugin entrypoint (ui.toast.show) is the known lift
541
+ // candidate - not yet wired. Degrades.
542
+ },
543
+ compactSession: async () => {
544
+ // No compaction trigger is reachable from the promise context: the
545
+ // SessionDomain Pick has no session.compact, and the built-in
546
+ // /compact is a TUI palette action calling the server endpoint
547
+ // directly (the server command registry only knows config/plugin-
548
+ // registered names). The compact wrap-up still runs its checklist
549
+ // and flush; only the automatic compaction itself degrades.
550
+ console.error("[thatch] v2: compaction trigger unavailable; wrap-up checklist ran, compact skipped");
551
+ },
552
+ exitHost: async () => {
553
+ // No TUI surface on v2; the exit wrap-up's checklist and flush run,
554
+ // the exit itself does not. When upstream restores a publish path
555
+ // (or the `tui` companion entrypoint's ui.tabs.close is wired),
556
+ // the right v2 target closes THIS session's tab, not app.exit - on
557
+ // a shared daemon app.exit would take down every tab. Tracked in
558
+ // anomalyco/opencode#50984.
559
+ },
560
+ };
561
+ }