@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.
- package/README.md +43 -4
- package/artifacts/skills/thatch-plan-refinement.md +2 -0
- package/bin/thatch +31 -16
- package/docs/dev/README.md +28 -6
- package/docs/dev/features/behavior-engine.md +3 -2
- package/docs/dev/features/cicd.md +1 -1
- package/docs/dev/features/compaction-recovery.md +1 -1
- package/docs/dev/features/cross-session-chat.md +4 -2
- package/docs/dev/features/extraction.md +10 -2
- package/docs/dev/features/hygiene.md +3 -1
- package/docs/dev/features/multi-host.md +4 -2
- package/docs/dev/features/notifications.md +121 -4
- package/docs/dev/features/nudge-pipeline.md +4 -2
- package/docs/dev/features/opencode-plugin.md +133 -0
- package/docs/dev/features/qa-system.md +43 -0
- package/docs/dev/features/repo-identity.md +1 -1
- package/docs/dev/features/session-lifecycle.md +5 -2
- package/docs/dev/features/watchers.md +12 -5
- package/docs/dev/gotchas.md +36 -3
- package/docs/dev/mcp-parity.md +4 -3
- package/docs/dev/setup-and-hooks.md +6 -1
- package/docs/plans/llm-alerts.md +287 -0
- package/docs/plans/prediction-consolidation.md +3 -3
- package/docs/user/README.md +7 -3
- package/docs/user/notifications.md +63 -4
- package/docs/user/setup.md +4 -0
- package/docs/user/watchers.md +15 -7
- package/package.json +3 -1
- package/src/alerts.ts +360 -0
- package/src/capabilities.ts +174 -0
- package/src/chat.ts +63 -5
- package/src/commands.ts +44 -5
- package/src/config.ts +32 -1
- package/src/db.ts +80 -0
- package/src/embeddings.ts +43 -0
- package/src/extraction.ts +124 -0
- package/src/index.ts +38 -1345
- package/src/opencode/v1.ts +54 -0
- package/src/opencode/v2.ts +561 -0
- package/src/os-args.ts +99 -0
- package/src/prompts.ts +77 -1
- package/src/runtime.ts +1815 -0
- package/src/seed-behaviors.ts +1 -1
- package/src/session-db.ts +32 -0
- package/src/tool-defs.ts +185 -19
- package/src/tools.ts +16 -35
- 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
|
+
}
|