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