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