@jeffober/thatch 0.2.4 → 0.2.6
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 +56 -26
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +48 -15
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +18 -10
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +4 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +194 -23
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +56 -15
- package/docs/dev/features/prediction-engine.md +71 -6
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +161 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +89 -0
- package/docs/dev/mcp-parity.md +5 -5
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +12 -9
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +29 -13
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +5 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +37 -10
- package/docs/user/cross-session-chat.md +76 -26
- package/docs/user/prediction-engine.md +23 -1
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +9 -7
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +358 -43
- package/src/commands.ts +123 -18
- package/src/db.ts +74 -3
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +101 -9
- package/src/opencode/tui-plugin.ts +90 -0
- package/src/opencode/v2.ts +440 -66
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +164 -29
- package/src/runtime.ts +298 -15
- package/src/scoring-engine.ts +40 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +231 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +6 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +454 -76
- package/src/tools.ts +2 -0
- package/src/watchers.ts +263 -26
- package/docs/plans/intuition-drives.md +0 -147
- package/docs/plans/llm-alerts.md +0 -287
- package/docs/plans/thatch-actions.md +0 -179
- package/docs/plans/worktree-deletion-recovery.md +0 -246
package/src/opencode/v2.ts
CHANGED
|
@@ -2,11 +2,15 @@ import type { Plugin } from "@opencode/plugin";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { Tool } from "@opencode/schema/tool";
|
|
4
4
|
import type { ToolContext as V2ToolContext } from "@opencode/plugin/promise/tool";
|
|
5
|
+
import type { ThatchDB } from "../db";
|
|
5
6
|
import type { HostCapabilities, PromptPart, ToastInput } from "../capabilities";
|
|
6
7
|
import { createRuntime } from "../runtime";
|
|
7
|
-
import { wrapUpCommandContent } from "../commands";
|
|
8
|
+
import { wrapUpCommandContent, wrapUpCommandDescription } from "../commands";
|
|
8
9
|
import { deriveTitle } from "../extraction";
|
|
9
|
-
import {
|
|
10
|
+
import { detectRepo, detectWorktreeKind } from "../git";
|
|
11
|
+
import { chatEnabled, loadConfig } from "../config";
|
|
12
|
+
import { EXIT_TAB_CLOSED_EVENT, SESSION_TAB_RPC, TAB_CLOSED_EVENT, TAB_OPENED_EVENT, TOAST_EVENT, buildSubordinatePrompt, tabClosedEventType, tabOpenedEventType } from "../session-tab-shared";
|
|
13
|
+
import { TOOL_DEFS, trimHostContext, type HostToolContext, type SessionTabCloseInput, type SessionTabHost, type SessionTabSpawnInput, type SessionTabSpawnResult } from "../tool-defs";
|
|
10
14
|
|
|
11
15
|
// The opencode v2 adapter (opencode 2.x, plugin API @opencode/plugin 2.x).
|
|
12
16
|
// Loaded only by v2 hosts - the dual entry (src/index.ts) lazy-imports this
|
|
@@ -25,9 +29,9 @@ import { TOOL_DEFS, trimHostContext, type HostToolContext } from "../tool-defs";
|
|
|
25
29
|
// array; the runtime's plain strings convert to {type: "text", text}
|
|
26
30
|
// parts at the boundary.
|
|
27
31
|
// - per-message nudges: the prompt hook computes the turn's injections;
|
|
28
|
-
// the
|
|
29
|
-
//
|
|
30
|
-
// Injections stay out of the stored prompt text.
|
|
32
|
+
// the context hook appends them to the outbound request's user message
|
|
33
|
+
// that matches the entry's prompt text (v2 wire Messages have no
|
|
34
|
+
// `parts` field). Injections stay out of the stored prompt text.
|
|
31
35
|
// - noReply deliveries (chat echoes, session-start reminder): v2's prompt
|
|
32
36
|
// endpoint cannot suppress the model turn, so the runtime gates them off
|
|
33
37
|
// via capabilities.noReplyDelivery.
|
|
@@ -39,17 +43,55 @@ import { TOOL_DEFS, trimHostContext, type HostToolContext } from "../tool-defs";
|
|
|
39
43
|
// {type, data, location?}. Filtered client-side the way the v1 host
|
|
40
44
|
// filters server-side: events resolve to a directory (their own, the
|
|
41
45
|
// session cache, or session.get) and drop when it is not ours.
|
|
42
|
-
// -
|
|
46
|
+
// - TUI reach: the ONLY server-to-TUI channel is the rpc event bridge - the
|
|
47
|
+
// session-tab rpc definition is registered at setup and its events reach
|
|
48
|
+
// the TUI CLI plugin (the package's ./tui entrypoint), which drives the
|
|
49
|
+
// tab strip (ui.tabs) and shows toasts (ui.toast). Keymap-command pushes
|
|
50
|
+
// still have no surface.
|
|
51
|
+
// - wrap-up actions: compact rides session.compact on the promise domain
|
|
52
|
+
// (upstream #52385, newer than the pinned dev types, so runtime-guarded);
|
|
53
|
+
// exit records the session's watcher deaths synchronously in the runtime
|
|
54
|
+
// and then emits the bridge's exit-tab-closed event, which the TUI plugin
|
|
55
|
+
// turns into closing the session's OWN tab (see exitHost in
|
|
56
|
+
// buildCapabilities).
|
|
57
|
+
// - toasts ride the bridge's toast event (TUI plugin's ui.toast.show);
|
|
58
|
+
// extraction-child cleanup rides session.remove (upstream #52387, also
|
|
59
|
+
// runtime-guarded). tui commands, session list endpoints:
|
|
43
60
|
// no v2 surface reachable from a plugin - degrades as no-op/null.
|
|
44
61
|
// fetchStatuses returns {} because the wake gate treats an unknown session
|
|
45
62
|
// as idle; the event-fed status map inside the runtime does the gating.
|
|
46
63
|
// Session MESSAGES are readable via session.context (mapped into the v1
|
|
47
|
-
// shape), so the wrap-up greenlight check works
|
|
48
|
-
// does not (no session.compact on the promise domain).
|
|
64
|
+
// shape), so the wrap-up greenlight check works.
|
|
49
65
|
|
|
50
66
|
type V2Context = Plugin.Context;
|
|
51
67
|
type V2Cleanup = Plugin.Cleanup;
|
|
52
68
|
|
|
69
|
+
// Both ends of the prompt-text binding must reconstruct text the same way:
|
|
70
|
+
// the prompt-session adapter joins outbound prompt parts with this helper,
|
|
71
|
+
// and the context hook joins a wire message's text parts with it before
|
|
72
|
+
// comparing against the stored prompt text. A separator mismatch would
|
|
73
|
+
// silently fail the equality and drop the turn's nudges.
|
|
74
|
+
function joinTextParts(parts: Array<{ text: string }>): string {
|
|
75
|
+
return parts.map((p) => p.text).join("\n\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reconstructs a wire message's text for the prompt-text binding. A string
|
|
79
|
+
// content is the common case; multi-part messages join with joinTextParts
|
|
80
|
+
// so the read side agrees with the write side's separator.
|
|
81
|
+
function messageText(message: { content: unknown }): string {
|
|
82
|
+
if (typeof message.content === "string") return message.content;
|
|
83
|
+
const parts = (Array.isArray(message.content) ? message.content : []).filter(
|
|
84
|
+
(p: any) => p?.type === "text",
|
|
85
|
+
);
|
|
86
|
+
return joinTextParts(parts);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The wrap-up exit's tab-close emitter, late-bound in setup (see the
|
|
90
|
+
// tuiBridge holder there). One shape, referenced at the holder, the
|
|
91
|
+
// buildCapabilities seam, and the assignment - a local alias so the payload
|
|
92
|
+
// shape has a single drift point in this file.
|
|
93
|
+
type ExitTabCloseEmitter = (data: { sessionID: string }) => Promise<unknown>;
|
|
94
|
+
|
|
53
95
|
// Raw setup function: the dual entry (src/index.ts) owns the plugin id and
|
|
54
96
|
// the merged default export; this adapter only implements the v2 setup.
|
|
55
97
|
export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
@@ -69,15 +111,27 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
69
111
|
// differs from this instance's directory whenever opencode is launched
|
|
70
112
|
// below the project root. Their events would never pass the directory
|
|
71
113
|
// filter, so they are forwarded by ID: sessionCreate records every child
|
|
72
|
-
// here, and the pump lets their events through.
|
|
114
|
+
// here, and the pump lets their events through. Ids are never removed
|
|
115
|
+
// (children are deleted, but their id never recurs and the set dies with
|
|
116
|
+
// this activation - only a long-lived instance grows it).
|
|
73
117
|
const childSessions = new Set<string>();
|
|
74
118
|
|
|
75
119
|
// Tool executions currently running against this instance's runtime (see
|
|
76
120
|
// the counter in the tool execute wrapper below).
|
|
77
121
|
let inflightTools = 0;
|
|
78
122
|
|
|
79
|
-
|
|
80
|
-
|
|
123
|
+
// TUI-bridge holder: the wrap-up exit emits the exit-tab-closed rpc event
|
|
124
|
+
// (the TUI plugin closes the session's own tab) and every runtime toast
|
|
125
|
+
// rides the toast event - both through this late-bound holder, because
|
|
126
|
+
// capabilities are built before the rpc registration exists. Assigned when
|
|
127
|
+
// the registration wires (below); undefined forever on an SDK floor
|
|
128
|
+
// without the rpc surface, which both emitters' guards degrade.
|
|
129
|
+
const tuiBridge: {
|
|
130
|
+
emitExitTabClose?: ExitTabCloseEmitter;
|
|
131
|
+
emitToast?: (toast: ToastInput) => Promise<unknown>;
|
|
132
|
+
} = {};
|
|
133
|
+
const capabilities = buildCapabilities(context, worktree, childSessions, tuiBridge);
|
|
134
|
+
const runtime = await createRuntime({ capabilities, directory, worktree, v2Tools: true });
|
|
81
135
|
// Seed the forwarding set from rehydrated child bookkeeping: after a
|
|
82
136
|
// reload, an in-flight extraction child's maps come back from the journal,
|
|
83
137
|
// but this NEW adapter instance's childSessions set starts empty - without
|
|
@@ -85,6 +139,51 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
85
139
|
// (the below-root launch case).
|
|
86
140
|
for (const id of runtime.childSessionIds()) childSessions.add(id);
|
|
87
141
|
|
|
142
|
+
// Session-tab surface: register the rpc definition once per activation and
|
|
143
|
+
// wire the host flow into the shared CoreContext (post-construction - the
|
|
144
|
+
// seam closes over both the runtime's db and the plugin context; see
|
|
145
|
+
// CoreContext.sessionTabHost in tool-defs.ts). The events are the ONLY
|
|
146
|
+
// server-to-TUI channel on v2: the TUI CLI plugin (the package's ./tui
|
|
147
|
+
// entrypoint) consumes them and calls ui.tabs.open. A TUI that is absent
|
|
148
|
+
// (headless run, still connecting) simply never acts on them.
|
|
149
|
+
//
|
|
150
|
+
// SDK-floor guard: the rpc surface is newer than some @opencode/plugin
|
|
151
|
+
// builds. An absent rpc domain must not crash setup - degrade by leaving
|
|
152
|
+
// the seam unwired; the session_tab tools refuse cleanly through it
|
|
153
|
+
// ("did not wire the session-tab surface") like any other unwired host.
|
|
154
|
+
const tabRegistration = typeof context.rpc?.register === "function"
|
|
155
|
+
? await context.rpc.register(SESSION_TAB_RPC, {})
|
|
156
|
+
: undefined;
|
|
157
|
+
if (tabRegistration) {
|
|
158
|
+
const emitTabOpened = (data: { sessionID: string; directory: string }) =>
|
|
159
|
+
tabRegistration.events.emit(TAB_OPENED_EVENT, data);
|
|
160
|
+
// The session_tab_close tool's confirmed-close emitter: the pump
|
|
161
|
+
// translates this event into the runtime's death path, so it is the
|
|
162
|
+
// tool's only route to that bookkeeping.
|
|
163
|
+
const emitTabClosed = (data: { sessionID: string; chatName: string | null }) =>
|
|
164
|
+
tabRegistration.events.emit(TAB_CLOSED_EVENT, data);
|
|
165
|
+
// The wrap-up exit's own emitter: TUI-only (the runtime records the
|
|
166
|
+
// exit's deaths synchronously before the action fires, so the pump
|
|
167
|
+
// deliberately never translates this one).
|
|
168
|
+
tuiBridge.emitExitTabClose = (data) => tabRegistration.events.emit(EXIT_TAB_CLOSED_EVENT, data);
|
|
169
|
+
// Every runtime toast rides this emitter (alerts, extraction metrics,
|
|
170
|
+
// chat registration, blocked wrap-ups) - TUI-only by nature. The spread
|
|
171
|
+
// widens the interface into a plain object type: the rpc emit's
|
|
172
|
+
// schema-inferred input wants an index signature, which interfaces
|
|
173
|
+
// never carry.
|
|
174
|
+
tuiBridge.emitToast = (toast) => tabRegistration.events.emit(TOAST_EVENT, { ...toast });
|
|
175
|
+
runtime.coreContext.sessionTabHost = buildSessionTabHost({
|
|
176
|
+
context,
|
|
177
|
+
directory,
|
|
178
|
+
db: runtime.coreContext.db,
|
|
179
|
+
emitTabOpened,
|
|
180
|
+
emitTabClosed,
|
|
181
|
+
debug: runtime.debug,
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
console.error("[thatch] opencode v2 rpc surface unavailable - session_tab tools will report unwired");
|
|
185
|
+
}
|
|
186
|
+
|
|
88
187
|
// Tool registration: the same CoreContext the v1 adapter feeds to
|
|
89
188
|
// createTools, registered through the v2 ToolEditor instead.
|
|
90
189
|
//
|
|
@@ -160,12 +259,34 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
160
259
|
// (the v1 contract); v2's system parts are { type: "text", text }
|
|
161
260
|
// objects, so convert at the boundary.
|
|
162
261
|
// - per-turn nudges: append the prompt hook's stored injections to the
|
|
163
|
-
// OUTBOUND request's
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
262
|
+
// OUTBOUND request's user message whose text matches the entry's
|
|
263
|
+
// stored prompt (see the binding check below). The stored user message
|
|
264
|
+
// stays clean (v1 delivered nudges as TUI-hidden synthetic parts; v2's
|
|
265
|
+
// prompt text IS the stored message), and the request is rebuilt per
|
|
266
|
+
// round trip, so injecting here reproduces v1's
|
|
267
|
+
// persistent-synthetic-part semantics: the model sees the nudges on
|
|
268
|
+
// every call of the turn.
|
|
269
|
+
// Per-turn nudge injections, keyed by session, then by the prompt text
|
|
270
|
+
// they were computed from. Each entry is bound to that text: the context
|
|
271
|
+
// hook injects an entry only when the request contains a user message
|
|
272
|
+
// whose text equals it. A bare sessionID-keyed entry that outlives its
|
|
273
|
+
// turn (the prompt hook missed or failed on a later message) replays a
|
|
274
|
+
// stale nudge block onto a message it was never computed for - deleted
|
|
275
|
+
// predictions surfaced as current with their old confidence labels (the
|
|
276
|
+
// October 2026 staleness report).
|
|
277
|
+
//
|
|
278
|
+
// The per-session set holds a small bounded number of entries (oldest
|
|
279
|
+
// evicted) so a prompt queued mid-turn keeps its own fresh entry instead
|
|
280
|
+
// of evicting the running turn's: the running turn's remaining rounds
|
|
281
|
+
// still match their own prompt, and the queued prompt's turn matches
|
|
282
|
+
// its own. A prompt whose nudge computation comes up empty clears the
|
|
283
|
+
// session's whole set - an entry left behind would match its own prompt
|
|
284
|
+
// text in the session HISTORY of later requests and replay a stale block
|
|
285
|
+
// there - so a queued prompt with no nudges drops the running turn's
|
|
286
|
+
// remaining-round block. That cost is smaller than the staleness it
|
|
287
|
+
// prevents; opencode-plugin.md discloses it.
|
|
288
|
+
const PENDING_INJECTION_CAP = 3;
|
|
289
|
+
const pendingInjections = new Map<string, Map<string, { promptText: string; injections: string[] }>>();
|
|
169
290
|
const registerSystem = await context.session.hook(
|
|
170
291
|
"context",
|
|
171
292
|
async (request: { sessionID: string; system: unknown[]; messages?: any[] }) => {
|
|
@@ -174,16 +295,51 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
174
295
|
for (const part of pushed) {
|
|
175
296
|
request.system.push(typeof part === "string" ? { type: "text", text: part } : part);
|
|
176
297
|
}
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
179
|
-
|
|
180
|
-
|
|
298
|
+
const pending = pendingInjections.get(request.sessionID);
|
|
299
|
+
if (!pending?.size) return;
|
|
300
|
+
// Binding check: a stored v2 user message IS its prompt text (the
|
|
301
|
+
// prompt hook's injections are computed from it), so text equality
|
|
302
|
+
// identifies the message an entry belongs to. Wire message ids are
|
|
303
|
+
// optional on the v2 Message schema and cannot be relied on here.
|
|
304
|
+
//
|
|
305
|
+
// The match searches the request's user messages for the LAST one
|
|
306
|
+
// whose joined text equals the entry's prompt - it is not simply the
|
|
307
|
+
// request's last user message. Sibling context hooks (opencode's plan
|
|
308
|
+
// plugin) splice their own user-role reminder into the request
|
|
309
|
+
// mid-turn: BEFORE the prompt when the request's last message is
|
|
310
|
+
// user-role, but AT THE TAIL when it is not (mid-turn rounds), which
|
|
311
|
+
// would make the reminder the last user message, fail the equality,
|
|
312
|
+
// and silently drop every nudge for the turn. Injecting into the
|
|
313
|
+
// prompt's own message is v1's persistent-synthetic-part semantics.
|
|
314
|
+
//
|
|
315
|
+
// Entries are tried newest first: when several are pending for the
|
|
316
|
+
// session, the latest prompt-hook run wins a text match.
|
|
317
|
+
//
|
|
318
|
+
// Text equality is the tightest binding available here, not an
|
|
319
|
+
// airtight one: an identical prompt re-submitted after a missed
|
|
320
|
+
// prompt hook still matches and replays. The next prompt-hook run
|
|
321
|
+
// that fires is the recovery.
|
|
322
|
+
let target: any;
|
|
323
|
+
let matched: { promptText: string; injections: string[] } | undefined;
|
|
324
|
+
for (const entry of [...pending.values()].reverse()) {
|
|
325
|
+
target = [...(request.messages ?? [])]
|
|
326
|
+
.reverse()
|
|
327
|
+
.find((m) => m?.role === "user" && messageText(m) === entry.promptText);
|
|
328
|
+
if (target) {
|
|
329
|
+
matched = entry;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!matched) {
|
|
334
|
+
runtime.debug("v2:hook:context", `session=${request.sessionID} no user message matches a stored prompt; skipping injections`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
181
337
|
// v2's wire Message carries parts in `content: Array<ContentPart>` -
|
|
182
338
|
// it has no `parts` field, so injecting there would write a stray
|
|
183
339
|
// property the provider formatter never reads (silent nudge loss).
|
|
184
|
-
const content = Array.isArray(
|
|
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}`);
|
|
340
|
+
const content = Array.isArray(target.content) ? target.content : (target.content = []);
|
|
341
|
+
for (const text of matched.injections) content.push({ type: "text", text });
|
|
342
|
+
runtime.debug("v2:hook:context", `session=${request.sessionID} injected=${matched.injections.length} messages=${request.messages?.length}`);
|
|
187
343
|
},
|
|
188
344
|
);
|
|
189
345
|
|
|
@@ -202,12 +358,29 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
202
358
|
);
|
|
203
359
|
} catch (err) {
|
|
204
360
|
console.error(`[thatch] v2 prompt hook failed: ${err}`);
|
|
361
|
+
// The hook owns no fresh entry for this prompt; a retained one
|
|
362
|
+
// belongs to a superseded prompt and would replay on an identical
|
|
363
|
+
// re-submission. Drop the session's whole set.
|
|
364
|
+
pendingInjections.delete(request.sessionID);
|
|
205
365
|
return;
|
|
206
366
|
}
|
|
207
367
|
const injections = parts.slice(1).map((part) => part.text);
|
|
208
368
|
if (injections.length > 0) runtime.debug("v2:hook:prompt", `session=${request.sessionID} injections=${injections.length}`);
|
|
209
|
-
if (injections.length > 0)
|
|
210
|
-
|
|
369
|
+
if (injections.length > 0) {
|
|
370
|
+
const entries = pendingInjections.get(request.sessionID) ?? new Map();
|
|
371
|
+
if (!entries.has(request.prompt.text) && entries.size >= PENDING_INJECTION_CAP) {
|
|
372
|
+
// Bounded set: evict the oldest entry (insertion order).
|
|
373
|
+
entries.delete(entries.keys().next().value!);
|
|
374
|
+
}
|
|
375
|
+
entries.set(request.prompt.text, { promptText: request.prompt.text, injections });
|
|
376
|
+
pendingInjections.set(request.sessionID, entries);
|
|
377
|
+
} else {
|
|
378
|
+
// A prompt that produced no nudges clears the session's whole set:
|
|
379
|
+
// an entry left behind would match its own prompt text in the
|
|
380
|
+
// session history of later requests and replay a stale block there.
|
|
381
|
+
// See the pendingInjections comment above for the disclosed cost.
|
|
382
|
+
pendingInjections.delete(request.sessionID);
|
|
383
|
+
}
|
|
211
384
|
},
|
|
212
385
|
);
|
|
213
386
|
|
|
@@ -239,10 +412,9 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
239
412
|
for (const kind of ["compact", "exit"] as const) {
|
|
240
413
|
editor.add({
|
|
241
414
|
name: `thatch/${kind}`,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
: "Flush thatch persistence, check for loose ends, then exit opencode if clear",
|
|
415
|
+
// Shared with the v1 command file's frontmatter so the two hosts'
|
|
416
|
+
// menu descriptions cannot drift.
|
|
417
|
+
description: wrapUpCommandDescription(kind),
|
|
246
418
|
// The user's typed arguments ride invocation.prompt (a
|
|
247
419
|
// PromptInput.Prompt with a text field). v1's host expanded
|
|
248
420
|
// $ARGUMENTS from the command file; v2's session.prompt does no
|
|
@@ -301,11 +473,44 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
301
473
|
location?: { directory?: string };
|
|
302
474
|
};
|
|
303
475
|
const data = located.data ?? {};
|
|
304
|
-
|
|
305
|
-
|
|
476
|
+
// rpc events (the session-tab bridge) stamp the PUBLISHING
|
|
477
|
+
// instance's location, not the session's - caching a tab event's
|
|
478
|
+
// location would pin the subordinate's sessionID to the
|
|
479
|
+
// coordinator's directory, and its later location-less execution
|
|
480
|
+
// events would route to the wrong instance (or be dropped by the
|
|
481
|
+
// right one). The tab-opened payload's directory IS authoritative
|
|
482
|
+
// (the subordinate's final, post-move directory) and is cached
|
|
483
|
+
// explicitly below.
|
|
484
|
+
const fromRpc = located.type.startsWith("rpc.");
|
|
485
|
+
if (!fromRpc && data.sessionID && located.location?.directory) sessionDirs.set(data.sessionID, located.location.directory);
|
|
486
|
+
if (!fromRpc && data.sessionID && data.location?.directory) sessionDirs.set(data.sessionID, data.location.directory);
|
|
487
|
+
if (located.type === tabOpenedEventType) {
|
|
488
|
+
const opened = (data ?? {}) as { sessionID?: string; directory?: string };
|
|
489
|
+
if (opened.sessionID && opened.directory) sessionDirs.set(opened.sessionID, opened.directory);
|
|
490
|
+
}
|
|
306
491
|
let eventDir = located.location?.directory ?? (data.sessionID ? sessionDirs.get(data.sessionID) : undefined);
|
|
307
492
|
if (!eventDir && data.sessionID) eventDir = await resolveSessionDir(data.sessionID);
|
|
308
493
|
runtime.debug("v2:pump", `event ${located.type} dir=${eventDir} self=${directory}`);
|
|
494
|
+
if (located.type === tabClosedEventType) {
|
|
495
|
+
// The session-tab tool's confirmed tab close: translated here
|
|
496
|
+
// rather than in translateEvent because the payload shape is
|
|
497
|
+
// tab-domain, and the death path is runtime-domain. Routed by
|
|
498
|
+
// the CLOSED session's own location, never the event's: the rpc
|
|
499
|
+
// event carries the publishing (coordinator) instance's
|
|
500
|
+
// directory, while the watchers and status bookkeeping live in
|
|
501
|
+
// the instance that owns the session (its post-move location).
|
|
502
|
+
// A later located event repairs the cache; resolve live when
|
|
503
|
+
// nothing is cached.
|
|
504
|
+
const closed = (data ?? {}) as { sessionID?: string; chatName?: string | null };
|
|
505
|
+
if (closed.sessionID) {
|
|
506
|
+
let closedDir = sessionDirs.get(closed.sessionID);
|
|
507
|
+
if (!closedDir) closedDir = await resolveSessionDir(closed.sessionID);
|
|
508
|
+
if (eventMatchesInstance(closedDir, closed.sessionID, directory, childSessions)) {
|
|
509
|
+
await runtime.onEvent({ type: "session.tab_closed", properties: { sessionID: closed.sessionID, chatName: closed.chatName ?? null } });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
309
514
|
if (eventMatchesInstance(eventDir, data.sessionID, directory, childSessions)) {
|
|
310
515
|
for (const translated of translateEvent(located)) {
|
|
311
516
|
await runtime.onEvent(translated);
|
|
@@ -346,6 +551,7 @@ export async function setup(context: V2Context): Promise<V2Cleanup | void> {
|
|
|
346
551
|
registerPrompt.dispose();
|
|
347
552
|
registerCompaction.dispose();
|
|
348
553
|
registerCommands.dispose();
|
|
554
|
+
tabRegistration?.dispose();
|
|
349
555
|
await runtime.dispose();
|
|
350
556
|
};
|
|
351
557
|
}
|
|
@@ -506,14 +712,161 @@ export function translateEvent(located: { type: string; data?: any }): { type: s
|
|
|
506
712
|
}
|
|
507
713
|
}
|
|
508
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The session-tab host flow - everything behind the CoreContext.sessionTabHost
|
|
717
|
+
* seam, host-executed end to end (the model supplies only the args; see the
|
|
718
|
+
* host-owned-handshake rule: no ids cross a prompt boundary). Order is
|
|
719
|
+
* load-bearing (docs/dev/features/session-tabs.md, "How it works"):
|
|
720
|
+
*
|
|
721
|
+
* create -> move -> register subordinate -> emit tab-opened -> prompt
|
|
722
|
+
*
|
|
723
|
+
* (The calling session's chat name is resolved by the tool definition - it
|
|
724
|
+
* has the db handle and the session context.)
|
|
725
|
+
*
|
|
726
|
+
* The emit runs AFTER the move: the event routes by THIS instance's location
|
|
727
|
+
* (the coordinator's directory) regardless of the session's directory, so
|
|
728
|
+
* tab placement is identical either way, and a failed move leaves no
|
|
729
|
+
* stranded open tab. The move targets an idle session, so its default
|
|
730
|
+
* "steer" delivery applies immediately - the subordinate's whole first turn
|
|
731
|
+
* runs in its final directory.
|
|
732
|
+
*
|
|
733
|
+
* The subordinate is chat-registered deliberately - participant semantics,
|
|
734
|
+
* like an explicit chat_register; it is NEVER marked machinery, and its
|
|
735
|
+
* title cannot collide with the machinery title (validateTitle rejects it).
|
|
736
|
+
*/
|
|
737
|
+
function buildSessionTabHost(input: {
|
|
738
|
+
context: V2Context;
|
|
739
|
+
/** This instance's directory - the coordinator session's cwd. */
|
|
740
|
+
directory: string;
|
|
741
|
+
db: ThatchDB;
|
|
742
|
+
emitTabOpened: (data: { sessionID: string; directory: string }) => Promise<unknown>;
|
|
743
|
+
emitTabClosed: (data: { sessionID: string; chatName: string | null }) => Promise<unknown>;
|
|
744
|
+
debug(tag: string, message: string): void;
|
|
745
|
+
}): SessionTabHost {
|
|
746
|
+
return {
|
|
747
|
+
directory: input.directory,
|
|
748
|
+
spawnSubordinate: async (spec: SessionTabSpawnInput): Promise<SessionTabSpawnResult> => {
|
|
749
|
+
const worktreeDir = typeof spec.worktree === "string" ? spec.worktree : undefined;
|
|
750
|
+
const isWorktreeFlow = worktreeDir !== undefined;
|
|
751
|
+
const createDirectory = isWorktreeFlow ? input.directory : spec.directory;
|
|
752
|
+
const finalDirectory = worktreeDir ?? spec.directory;
|
|
753
|
+
// Failure narration for the error below: a mid-flow throw (move
|
|
754
|
+
// refused, prompt endpoint down) must leave the calling model the
|
|
755
|
+
// session id and what already happened, or the created session is
|
|
756
|
+
// stranded with no way to name it - there is no session delete on
|
|
757
|
+
// the v2 surface, so rollback is not an option either.
|
|
758
|
+
const steps: string[] = [];
|
|
759
|
+
let sessionID: string | undefined;
|
|
760
|
+
let chatName: string | null = null;
|
|
761
|
+
try {
|
|
762
|
+
const created = await input.context.session.create({
|
|
763
|
+
title: spec.title,
|
|
764
|
+
metadata: {
|
|
765
|
+
thatch: {
|
|
766
|
+
coordinatedBy: spec.coordinatorChatName,
|
|
767
|
+
coordinatorSessionID: spec.coordinatorSessionID,
|
|
768
|
+
...(worktreeDir !== undefined ? { worktree: worktreeDir } : {}),
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
location: { directory: createDirectory },
|
|
772
|
+
});
|
|
773
|
+
sessionID = created.id;
|
|
774
|
+
steps.push(`created session ${sessionID ?? "(no id returned)"}`);
|
|
775
|
+
if (worktreeDir !== undefined) {
|
|
776
|
+
await input.context.session.move({ sessionID, directory: worktreeDir });
|
|
777
|
+
steps.push(`moved to ${worktreeDir}`);
|
|
778
|
+
}
|
|
779
|
+
// Pre-register the subordinate AFTER the move, so the roster row
|
|
780
|
+
// records the session's FINAL directory (right repo slug, right
|
|
781
|
+
// worktree kind) and a failed move leaves no row behind. Deliberate
|
|
782
|
+
// participant semantics - like an explicit chat_register, never marked
|
|
783
|
+
// machinery - so the response carries its chat name and the
|
|
784
|
+
// coordinator's first chat_send resolves; the later auto-register
|
|
785
|
+
// converges (same id, same name). Gated on the chat-enabled config
|
|
786
|
+
// like every other registration path: with chat off, the tool
|
|
787
|
+
// response reports no chat name and advertises no supervision.
|
|
788
|
+
const repo = await detectRepo(finalDirectory);
|
|
789
|
+
const registered = chatEnabled(loadConfig().config)
|
|
790
|
+
? input.db.registerChatSession(
|
|
791
|
+
sessionID,
|
|
792
|
+
repo,
|
|
793
|
+
null,
|
|
794
|
+
"opencode",
|
|
795
|
+
null,
|
|
796
|
+
detectWorktreeKind(finalDirectory),
|
|
797
|
+
)
|
|
798
|
+
: { ok: false as const, error: "chat disabled in config" };
|
|
799
|
+
if (registered.ok) chatName = registered.name;
|
|
800
|
+
steps.push(registered.ok ? `registered in chat as ${registered.name}` : "chat registration skipped");
|
|
801
|
+
input.debug(
|
|
802
|
+
"session-tab",
|
|
803
|
+
`created ${sessionID} dir=${finalDirectory} repo=${repo} chat=${registered.ok ? registered.name : registered.error}`,
|
|
804
|
+
);
|
|
805
|
+
// The payload's directory is the subordinate's FINAL (post-move)
|
|
806
|
+
// directory, not this instance's: the TUI guard routes by the event
|
|
807
|
+
// envelope's location, while the pump caches this field so the
|
|
808
|
+
// subordinate's later location-less execution events resolve to the
|
|
809
|
+
// instance that owns it.
|
|
810
|
+
await input.emitTabOpened({ sessionID, directory: finalDirectory });
|
|
811
|
+
steps.push("tab-opened emitted");
|
|
812
|
+
await input.context.session.prompt({
|
|
813
|
+
sessionID,
|
|
814
|
+
text: buildSubordinatePrompt(spec.coordinatorChatName, spec.prompt),
|
|
815
|
+
});
|
|
816
|
+
steps.push("task prompt delivered");
|
|
817
|
+
return { sessionID, chatName, directory: finalDirectory };
|
|
818
|
+
} catch (err) {
|
|
819
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
820
|
+
throw new Error(
|
|
821
|
+
`session_tab spawn failed: ${reason}. Completed steps: ${steps.length > 0 ? steps.join("; ") : "none"}.` +
|
|
822
|
+
(sessionID
|
|
823
|
+
? ` The session ${sessionID} was created and still exists - name it in your report so it can be recovered (sessions list, or thatch_session_tab_close${chatName ? ` by chat name ${chatName}` : ""}).`
|
|
824
|
+
: " No session was created."),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
closeTab: async (spec: SessionTabCloseInput): Promise<void> => {
|
|
829
|
+
await input.emitTabClosed({ sessionID: spec.sessionID, chatName: spec.chatName });
|
|
830
|
+
input.debug("session-tab", `tab-close emitted for ${spec.sessionID} (${spec.chatName ?? "unnamed"})`);
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Runtime guard for promise-domain members newer than the pinned dev types
|
|
836
|
+
// (compact - upstream #52385; remove - #52387): a typed call fails tsc
|
|
837
|
+
// against the pin, and an unguarded cast crashes on an older host. Returns
|
|
838
|
+
// the member as a callable, or undefined on the floor - the CALLER owns the
|
|
839
|
+
// degrade (a log for user-requested actions like compact; silence for
|
|
840
|
+
// best-effort ones like the child cleanup). The rpc registration guard in
|
|
841
|
+
// setup is the same idiom at domain granularity and stays inline: it gates
|
|
842
|
+
// a whole registration, not one call.
|
|
843
|
+
export function optionalDomainMethod(
|
|
844
|
+
domain: object,
|
|
845
|
+
method: string,
|
|
846
|
+
): ((input: unknown) => Promise<unknown>) | undefined {
|
|
847
|
+
const fn = (domain as Record<string, unknown>)[method];
|
|
848
|
+
return typeof fn === "function" ? (fn as (input: unknown) => Promise<unknown>) : undefined;
|
|
849
|
+
}
|
|
850
|
+
|
|
509
851
|
// The HostCapabilities implementation over the v2 promise context. Every
|
|
510
852
|
// operation the v2 surface lacks degrades as a no-op or null - the shared
|
|
511
853
|
// runtime's callers treat those results as best-effort and log, never crash.
|
|
512
|
-
function buildCapabilities(
|
|
854
|
+
function buildCapabilities(
|
|
855
|
+
context: V2Context,
|
|
856
|
+
worktree: string,
|
|
857
|
+
childSessions: Set<string>,
|
|
858
|
+
tuiBridge: {
|
|
859
|
+
emitExitTabClose?: ExitTabCloseEmitter;
|
|
860
|
+
emitToast?: (toast: ToastInput) => Promise<unknown>;
|
|
861
|
+
},
|
|
862
|
+
): HostCapabilities {
|
|
513
863
|
// Typed against the SDK's own SessionDomain: the shape errors the
|
|
514
864
|
// original cast (as unknown as {...}) suppressed are exactly what the
|
|
515
|
-
// review caught (message.list
|
|
516
|
-
// {sessionID}).
|
|
865
|
+
// review caught (message.list does not exist here; get takes
|
|
866
|
+
// {sessionID}). compact and remove DO exist on newer SDK builds but not
|
|
867
|
+
// on the pinned dev types - both calls go through optionalDomainMethod
|
|
868
|
+
// (above). Let the typecheck keep holding that line for the pinned
|
|
869
|
+
// surface.
|
|
517
870
|
const session = context.session;
|
|
518
871
|
|
|
519
872
|
return {
|
|
@@ -538,14 +891,20 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
|
|
|
538
891
|
childSessions.add(id);
|
|
539
892
|
return { id };
|
|
540
893
|
},
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
894
|
+
// Extraction child cleanup: upstream exposed session.remove on the
|
|
895
|
+
// promise domain (#52387), the same era as compact - guarded the same
|
|
896
|
+
// way (optionalDomainMethod; the pinned dev types predate the surface).
|
|
897
|
+
// An older host keeps the documented gap: the session picker accumulates
|
|
898
|
+
// one thatch-extraction entry per extraction, and "continue last session"
|
|
899
|
+
// (-c) logic that picks the newest top-level session lands in an
|
|
900
|
+
// extraction child after any session that triggered extraction,
|
|
901
|
+
// because the child is top-level and newer.
|
|
902
|
+
sessionDelete: async (id: string) => {
|
|
903
|
+
const remove = optionalDomainMethod(session, "remove");
|
|
904
|
+
// Best-effort by the caller's contract: the floor degrades silently.
|
|
905
|
+
if (!remove) return;
|
|
906
|
+
await remove({ sessionID: id });
|
|
907
|
+
},
|
|
549
908
|
promptSession: async (sessionID, body, _mode) => {
|
|
550
909
|
// v2's prompt endpoint takes text, not parts, and has no synthetic /
|
|
551
910
|
// noReply semantics. Nudge injections ride the prompt hook.
|
|
@@ -556,7 +915,7 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
|
|
|
556
915
|
// prompt); v2's prompt enqueues the message and returns while the
|
|
557
916
|
// model turn runs asynchronously, so the extraction background flag
|
|
558
917
|
// degrades: both modes enqueue the same way.
|
|
559
|
-
const text = body.parts
|
|
918
|
+
const text = joinTextParts(body.parts);
|
|
560
919
|
if (!body.noReply && body.parts.length > 0 && body.parts.every((part) => part.synthetic)) {
|
|
561
920
|
await session.synthetic({ sessionID, text });
|
|
562
921
|
return;
|
|
@@ -571,36 +930,51 @@ function buildCapabilities(context: V2Context, worktree: string, childSessions:
|
|
|
571
930
|
},
|
|
572
931
|
sessionList: async () => null,
|
|
573
932
|
sessionMessages: async (id) => {
|
|
574
|
-
// The
|
|
575
|
-
//
|
|
933
|
+
// The pinned promise domain has no message-list endpoint (no `message`
|
|
934
|
+
// accessor; compact/remove exist on newer SDK builds but not here).
|
|
576
935
|
// `session.context` IS exposed and returns the session's message list
|
|
577
936
|
// (Array<SessionMessageInfo>); map into the v1 shape the wrap-up
|
|
578
937
|
// greenlight check reads.
|
|
579
938
|
const result = await session.context({ sessionID: id });
|
|
580
939
|
return mapSessionContextMessages(result);
|
|
581
940
|
},
|
|
582
|
-
showToast: async (
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
941
|
+
showToast: async (toast: ToastInput) => {
|
|
942
|
+
// The toast rpc event is the only TUI feedback channel on v2 - the
|
|
943
|
+
// TUI plugin's ui.toast.show. Best-effort by contract (the runtime's
|
|
944
|
+
// call sites catch-and-ignore): a TUI-less environment (headless run,
|
|
945
|
+
// still connecting) or an rpc-less SDK floor simply never shows it,
|
|
946
|
+
// and that is the correct silent degrade, not an error.
|
|
947
|
+
const emit = tuiBridge.emitToast;
|
|
948
|
+
if (!emit) return;
|
|
949
|
+
await emit(toast);
|
|
587
950
|
},
|
|
588
|
-
compactSession: async () => {
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
951
|
+
compactSession: async (sessionID: string) => {
|
|
952
|
+
// Upstream exposed session.compact on the promise SessionDomain
|
|
953
|
+
// (#52385) - the same core compaction the built-in /compact runs.
|
|
954
|
+
// The pinned dev types predate that surface, so the call goes through
|
|
955
|
+
// optionalDomainMethod: an older host degrades to a logged no-op (the
|
|
956
|
+
// wrap-up checklist and flush still ran).
|
|
957
|
+
const compact = optionalDomainMethod(session, "compact");
|
|
958
|
+
if (!compact) {
|
|
959
|
+
console.error("[thatch] v2: compaction trigger unavailable; wrap-up checklist ran, compact skipped");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
await compact({ sessionID });
|
|
596
963
|
},
|
|
597
|
-
exitHost: async () => {
|
|
598
|
-
//
|
|
599
|
-
// the
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
964
|
+
exitHost: async (sessionID: string) => {
|
|
965
|
+
// Tab-scoped close (the close anomalyco/opencode#50984 asks for):
|
|
966
|
+
// the daemon hosts every tab, so app.exit would take down unrelated
|
|
967
|
+
// sessions. This emit is TUI-only - the runtime records the
|
|
968
|
+
// session's watcher deaths synchronously BEFORE calling here, and
|
|
969
|
+
// the pump deliberately never translates this event (a second
|
|
970
|
+
// death pass would erase the durable death row the first one
|
|
971
|
+
// wrote).
|
|
972
|
+
const emit = tuiBridge.emitExitTabClose;
|
|
973
|
+
if (!emit) {
|
|
974
|
+
console.error("[thatch] v2: rpc surface unavailable; wrap-up checklist ran, tab close skipped");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await emit({ sessionID });
|
|
604
978
|
},
|
|
605
979
|
};
|
|
606
980
|
}
|