@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/runtime.ts
ADDED
|
@@ -0,0 +1,1815 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createDebugLog } from "./debug";
|
|
3
|
+
import { ThatchDB, repoPathCache } from "./db";
|
|
4
|
+
import { SharedModelPool } from "./embeddings";
|
|
5
|
+
import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
|
|
6
|
+
import { buildCoreContext } from "./tool-defs";
|
|
7
|
+
import {
|
|
8
|
+
systemPrompt,
|
|
9
|
+
compactionContext,
|
|
10
|
+
sessionStartReminder,
|
|
11
|
+
recallNudge,
|
|
12
|
+
extractionNudge,
|
|
13
|
+
extractionDirectPrompt,
|
|
14
|
+
predictionNudge,
|
|
15
|
+
behaviorNudge,
|
|
16
|
+
versionWarningNudge,
|
|
17
|
+
skewWarningText,
|
|
18
|
+
type NudgeMatch,
|
|
19
|
+
} from "./prompts";
|
|
20
|
+
import { ExtractionPipeline, isMetaToolName, unwrapExecuteThatchCalls, type ToolInteraction } from "./extraction";
|
|
21
|
+
import { createAlerts, deriveRoundShape, isNoWorkTool, replyRequestID } from "./alerts";
|
|
22
|
+
import { mostRecentTopLevelSessionId } from "./session-db";
|
|
23
|
+
import type { CoreContext } from "./tool-defs";
|
|
24
|
+
import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
|
|
25
|
+
import { installOpencodeCommands, opencodeActionCommandDefs, removeWrapUpCommandFiles, COMPACT_READY_TOKEN, EXIT_READY_TOKEN } from "./commands";
|
|
26
|
+
import { hygieneReport } from "./hygiene";
|
|
27
|
+
import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
28
|
+
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
29
|
+
import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher } from "./watchers";
|
|
30
|
+
import { watcherNotificationNudge, watcherRearmNotice, watcherDeathNotice, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
31
|
+
import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle } from "./chat";
|
|
32
|
+
import { chatEnabled, chatAutoRegister, loadConfig, alertMode, notificationDefaults } from "./config";
|
|
33
|
+
import { sendNotification } from "./notify";
|
|
34
|
+
import { osProcessArgs, startupSessionId, continuesLastSessionFromArgv, continuesLastSessionId } from "./os-args";
|
|
35
|
+
import type { HostCapabilities } from "./capabilities";
|
|
36
|
+
import pkg from "../package.json";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Shared plugin runtime - host-agnostic across opencode v1 and v2
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// Minimum cosine score for the prompt-aware recall nudge. Lower than
|
|
43
|
+
// findDuplicates' 0.85 (near-dupes) because "relates to" is a weaker signal
|
|
44
|
+
// than "duplicate." Tunable via THATCH_RECALL_THRESHOLD.
|
|
45
|
+
const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55");
|
|
46
|
+
|
|
47
|
+
// How long a dormant watcher journal row (a restart kept it because its
|
|
48
|
+
// session may yet be resumed) survives past its newest definition's TTL
|
|
49
|
+
// before aging out. The TTL alone would kill the row the morning after an
|
|
50
|
+
// overnight watch; the grace keeps recovery possible for sessions the user
|
|
51
|
+
// returns to days later.
|
|
52
|
+
const DORMANT_WATCHER_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when every definition in a dormant watcher row is past its TTL plus
|
|
56
|
+
* the dormant grace period - the row is a corpse no resume can usefully
|
|
57
|
+
* re-arm. An empty or malformed payload counts as stale.
|
|
58
|
+
*/
|
|
59
|
+
function dormantWatchersStale(defs: Watcher[]): boolean {
|
|
60
|
+
if (!Array.isArray(defs) || defs.length === 0) return true;
|
|
61
|
+
const lastExpiry = Math.max(...defs.map((w) => Number((w as { expiresAt?: number })?.expiresAt ?? 0)));
|
|
62
|
+
return Date.now() > lastExpiry + DORMANT_WATCHER_GRACE_MS;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Process-wide pool for the shared embedding models (see SharedModelPool). */
|
|
66
|
+
const sharedModels = new SharedModelPool();
|
|
67
|
+
|
|
68
|
+
// Prompts shorter than this skip the recall nudge - trivially short prompts
|
|
69
|
+
// like "yes" or "ok" match too broadly to be useful.
|
|
70
|
+
const MIN_PROMPT_LEN = 10;
|
|
71
|
+
|
|
72
|
+
// Minimum cosine score for the prediction auto-fire. Higher than the
|
|
73
|
+
// recall nudge (0.55) because surfacing a user-preference nudge is more
|
|
74
|
+
// disruptive than surfacing a memory -- the agent may act on it or
|
|
75
|
+
// surface it to the user. 0.60 cuts noise in dense embedding spaces
|
|
76
|
+
// while still catching genuinely related contexts.
|
|
77
|
+
const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD ?? "0.60");
|
|
78
|
+
|
|
79
|
+
// Same threshold for behavior auto-fire. Same rationale: surfacing a
|
|
80
|
+
// self-discipline rule is disruptive and should only fire when the
|
|
81
|
+
// situation genuinely matches.
|
|
82
|
+
const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
|
|
83
|
+
|
|
84
|
+
// Wrap-up command registry: command name as opencode derives it from the
|
|
85
|
+
// installed file path (~/.config/opencode/command/thatch/<name>.md), the
|
|
86
|
+
// greenlight token the command instructs the model to end its response with,
|
|
87
|
+
// and which TUI action the greenlight triggers.
|
|
88
|
+
const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit" }> = {
|
|
89
|
+
"thatch/compact": { token: COMPACT_READY_TOKEN, kind: "compact" },
|
|
90
|
+
"thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// The handler-set shape both host adapters consume. The methods correspond
|
|
94
|
+
// to the v1 hook surface (plus the per-call coreContext and the
|
|
95
|
+
// host-agnostic debug log); the v2 adapter routes its domain hooks into
|
|
96
|
+
// these same methods. Part and message shapes are kept structural (the v1
|
|
97
|
+
// bodies already treat parts loosely) so neither adapter has to convert
|
|
98
|
+
// host SDK types.
|
|
99
|
+
export interface ThatchRuntime {
|
|
100
|
+
/** The shared per-call context both adapters register TOOL_DEFS with. */
|
|
101
|
+
coreContext: CoreContext;
|
|
102
|
+
/** The host-agnostic diagnostic log (THATCH_DEBUG; no-op when unset). */
|
|
103
|
+
debug(tag: string, message: string): void;
|
|
104
|
+
/**
|
|
105
|
+
* Session IDs with live child bookkeeping (in-flight extraction children).
|
|
106
|
+
* The v2 adapter seeds its event-forwarding set from this after a reload:
|
|
107
|
+
* the rehydrated child maps must be visible to the pump's directory
|
|
108
|
+
* filter, or a below-root launch drops the child's events again.
|
|
109
|
+
*/
|
|
110
|
+
childSessionIds(): string[];
|
|
111
|
+
onSystemTransform(output: { system: string[] }): Promise<void>;
|
|
112
|
+
onSessionCompacting(input: { sessionID: string }, output: { context: string[] }): Promise<void>;
|
|
113
|
+
onCompactionAutocontinue(input: { sessionID: string }): Promise<void>;
|
|
114
|
+
onToolExecuteAfter(
|
|
115
|
+
input: { tool: string; sessionID: string; args?: unknown },
|
|
116
|
+
output: { title: string; output: string },
|
|
117
|
+
): Promise<void>;
|
|
118
|
+
onCommandExecuteBefore(input: { command: string; sessionID: string }): Promise<void>;
|
|
119
|
+
onChatMessage(
|
|
120
|
+
input: { sessionID: string; messageID?: string },
|
|
121
|
+
output: { parts: any[]; message: { id: string } },
|
|
122
|
+
): Promise<void>;
|
|
123
|
+
onEvent(event: any): Promise<void>;
|
|
124
|
+
dispose(): Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function createRuntime(input: {
|
|
128
|
+
capabilities: HostCapabilities;
|
|
129
|
+
directory: string;
|
|
130
|
+
worktree: string;
|
|
131
|
+
}): Promise<ThatchRuntime> {
|
|
132
|
+
const { capabilities: caps, directory, worktree } = input;
|
|
133
|
+
// The opencode server's cwd is wherever the server happened to start;
|
|
134
|
+
// `worktree` is the project this plugin instance actually serves.
|
|
135
|
+
// `directory` is the session's own directory - on resume after the
|
|
136
|
+
// worktree was deleted, opencode reassigns worktree to "/" (it walks up
|
|
137
|
+
// from the missing dir, finds no .git, and boots the global project) but
|
|
138
|
+
// still forwards the original path as directory, which is the only input
|
|
139
|
+
// the repo_paths cache can recover identity from.
|
|
140
|
+
const home = process.env.HOME ?? "/tmp";
|
|
141
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
142
|
+
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
143
|
+
const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
|
|
144
|
+
|
|
145
|
+
// The DB must exist before identity detection: detectRepo consults the
|
|
146
|
+
// repo_paths cache when the session directory is gone.
|
|
147
|
+
const db = new ThatchDB(dbPath);
|
|
148
|
+
const repoCache = repoPathCache(db);
|
|
149
|
+
const repo = await detectRepo(directory ?? worktree, repoCache);
|
|
150
|
+
|
|
151
|
+
// Whether cross-session chat is on (chat.enabled, default on). Read once
|
|
152
|
+
// at init: it gates the chat poller and the prompt's chat section. The
|
|
153
|
+
// chat tools re-read the file per call, so a toggle takes effect there
|
|
154
|
+
// without a restart.
|
|
155
|
+
const chatOn = chatEnabled(loadConfig(dbPath).config);
|
|
156
|
+
|
|
157
|
+
const debug = createDebugLog(dbPath);
|
|
158
|
+
|
|
159
|
+
// Shared embedding model: refcounted per db path (see SharedModelPool).
|
|
160
|
+
const model = sharedModels.acquire(dbPath, modelName);
|
|
161
|
+
const releaseModel = () => sharedModels.release(dbPath);
|
|
162
|
+
|
|
163
|
+
// Extraction buffer, journaled to runtime_state so a v2 plugin reload
|
|
164
|
+
// (same process, graph rebuilt) can rehydrate it.
|
|
165
|
+
const extraction = new ExtractionPipeline((kind, sessionID, value) => {
|
|
166
|
+
if (value === undefined) db.runtimeStateDelete(kind, sessionID);
|
|
167
|
+
else db.runtimeStatePut(kind, sessionID, value, directory);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Seed default behaviors into the global store on first run, and
|
|
171
|
+
// update them when their content changes across releases. Idempotent:
|
|
172
|
+
// behaviors are matched by key stamp, not by cosine similarity, so
|
|
173
|
+
// user-codified behaviors are never overwritten. See seed-behaviors.ts
|
|
174
|
+
// for the full mechanism.
|
|
175
|
+
await seedDefaultBehaviors(db, model);
|
|
176
|
+
|
|
177
|
+
// Start background npm update polling. The checker caches the latest
|
|
178
|
+
// version in memory and to a file. Never blocks the plugin or tool calls.
|
|
179
|
+
// The opencode plugin path has no hook/server split, so version skew
|
|
180
|
+
// between hooks and server is not a concern here. But two opencode
|
|
181
|
+
// sessions on different versions sharing one DB can cause schema/data
|
|
182
|
+
// corruption. The npm update nudge covers the "please update" case.
|
|
183
|
+
// The on-disk version check (readOnDiskVersion) catches the case where
|
|
184
|
+
// the user ran `npm update` but hasn't restarted opencode yet.
|
|
185
|
+
startVersionChecker(dbPath);
|
|
186
|
+
|
|
187
|
+
// The running version, frozen at module load time. Compared against the
|
|
188
|
+
// on-disk package.json version on each chat.message to detect post-upgrade
|
|
189
|
+
// non-restart.
|
|
190
|
+
const runningVersion = pkg.version;
|
|
191
|
+
|
|
192
|
+
// Latest observed opencode session status per session ("busy" | "idle" |
|
|
193
|
+
// "retry"). Keys enter from session.status events and leave only in
|
|
194
|
+
// session.deleted (which shrinks the chat poller's hosted set). Readers:
|
|
195
|
+
// the watcher registry's and the chat poller's canDeliver gates
|
|
196
|
+
// (proactive prompts only fire into idle sessions, never into a running
|
|
197
|
+
// turn), and the chat poller's hostedSessions set (the sessions this
|
|
198
|
+
// process may deliver chat mail to - the map's keys).
|
|
199
|
+
const sessionStatus = new Map<string, string>();
|
|
200
|
+
|
|
201
|
+
// LLM alerts (docs/plans/llm-alerts.md): notify when the LLM pauses for
|
|
202
|
+
// human input or finishes a round of real work. Plain in-memory state -
|
|
203
|
+
// a v2 reload mid-turn loses that turn's transition and stays silent
|
|
204
|
+
// (accepted). Delivery reuses the notify_user dispatcher; the alerts
|
|
205
|
+
// config section picks the channel per event, defaulting to banner. The
|
|
206
|
+
// config is re-read per delivery so a config_set (or hand edit) applies
|
|
207
|
+
// without a restart, the same contract the chat tools' per-call config
|
|
208
|
+
// read gives the chat section.
|
|
209
|
+
const alerts = createAlerts({
|
|
210
|
+
config: () => loadConfig(dbPath).config,
|
|
211
|
+
roundShape: async (sessionID) =>
|
|
212
|
+
deriveRoundShape(await caps.sessionMessages(sessionID)),
|
|
213
|
+
sessionTitle: async (sessionID) => (await caps.sessionGet(sessionID))?.title,
|
|
214
|
+
notify: async ({ kind, title, message }) => {
|
|
215
|
+
const defaults = notificationDefaults();
|
|
216
|
+
await sendNotification({
|
|
217
|
+
message,
|
|
218
|
+
title,
|
|
219
|
+
source: title,
|
|
220
|
+
// The machine owns the per-event "none" gate and never calls here
|
|
221
|
+
// with one - this cast records that contract.
|
|
222
|
+
channel: alertMode(loadConfig(dbPath).config, kind) as "both" | "banner" | "voice",
|
|
223
|
+
voice: defaults.voice,
|
|
224
|
+
sound: defaults.sound,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The proactive-prompt gate, shared by both delivery registries (watchers
|
|
230
|
+
// and chat). Two layers, in cost order: the compacting set and the
|
|
231
|
+
// event-fed sessionStatus map answer cheaply, and a final check verifies
|
|
232
|
+
// against the server's live status - the map is only as fresh as the
|
|
233
|
+
// event stream, and a stale "idle" turns a wake into mid-turn context
|
|
234
|
+
// injection (observed live: a session read mail mid-burst, contradicted
|
|
235
|
+
// its own in-flight plan, and looped).
|
|
236
|
+
const canPromptSession = createWakeGate({
|
|
237
|
+
isCompacting: (sessionID) => compacting.has(sessionID),
|
|
238
|
+
mappedStatus: (sessionID) => sessionStatus.get(sessionID),
|
|
239
|
+
fetchStatuses: async () => {
|
|
240
|
+
return (await caps.fetchStatuses()) ?? {};
|
|
241
|
+
},
|
|
242
|
+
onStatusError: (sessionID, err) => {
|
|
243
|
+
console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
248
|
+
// and branch watching, plus local command watches). Live state is
|
|
249
|
+
// process-scoped; definitions are journalled for reload rehydration and
|
|
250
|
+
// restart dormancy. See src/watchers.ts for the rationale.
|
|
251
|
+
//
|
|
252
|
+
// Delivery prompts the session with a synthetic part - the same mechanism
|
|
253
|
+
// opencode uses for background task completions - so a watched event
|
|
254
|
+
// triggers a model turn even when the user is away. Events carry pointer
|
|
255
|
+
// data plus machine status (check conclusions, exit codes); the model
|
|
256
|
+
// fetches logs and further details itself with gh.
|
|
257
|
+
// Watched-command runner: resolves the spawn cwd per poll. When the
|
|
258
|
+
// project directory is deleted mid-watch (worktree merged and cleaned up),
|
|
259
|
+
// the poll falls back to the repo_paths cache's main checkout instead of
|
|
260
|
+
// spinning ENOENT errors until the TTL. The wrapper logs the fallback
|
|
261
|
+
// once per dead directory - per-cycle error logging is the existing
|
|
262
|
+
// failure mode and it spams.
|
|
263
|
+
const watchedCommandRunner = withCwdFallback(runWatchedCommand, (cwd) => resolveSpawnCwd(cwd, repoCache), (from, to) => {
|
|
264
|
+
console.error(`[thatch] project directory ${from} deleted; watcher falling back to main checkout ${to}`);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const watchers = new WatcherRegistry({
|
|
268
|
+
// Journal watcher definitions so a v2 plugin reload (same process) can
|
|
269
|
+
// re-arm them; the registry journals after every membership change.
|
|
270
|
+
journal: (sessionID, sessionWatchers) => {
|
|
271
|
+
if (sessionWatchers.length === 0) db.runtimeStateDelete("watchers", sessionID);
|
|
272
|
+
else db.runtimeStatePut("watchers", sessionID, sessionWatchers, directory);
|
|
273
|
+
},
|
|
274
|
+
deliver: async (sessionID, events) => {
|
|
275
|
+
// Events carry their watch's target label from the registry, so the
|
|
276
|
+
// notification header is correct for every source (PRs, branches,
|
|
277
|
+
// commands, CI events whose URLs would not parse).
|
|
278
|
+
await caps.promptSession(
|
|
279
|
+
sessionID,
|
|
280
|
+
{
|
|
281
|
+
parts: [
|
|
282
|
+
{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
"async",
|
|
286
|
+
);
|
|
287
|
+
// Toast: the notification part is TUI-hidden, so without this the
|
|
288
|
+
// user watching the session sees the model wake up with no visible
|
|
289
|
+
// cause. Announce what fired. Failed checks/workflows surface as a
|
|
290
|
+
// warning variant; routine events as info. Best-effort - the TUI may
|
|
291
|
+
// not be connected (headless mode).
|
|
292
|
+
const failed = events.some((e) => e.summary.includes("failure"));
|
|
293
|
+
const more = events.length > 1 ? ` +${events.length - 1} more` : "";
|
|
294
|
+
try {
|
|
295
|
+
await caps.showToast({
|
|
296
|
+
message: `⏰ ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
|
|
297
|
+
variant: failed ? "warning" : "info",
|
|
298
|
+
duration: 5000,
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
// TUI may not be connected. Best-effort.
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
canDeliver: canPromptSession,
|
|
305
|
+
ghRunner: ghApiRun,
|
|
306
|
+
commandRunner: watchedCommandRunner,
|
|
307
|
+
});
|
|
308
|
+
// gh presence decides whether watch_create and watch_branch_create work;
|
|
309
|
+
// checked lazily by the tools, but log once at startup so misconfiguration
|
|
310
|
+
// is visible in debug logs. watch_command_create needs only bash, not gh.
|
|
311
|
+
void ghAvailable().then((ok) => {
|
|
312
|
+
if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
|
|
313
|
+
});
|
|
314
|
+
watchers.start();
|
|
315
|
+
|
|
316
|
+
// The session this harness was launched to resume, resolved at startup:
|
|
317
|
+
// `-s <id>` names it on the command line, `-c` means "most recent in this
|
|
318
|
+
// directory" and resolves through the SDK. Either way the poller hosts it
|
|
319
|
+
// from the first beat, so the heartbeat and delivery are live before the
|
|
320
|
+
// user's first prompt.
|
|
321
|
+
// Startup-resumed chat sessions. A Set (not a scalar): one plugin
|
|
322
|
+
// instance can serve several sessions - shared-server tabs in the same
|
|
323
|
+
// directory each resolve their own -c/-s target.
|
|
324
|
+
const resumedSessions = new Set<string>();
|
|
325
|
+
|
|
326
|
+
// Chat names assigned by the startup resume paths (-s and -c) wait here
|
|
327
|
+
// until the session's first message or idle event, which toast them. At
|
|
328
|
+
// plugin-init time the TUI is not yet connected, so an immediate toast
|
|
329
|
+
// would be dropped silently.
|
|
330
|
+
const chatStartupNames = new Map<string, string>();
|
|
331
|
+
|
|
332
|
+
const chatPoller = new ChatPoller({
|
|
333
|
+
store: db,
|
|
334
|
+
hostedSessions: () => {
|
|
335
|
+
const hosted = hostedSessionIds({
|
|
336
|
+
statusKeys: sessionStatus.keys(),
|
|
337
|
+
resumedSessions: resumedSessions,
|
|
338
|
+
rehostedSessions: rehostedSessions,
|
|
339
|
+
exclude: childToParent.keys(),
|
|
340
|
+
});
|
|
341
|
+
// Journal the set so the NEXT reload (same pid + directory) re-hosts
|
|
342
|
+
// these sessions and the poller can wake them with pending mail
|
|
343
|
+
// instead of waiting for the user to type.
|
|
344
|
+
db.runtimeStatePut("hosted", directory, hosted, directory);
|
|
345
|
+
return hosted;
|
|
346
|
+
},
|
|
347
|
+
deliver: async (sessionID, senders, count) => {
|
|
348
|
+
await caps.promptSession(
|
|
349
|
+
sessionID,
|
|
350
|
+
{
|
|
351
|
+
parts: [{ type: "text", text: chatNotificationNudge(senders, count), synthetic: true }],
|
|
352
|
+
},
|
|
353
|
+
"async",
|
|
354
|
+
);
|
|
355
|
+
try {
|
|
356
|
+
await caps.showToast({
|
|
357
|
+
message: `💬 chat: ${count} unread from ${senders.join(", ")}`,
|
|
358
|
+
variant: "info",
|
|
359
|
+
duration: 5000,
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
// TUI may not be connected. Best-effort.
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
canDeliver: canPromptSession,
|
|
366
|
+
});
|
|
367
|
+
// With chat disabled the tools refuse, so the poller would only heartbeat
|
|
368
|
+
// sessions and burn wake budget against a feature the user turned off.
|
|
369
|
+
if (chatOn) chatPoller.start();
|
|
370
|
+
|
|
371
|
+
// Startup registration: `opencode -s <id>` continues a session whose
|
|
372
|
+
// directory row may predate this process (its harness died, or the
|
|
373
|
+
// machine restarted); `opencode -c` continues the most recent session in
|
|
374
|
+
// this directory. The framework passes no session id to plugins and
|
|
375
|
+
// resuming fires no events, so the command line is the source (see
|
|
376
|
+
// startupSessionId). Registering here RECLAIMS the row - the name is
|
|
377
|
+
// owned by the session id and never changes - and refreshes its
|
|
378
|
+
// heartbeat: synchronously for -s (local SQLite, it cannot fail on a
|
|
379
|
+
// server that is still starting); for -c after an SDK list call resolves
|
|
380
|
+
// the target. The async tail (title fetch, asleep-mail delivery) is
|
|
381
|
+
// best-effort - the poller retries whatever it misses, and the first
|
|
382
|
+
// idle converges the topic.
|
|
383
|
+
const reclaimTail = (sessionID: string) => {
|
|
384
|
+
void (async () => {
|
|
385
|
+
try {
|
|
386
|
+
const data = await caps.sessionGet(sessionID);
|
|
387
|
+
const title = data?.title ?? "";
|
|
388
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
389
|
+
if (topic) db.refreshChatTopic(sessionID, topic);
|
|
390
|
+
} catch {
|
|
391
|
+
// Server may still be starting; the topic converges on the
|
|
392
|
+
// session's first idle either way.
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
// Asleep-mail: deliver what queued while this session was away,
|
|
396
|
+
// through the normal path (gate, nudge, toast, stamps).
|
|
397
|
+
await chatPoller.deliverPending();
|
|
398
|
+
} catch {
|
|
399
|
+
// The poller cycle retries delivery; nothing to do here.
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
402
|
+
};
|
|
403
|
+
const osArgs = osProcessArgs();
|
|
404
|
+
const startupSession = startupSessionId(osArgs);
|
|
405
|
+
const continueLast = !startupSession && (continuesLastSessionFromArgv(process.argv) || continuesLastSessionFromArgv(osArgs));
|
|
406
|
+
// The session whose journalled runtime state a restart inherits: -s names
|
|
407
|
+
// it on the command line; -c means "most recent top-level session in this
|
|
408
|
+
// directory", resolved straight from the session database because the
|
|
409
|
+
// rehydration loop below is synchronous and the v2 plugin context has no
|
|
410
|
+
// session.list for the SDK route. Resolving it HERE is the point -
|
|
411
|
+
// after the loop, the inherited rows are already pruned.
|
|
412
|
+
const continueTarget = continueLast && startupSession === undefined
|
|
413
|
+
? mostRecentTopLevelSessionId(directory ?? worktree)
|
|
414
|
+
: null;
|
|
415
|
+
const inheritSession = startupSession ?? continueTarget ?? undefined;
|
|
416
|
+
const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
|
|
417
|
+
const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
|
|
418
|
+
debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} continue=${continueLast} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
|
|
419
|
+
if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
|
|
420
|
+
resumedSessions.add(startupSession);
|
|
421
|
+
const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
422
|
+
debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
423
|
+
if (res.ok) {
|
|
424
|
+
chatStartupNames.set(startupSession, res.name);
|
|
425
|
+
reclaimTail(startupSession);
|
|
426
|
+
}
|
|
427
|
+
} else if (chatOn && autoRegisterOn && continueLast) {
|
|
428
|
+
void (async () => {
|
|
429
|
+
try {
|
|
430
|
+
const data = await caps.sessionList();
|
|
431
|
+
const target = continuesLastSessionId(data ?? []);
|
|
432
|
+
debug("chat:startup", `-c resolved: ${target ?? "none"}`);
|
|
433
|
+
if (!target || db.hasChatLeaveTombstone(target)) return;
|
|
434
|
+
resumedSessions.add(target);
|
|
435
|
+
const res = db.registerChatSession(target, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
436
|
+
debug("chat:startup", `registration for ${target}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
437
|
+
if (res.ok) {
|
|
438
|
+
chatStartupNames.set(target, res.name);
|
|
439
|
+
reclaimTail(target);
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
console.error(`[thatch] chat -c startup registration failed: ${err}`);
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
449
|
+
// a session is in this set - the agent can't call tools during summary
|
|
450
|
+
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
451
|
+
// error. Cleared by experimental.compaction.autocontinue (success), the
|
|
452
|
+
// session.compacted event (redundant belt-and-suspenders), or chat.message
|
|
453
|
+
// itself when a non-compaction message arrives (compaction failure fallback
|
|
454
|
+
// - autocontinue never fired, so the next real user message clears the
|
|
455
|
+
// stale flag). If all three somehow miss, the flag leaks (graceful
|
|
456
|
+
// degradation: nudges stay off for that session, but no crash).
|
|
457
|
+
const compacting = new Set<string>();
|
|
458
|
+
|
|
459
|
+
// Per-session count of consecutive extraction nudges delivered without any
|
|
460
|
+
// memory_remember call in between. Drives nudge escalation: the agent gets
|
|
461
|
+
// a couple of polite chances, then the tone shifts to directive, then to
|
|
462
|
+
// all-caps shouting. Reset to 0 whenever the agent writes a memory.
|
|
463
|
+
const missedNudges = new Map<string, number>();
|
|
464
|
+
|
|
465
|
+
// Parent-child session mapping for cross-session buffer drain. opencode
|
|
466
|
+
// creates real child sessions with their own IDs for sub-agents; without
|
|
467
|
+
// this map, a memory_remember call in a child (e.g. a background
|
|
468
|
+
// fact-extractor task) drains the child's empty buffer but leaves the
|
|
469
|
+
// parent's buffer untouched, causing the nudge to replay indefinitely.
|
|
470
|
+
// Populated from session.created events that carry a parentID.
|
|
471
|
+
const childToParent = new Map<string, string>();
|
|
472
|
+
|
|
473
|
+
// Snapshot of the parent's buffer at the moment each child was dispatched.
|
|
474
|
+
// When the child writes a memory, consumeSnapshot drains only these entries
|
|
475
|
+
// (by reference identity), preserving interleaved-turn entries that arrived
|
|
476
|
+
// while the sub-agent was running. Without this, the child's memory_remember
|
|
477
|
+
// would drain the parent's ENTIRE buffer - silently dropping facts from
|
|
478
|
+
// any tool calls the parent made concurrently with the sub-agent.
|
|
479
|
+
const parentSnapshots = new Map<string, ToolInteraction[]>();
|
|
480
|
+
|
|
481
|
+
// Parent sessions with an active direct-extraction child. When the parent
|
|
482
|
+
// goes idle with pending tool interactions, the plugin creates a child
|
|
483
|
+
// session and prompts it directly through the host's prompt capability
|
|
484
|
+
// instead of injecting a nudge into the next user message. This set
|
|
485
|
+
// suppresses the nudge path
|
|
486
|
+
// while the child runs, and prevents re-triggering if the parent goes idle
|
|
487
|
+
// again before the child finishes. Cleared when the child goes idle, errors,
|
|
488
|
+
// or is deleted. If direct extraction fails, the set is cleared so the nudge
|
|
489
|
+
// path takes over as a fallback on the next chat.message.
|
|
490
|
+
const extracting = new Set<string>();
|
|
491
|
+
|
|
492
|
+
// Child session IDs created by triggerExtraction (direct extraction only).
|
|
493
|
+
// The child idle handler uses this to distinguish plugin-created extraction
|
|
494
|
+
// children from task-dispatched sub-agents (code review specialists, the
|
|
495
|
+
// nudge-path fact-extractor, any model-dispatched task). Only extraction
|
|
496
|
+
// children get the full cleanup: buffer drain, session deletion, toast.
|
|
497
|
+
// Non-extraction children get the old behavior (completeAccepted +
|
|
498
|
+
// missedNudges.reset) so their sessions are not deleted out from under the
|
|
499
|
+
// task tool that dispatched them.
|
|
500
|
+
const extractionChildren = new Set<string>();
|
|
501
|
+
|
|
502
|
+
// Per-child-session extraction metrics for the toast notification. When the
|
|
503
|
+
// child session goes idle, these counts are formatted into a toast that
|
|
504
|
+
// shows the user thatch is working without polluting the conversation.
|
|
505
|
+
// Keyed by child session ID. Cleaned up on child idle, error, or deletion.
|
|
506
|
+
const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
|
|
507
|
+
|
|
508
|
+
// Journal the child bookkeeping (parent link, buffer snapshot, metrics) so
|
|
509
|
+
// a v2 plugin reload can rehydrate an in-flight extraction instead of
|
|
510
|
+
// orphaning it. A child with no parent entry journals a delete.
|
|
511
|
+
const journalChild = (childId: string) => {
|
|
512
|
+
const parentID = childToParent.get(childId);
|
|
513
|
+
if (!parentID) {
|
|
514
|
+
db.runtimeStateDelete("child", childId);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
db.runtimeStatePut("child", childId, {
|
|
518
|
+
parentID,
|
|
519
|
+
snapshot: parentSnapshots.get(childId) ?? [],
|
|
520
|
+
metrics: childMetrics.get(childId) ?? { new: 0, updated: 0, deleted: 0 },
|
|
521
|
+
}, directory);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// Wrap-up slash commands (/thatch/compact, /thatch/exit) awaiting their
|
|
525
|
+
// greenlight check. command.execute.before marks the session when one of
|
|
526
|
+
// these commands runs; when the session next goes idle, the plugin reads
|
|
527
|
+
// the final assistant message for the ready token (the greenlight) and
|
|
528
|
+
// triggers the TUI action. Token absence means the model listed blockers
|
|
529
|
+
// instead, so nothing fires beyond a toast. Cleared on resolution or on
|
|
530
|
+
// session deletion.
|
|
531
|
+
const pendingWrapUp = new Map<string, { token: string; kind: "compact" | "exit" }>();
|
|
532
|
+
|
|
533
|
+
// Rehydrate persisted runtime state (docs/dev/features/opencode-plugin.md,
|
|
534
|
+
// the runtime_state row).
|
|
535
|
+
// Rows are instance-scoped (kind+session, tagged with the writer's pid and
|
|
536
|
+
// location directory):
|
|
537
|
+
// - same pid, same directory: a v2 plugin reload rebuilt THIS instance's
|
|
538
|
+
// graph - rehydrate everything.
|
|
539
|
+
// - same pid, other directory: a live sibling instance's rows (one serve
|
|
540
|
+
// hosts one instance per location) - leave them alone; hydrating them
|
|
541
|
+
// would double-poll watchers and re-deliver another instance's state.
|
|
542
|
+
// - foreign pid: a process restart, whose sessions died with their
|
|
543
|
+
// harness. Only a startup-resumed session (-s/-c) in this directory
|
|
544
|
+
// inherits recovery-safe state; everything else is pruned.
|
|
545
|
+
const rehydratedSessions = new Set<string>();
|
|
546
|
+
// Extraction children restored by a same-pid reload (childId -> parentId).
|
|
547
|
+
// Their idle event may have fired during the reload window and been lost;
|
|
548
|
+
// reconciled after setup (see reconcileRestoredChildren).
|
|
549
|
+
const restoredChildren = new Map<string, string>();
|
|
550
|
+
// Sessions this instance hosted, restored from the journal on a reload
|
|
551
|
+
// (same pid + same directory). Written through by the poller's hosted-set
|
|
552
|
+
// read, so the next reload picks up the latest set.
|
|
553
|
+
const rehostedSessions = new Set<string>();
|
|
554
|
+
const rehydrated: Record<string, number> = {};
|
|
555
|
+
for (const row of db.runtimeStateAll()) {
|
|
556
|
+
const samePid = row.pid === process.pid;
|
|
557
|
+
const ownDirectory = row.directory === directory;
|
|
558
|
+
const isStartup = inheritSession !== undefined && row.sessionID === inheritSession;
|
|
559
|
+
if (samePid && !ownDirectory) continue; // live sibling - untouched
|
|
560
|
+
if (!samePid && !ownDirectory) continue; // another location's instance owns these rows - its own restart cleans them
|
|
561
|
+
if (!samePid && !isStartup) {
|
|
562
|
+
// Watcher definitions survive a restart as DORMANT rows instead of
|
|
563
|
+
// being pruned: the defining session's harness died with the old
|
|
564
|
+
// process, but the session itself is resumable (session picker, v2
|
|
565
|
+
// tab reopen), and resuming it re-arms the watches through
|
|
566
|
+
// scanDormantWatchers. Rows whose every definition outlived the
|
|
567
|
+
// watcher TTL plus the dormant grace period age out here instead -
|
|
568
|
+
// corpses of sessions that will probably never return.
|
|
569
|
+
if (row.kind === "watchers" && !dormantWatchersStale((row.value as Watcher[] | null) ?? [])) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (row.kind === "buffer") {
|
|
576
|
+
extraction.hydrate(row.value as ToolInteraction[], []);
|
|
577
|
+
} else if (row.kind === "accepted") {
|
|
578
|
+
// On a RESTART the extractor child died with the old process - there
|
|
579
|
+
// is nobody left to complete the accepted queue, and the first
|
|
580
|
+
// unrelated completion signal would silently drop it. Requeue as
|
|
581
|
+
// pending instead: the resumed session's next nudge honestly
|
|
582
|
+
// re-extracts it. On a reload the child may still be running and
|
|
583
|
+
// stays accepted.
|
|
584
|
+
if (!samePid) extraction.hydrate(row.value as ToolInteraction[], []);
|
|
585
|
+
else extraction.hydrate([], row.value as ToolInteraction[]);
|
|
586
|
+
} else if (row.kind === "watchers") {
|
|
587
|
+
watchers.hydrate((row.value ?? []) as Watcher[]);
|
|
588
|
+
} else if (row.kind === "hosted") {
|
|
589
|
+
// Rehydrate only on a same-process reload. A foreign-pid hosted row
|
|
590
|
+
// (restart) would re-host sessions whose harnesses died - dropping
|
|
591
|
+
// it is the point: the resumed session re-hosts itself through the
|
|
592
|
+
// event stream instead.
|
|
593
|
+
if (!samePid) {
|
|
594
|
+
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
for (const id of (row.value ?? []) as string[]) rehostedSessions.add(id);
|
|
598
|
+
} else if (row.kind === "child") {
|
|
599
|
+
const rec = row.value as { parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
|
|
600
|
+
if (samePid && rec?.parentID) {
|
|
601
|
+
// Reload: the child may still be running - restore its live
|
|
602
|
+
// bookkeeping so its idle event finds the maps populated.
|
|
603
|
+
childToParent.set(row.sessionID, rec.parentID);
|
|
604
|
+
parentSnapshots.set(row.sessionID, rec.snapshot ?? []);
|
|
605
|
+
extractionChildren.add(row.sessionID);
|
|
606
|
+
extracting.add(rec.parentID);
|
|
607
|
+
if (rec.metrics) childMetrics.set(row.sessionID, rec.metrics);
|
|
608
|
+
restoredChildren.set(row.sessionID, rec.parentID);
|
|
609
|
+
} else {
|
|
610
|
+
// Restart: the child ran in the dead process and no execution event
|
|
611
|
+
// will ever arrive for it. Recover the snapshot as plain pending
|
|
612
|
+
// entries (extraction stays available) and drop the record - never
|
|
613
|
+
// restore `extracting`, which would suppress both extraction paths
|
|
614
|
+
// for the resumed session forever. The guard tests the child's
|
|
615
|
+
// PARENT against the inherited session, not the row's own session
|
|
616
|
+
// id: child rows are keyed by child id, so an isStartup-style check
|
|
617
|
+
// could never hold and the snapshot recovery was unreachable.
|
|
618
|
+
if (inheritSession !== undefined && rec?.parentID === inheritSession && Array.isArray(rec.snapshot)) {
|
|
619
|
+
for (const interaction of rec.snapshot) extraction.push(interaction);
|
|
620
|
+
}
|
|
621
|
+
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
} else if (row.kind === "wrapup") {
|
|
625
|
+
if (!samePid) {
|
|
626
|
+
// An armed wrap-up inherited across a restart could auto-fire
|
|
627
|
+
// compact/exit on the resumed session's first idle if the final
|
|
628
|
+
// message already carries the token. Too dangerous to inherit.
|
|
629
|
+
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
pendingWrapUp.set(row.sessionID, row.value as { token: string; kind: "compact" | "exit" });
|
|
633
|
+
} else {
|
|
634
|
+
continue; // unknown kind - leave it for a future version
|
|
635
|
+
}
|
|
636
|
+
rehydrated[row.kind] = (rehydrated[row.kind] ?? 0) + 1;
|
|
637
|
+
rehydratedSessions.add(row.sessionID);
|
|
638
|
+
}
|
|
639
|
+
if (Object.keys(rehydrated).length > 0) {
|
|
640
|
+
const counts = Object.entries(rehydrated)
|
|
641
|
+
.map(([kind, n]) => `${kind}=${n}`)
|
|
642
|
+
.join(" ");
|
|
643
|
+
debug("runtime:rehydrate", `restored ${counts}`);
|
|
644
|
+
// Ping the restored sessions directly: the reload/restart happened
|
|
645
|
+
// outside their event stream, so without this the sessions would not
|
|
646
|
+
// know the plugin is back. Synthetic + noReply on v1 renders a visible
|
|
647
|
+
// bubble and starts no model turn. On v2 there is no turn-free delivery
|
|
648
|
+
// (a noReply body falls through to session.prompt: a real message and a
|
|
649
|
+
// model turn in every restored session on every file save), so v2 skips
|
|
650
|
+
// the notice entirely - the re-hosted poller's mail delivery is the
|
|
651
|
+
// wake there.
|
|
652
|
+
if (chatOn && caps.noReplyDelivery) {
|
|
653
|
+
for (const sessionID of rehydratedSessions) {
|
|
654
|
+
void caps
|
|
655
|
+
.promptSession(
|
|
656
|
+
sessionID,
|
|
657
|
+
{
|
|
658
|
+
parts: [
|
|
659
|
+
{
|
|
660
|
+
type: "text",
|
|
661
|
+
text: "[thatch] plugin reloaded - persisted state restored (extraction buffers, watchers, wrap-ups). No action needed.",
|
|
662
|
+
synthetic: true,
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
noReply: true,
|
|
666
|
+
},
|
|
667
|
+
"async",
|
|
668
|
+
)
|
|
669
|
+
.catch(() => {
|
|
670
|
+
// The session may be gone (restart prune edge) - the poller's
|
|
671
|
+
// normal gates handle delivery; nothing to do here.
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Dormant watcher recovery (watcher-continuity fast-follow). A restart
|
|
678
|
+
// keeps foreign-pid watcher journal rows instead of pruning them (see the
|
|
679
|
+
// rehydration loop): the defining session's harness died, but the session
|
|
680
|
+
// is resumable. A resumed session's first message scans the dormant rows:
|
|
681
|
+
// - the session's own rows re-arm into the live registry (no
|
|
682
|
+
// re-registration step, so stated watch policy survives reboots);
|
|
683
|
+
// - rows of other still-dead sessions in THIS directory earn the live
|
|
684
|
+
// session a one-line death notice - a watch that will never fire is
|
|
685
|
+
// at least heard about. Deduplicated per process per target, so one
|
|
686
|
+
// active session hears about each corpse once;
|
|
687
|
+
// - rows past the TTL plus the dormant grace age out silently.
|
|
688
|
+
// Death notices are gated on the owning pid actually being dead: a v1
|
|
689
|
+
// sibling process (separate TUI window, shared project db, different pid,
|
|
690
|
+
// still alive) owns live watchers that must never be reported dead.
|
|
691
|
+
const dormantScanned = new Set<string>();
|
|
692
|
+
const notifiedWatcherDeaths = new Set<string>();
|
|
693
|
+
const pidAlive = (pid: number): boolean => {
|
|
694
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
695
|
+
try {
|
|
696
|
+
process.kill(pid, 0);
|
|
697
|
+
return true;
|
|
698
|
+
} catch (err) {
|
|
699
|
+
// ESRCH: no such process. Anything else (e.g. EPERM on a foreign-user
|
|
700
|
+
// pid) means the pid exists - fail toward "alive" and stay silent.
|
|
701
|
+
return (err as NodeJS.ErrnoException).code !== "ESRCH";
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const scanDormantWatchers = async (sessionID: string): Promise<string[]> => {
|
|
705
|
+
if (dormantScanned.has(sessionID)) return [];
|
|
706
|
+
dormantScanned.add(sessionID);
|
|
707
|
+
try {
|
|
708
|
+
const rows = db.runtimeStateAll().filter((r) => r.kind === "watchers" && r.pid !== process.pid);
|
|
709
|
+
const own: Watcher[] = [];
|
|
710
|
+
const deadSessions = new Map<string, string[]>();
|
|
711
|
+
for (const row of rows) {
|
|
712
|
+
const defs = Array.isArray(row.value) ? (row.value as Watcher[]) : [];
|
|
713
|
+
if (dormantWatchersStale(defs)) {
|
|
714
|
+
db.runtimeStateDelete("watchers", row.sessionID);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (row.sessionID === sessionID) {
|
|
718
|
+
// The startup-resume case hydrated these live at setup already -
|
|
719
|
+
// re-arming is a no-op there and the notice would be redundant.
|
|
720
|
+
if (!rehydratedSessions.has(sessionID)) own.push(...defs);
|
|
721
|
+
} else if (row.directory === directory && !pidAlive(row.pid)) {
|
|
722
|
+
// Dedup per dead SESSION (not per target): a resumed-then-died-
|
|
723
|
+
// again cycle re-arms the same session under new ids, and its
|
|
724
|
+
// second death deserves a fresh notice. Its own re-arm clears
|
|
725
|
+
// the entry below.
|
|
726
|
+
if (!notifiedWatcherDeaths.has(row.sessionID)) {
|
|
727
|
+
deadSessions.set(row.sessionID, defs.map(watcherTarget));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const dead of deadSessions.keys()) notifiedWatcherDeaths.add(dead);
|
|
732
|
+
const notices: string[] = [];
|
|
733
|
+
if (own.length > 0) {
|
|
734
|
+
const { rearmed, expired, failed } = await watchers.rearm(sessionID, own);
|
|
735
|
+
// This session came back to life: its own previous death notice is
|
|
736
|
+
// obsolete - a future death deserves a fresh one.
|
|
737
|
+
notifiedWatcherDeaths.delete(sessionID);
|
|
738
|
+
if (rearmed.length > 0) notices.push(watcherRearmNotice(rearmed.map(watcherTarget), expired, failed));
|
|
739
|
+
}
|
|
740
|
+
if (deadSessions.size > 0) {
|
|
741
|
+
const targets = [...new Set([...deadSessions.values()].flat())];
|
|
742
|
+
notices.push(watcherDeathNotice(targets));
|
|
743
|
+
}
|
|
744
|
+
return notices;
|
|
745
|
+
} catch (err) {
|
|
746
|
+
// Recovery must never break the message that triggered it.
|
|
747
|
+
console.error(`[thatch] dormant watcher scan failed for ${sessionID}: ${err}`);
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// Finalizes an extraction child that finished. The session.status idle
|
|
753
|
+
// handler calls it when the child's idle event arrives; the reload
|
|
754
|
+
// reconciler calls it for restored children whose idle event was lost to
|
|
755
|
+
// the reload window.
|
|
756
|
+
const finishExtractionChild = async (sessionID: string, parentID: string) => {
|
|
757
|
+
if (extractionChildren.has(sessionID)) {
|
|
758
|
+
// Drain the parent's snapshot entries from the pending buffer. If the
|
|
759
|
+
// child wrote memories, consumeSnapshot already ran in
|
|
760
|
+
// tool.execute.after and the snapshot is gone - nothing to drain. If
|
|
761
|
+
// the child did a no-save run, the snapshot entries are still in the
|
|
762
|
+
// buffer and need to be drained here so they don't replay as a nudge
|
|
763
|
+
// on the next chat.message. Never drain the entire buffer -
|
|
764
|
+
// interleaved-turn entries must survive.
|
|
765
|
+
const snapshot = parentSnapshots.get(sessionID);
|
|
766
|
+
if (snapshot) {
|
|
767
|
+
extraction.consumeSnapshot(parentID, snapshot);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Fire a toast with the extraction metrics. Only show a toast when
|
|
771
|
+
// memories were actually written - no toast for no-save runs to avoid
|
|
772
|
+
// notification fatigue.
|
|
773
|
+
const metrics = childMetrics.get(sessionID);
|
|
774
|
+
const parts: string[] = [];
|
|
775
|
+
if (metrics) {
|
|
776
|
+
if (metrics.new > 0) parts.push(`new: ${metrics.new}`);
|
|
777
|
+
if (metrics.updated > 0) parts.push(`updated: ${metrics.updated}`);
|
|
778
|
+
if (metrics.deleted > 0) parts.push(`deleted: ${metrics.deleted}`);
|
|
779
|
+
}
|
|
780
|
+
if (parts.length > 0) {
|
|
781
|
+
try {
|
|
782
|
+
await caps.showToast({
|
|
783
|
+
message: `💭 ${parts.join(", ")}`,
|
|
784
|
+
variant: "success",
|
|
785
|
+
duration: 4000,
|
|
786
|
+
});
|
|
787
|
+
} catch {
|
|
788
|
+
// TUI may not be connected (e.g. headless mode).
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
extraction.completeAccepted(parentID);
|
|
793
|
+
missedNudges.delete(parentID);
|
|
794
|
+
extracting.delete(parentID);
|
|
795
|
+
childToParent.delete(sessionID);
|
|
796
|
+
parentSnapshots.delete(sessionID);
|
|
797
|
+
childMetrics.delete(sessionID);
|
|
798
|
+
extractionChildren.delete(sessionID);
|
|
799
|
+
journalChild(sessionID);
|
|
800
|
+
extraction.consume(sessionID);
|
|
801
|
+
// Delete the child session to avoid clutter.
|
|
802
|
+
try {
|
|
803
|
+
await caps.sessionDelete(sessionID);
|
|
804
|
+
} catch {
|
|
805
|
+
// Best-effort - the child is idle and harmless if not deleted.
|
|
806
|
+
}
|
|
807
|
+
} else {
|
|
808
|
+
// Task-dispatched sub-agent went idle. Complete the parent's accepted
|
|
809
|
+
// entries (from the nudge-path extraction_done accept) and reset
|
|
810
|
+
// missedNudges. Retained for the nudge fallback path.
|
|
811
|
+
extraction.completeAccepted(parentID);
|
|
812
|
+
missedNudges.delete(parentID);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// A restored child may have gone idle DURING the reload window - its idle
|
|
817
|
+
// event fired before the plugin's event pump resubscribed and was lost.
|
|
818
|
+
// Without reconciliation, `extracting` stays populated forever and BOTH
|
|
819
|
+
// extraction paths stay suppressed for the session's life. Wait a beat
|
|
820
|
+
// for the pump to repopulate the status map, then finalize any restored
|
|
821
|
+
// child that is no longer running. A child genuinely mid-turn keeps its
|
|
822
|
+
// own idle event, and the finalize is idempotent if both paths race.
|
|
823
|
+
const reconcileRestoredChildren = async () => {
|
|
824
|
+
if (restoredChildren.size === 0) return;
|
|
825
|
+
await new Promise<void>((resolve) => {
|
|
826
|
+
const t = setTimeout(resolve, 10_000);
|
|
827
|
+
t.unref?.();
|
|
828
|
+
});
|
|
829
|
+
let live: Record<string, { type: string }> = {};
|
|
830
|
+
try {
|
|
831
|
+
live = (await caps.fetchStatuses()) ?? {};
|
|
832
|
+
} catch {
|
|
833
|
+
// Map-only liveness then - the event-fed statuses are the fallback.
|
|
834
|
+
}
|
|
835
|
+
for (const [childId, parentId] of restoredChildren) {
|
|
836
|
+
const mapped = sessionStatus.get(childId);
|
|
837
|
+
const remote = live[childId]?.type;
|
|
838
|
+
if (remote === "busy" || remote === "retry" || mapped === "busy" || mapped === "retry") continue;
|
|
839
|
+
restoredChildren.delete(childId);
|
|
840
|
+
await finishExtractionChild(childId, parentId);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
void reconcileRestoredChildren();
|
|
844
|
+
|
|
845
|
+
// Skills always install to the global opencode config - installing into the
|
|
846
|
+
// worktree would mutate the user's repo (untracked files in git status).
|
|
847
|
+
// A failed install degrades the nudge workflow but must not kill the plugin.
|
|
848
|
+
try {
|
|
849
|
+
installSkills(join(configHome, "opencode", "skills"), [
|
|
850
|
+
...SHARED_SKILLS,
|
|
851
|
+
...OPENCODE_ONLY_SKILLS,
|
|
852
|
+
]);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
console.error(`[thatch] skill install failed: ${err}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Same for the wrap-up slash commands: synced into the global config dir on
|
|
858
|
+
// every load so template updates self-heal. Config is loaded before plugins
|
|
859
|
+
// during server startup, so a first-ever install lands on the next start.
|
|
860
|
+
// Hosts that register commands natively (v2) get the action commands as
|
|
861
|
+
// files only - the wrap-ups register in code (onCommandExecuteBefore), and a file with
|
|
862
|
+
// the same name as a registered command would collide.
|
|
863
|
+
try {
|
|
864
|
+
if (caps.nativeCommands) removeWrapUpCommandFiles(configHome);
|
|
865
|
+
installOpencodeCommands(configHome, caps.nativeCommands ? opencodeActionCommandDefs() : undefined);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error(`[thatch] command install failed: ${err}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const sys = systemPrompt(repo, chatOn);
|
|
871
|
+
const compact = compactionContext(repo);
|
|
872
|
+
|
|
873
|
+
// Direct extraction: create a child session linked to the parent and prompt
|
|
874
|
+
// it with the extraction payload. The child runs the fact-extractor skill,
|
|
875
|
+
// writes memories, and goes idle. The existing childToParent / snapshot
|
|
876
|
+
// drain machinery handles buffer cleanup. The nudge path is a fallback if
|
|
877
|
+
// this throws.
|
|
878
|
+
//
|
|
879
|
+
// Does NOT call extraction.accept - entries stay in pending so consumeSnapshot
|
|
880
|
+
// can drain them by reference identity when the child writes a memory. The
|
|
881
|
+
// extracting set (not accept) suppresses the nudge in chat.message.
|
|
882
|
+
//
|
|
883
|
+
// The child ID is added to extractionChildren so the idle handler can
|
|
884
|
+
// distinguish plugin-created extraction children from task-dispatched
|
|
885
|
+
// sub-agents. Only extraction children get the full cleanup (buffer drain,
|
|
886
|
+
// session deletion, toast).
|
|
887
|
+
async function triggerExtraction(parentID: string): Promise<void> {
|
|
888
|
+
extracting.add(parentID);
|
|
889
|
+
|
|
890
|
+
const batch = extraction.peek(parentID);
|
|
891
|
+
const promptText = extractionDirectPrompt(batch.length, parentID);
|
|
892
|
+
|
|
893
|
+
const result = await caps.sessionCreate({
|
|
894
|
+
parentID,
|
|
895
|
+
title: "thatch-extraction",
|
|
896
|
+
});
|
|
897
|
+
// session.created event fires here, setting childToParent and
|
|
898
|
+
// parentSnapshots (snapshot of the full pending buffer, since we
|
|
899
|
+
// have not called accept).
|
|
900
|
+
const childId = result.id;
|
|
901
|
+
extractionChildren.add(childId);
|
|
902
|
+
// v2's session API cannot create a CHILD session (no parentID in its
|
|
903
|
+
// create input), so the adapter returns a top-level session and no
|
|
904
|
+
// session.created event carries the parent mapping. Set both here
|
|
905
|
+
// eagerly; on v1 the event sets the same values (idempotent).
|
|
906
|
+
if (!childToParent.has(childId)) {
|
|
907
|
+
childToParent.set(childId, parentID);
|
|
908
|
+
parentSnapshots.set(childId, [...extraction.peek(parentID)]);
|
|
909
|
+
}
|
|
910
|
+
journalChild(childId);
|
|
911
|
+
|
|
912
|
+
// Clean up the child session and all map entries if prompting fails.
|
|
913
|
+
// Without this, the child exists on the server but was never prompted,
|
|
914
|
+
// so it never goes idle and the maps leak.
|
|
915
|
+
const cleanupChild = () => {
|
|
916
|
+
extracting.delete(parentID);
|
|
917
|
+
extractionChildren.delete(childId);
|
|
918
|
+
childToParent.delete(childId);
|
|
919
|
+
parentSnapshots.delete(childId);
|
|
920
|
+
childMetrics.delete(childId);
|
|
921
|
+
journalChild(childId);
|
|
922
|
+
try { void caps.sessionDelete(childId); } catch {}
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const bgEnabled =
|
|
926
|
+
process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === "true" ||
|
|
927
|
+
process.env.OPENCODE_EXPERIMENTAL === "true";
|
|
928
|
+
|
|
929
|
+
if (bgEnabled) {
|
|
930
|
+
try {
|
|
931
|
+
await caps.promptSession(
|
|
932
|
+
childId,
|
|
933
|
+
{
|
|
934
|
+
parts: [{ type: "text", text: promptText }],
|
|
935
|
+
},
|
|
936
|
+
"async",
|
|
937
|
+
);
|
|
938
|
+
} catch (err) {
|
|
939
|
+
console.error(`[thatch] extraction child prompt failed: ${err}`);
|
|
940
|
+
cleanupChild();
|
|
941
|
+
throw err;
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
// Fire and forget - the parent is already idle, so blocking the event
|
|
945
|
+
// handler would only delay other event processing. The child runs to
|
|
946
|
+
// completion and its idle event triggers cleanup.
|
|
947
|
+
caps
|
|
948
|
+
.promptSession(
|
|
949
|
+
childId,
|
|
950
|
+
{
|
|
951
|
+
parts: [{ type: "text", text: promptText }],
|
|
952
|
+
},
|
|
953
|
+
"sync",
|
|
954
|
+
)
|
|
955
|
+
.catch((err: unknown) => {
|
|
956
|
+
console.error(`[thatch] extraction child failed: ${err}`);
|
|
957
|
+
cleanupChild();
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Hook-semantics handlers for the memory/extraction tools. Both direct
|
|
963
|
+
// tool calls (input.tool match in onToolExecuteAfter) and Code Mode
|
|
964
|
+
// execute calls that wrap them (unwrapExecuteThatchCalls) must run the
|
|
965
|
+
// same logic, so the bodies live here instead of inline in the hook.
|
|
966
|
+
const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
|
|
967
|
+
extraction.consume(sessionID);
|
|
968
|
+
missedNudges.delete(sessionID);
|
|
969
|
+
// Track extraction metrics for toast display. Counted for any
|
|
970
|
+
// child session (both direct-extraction and nudge-path sub-agents)
|
|
971
|
+
// since childToParent covers both. The toast only fires for
|
|
972
|
+
// extraction children (extractionChildren set) on idle.
|
|
973
|
+
const parentID = childToParent.get(sessionID);
|
|
974
|
+
if (parentID) {
|
|
975
|
+
const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
|
|
976
|
+
if (overwrite) metrics.updated++;
|
|
977
|
+
else metrics.new++;
|
|
978
|
+
childMetrics.set(sessionID, metrics);
|
|
979
|
+
journalChild(sessionID);
|
|
980
|
+
// Complete the parent's accepted entries (the extractor confirmed
|
|
981
|
+
// it is alive and saving) and drain the parent's snapshot entries
|
|
982
|
+
// from the pending buffer. If no snapshot was recorded (unreachable
|
|
983
|
+
// when childToParent has the entry, since both are set together in
|
|
984
|
+
// session.created), skip the drain rather than dropping the entire
|
|
985
|
+
// buffer - interleaved-turn entries that arrived while the child
|
|
986
|
+
// was running must survive for the next extraction cycle.
|
|
987
|
+
extraction.completeAccepted(parentID);
|
|
988
|
+
const snapshot = parentSnapshots.get(sessionID);
|
|
989
|
+
if (snapshot) {
|
|
990
|
+
extraction.consumeSnapshot(parentID, snapshot);
|
|
991
|
+
parentSnapshots.delete(sessionID);
|
|
992
|
+
}
|
|
993
|
+
journalChild(sessionID);
|
|
994
|
+
missedNudges.delete(parentID);
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
const toolExtractionDone = (sessionID: string, targetID?: string) => {
|
|
998
|
+
const parentID = childToParent.get(sessionID);
|
|
999
|
+
if (parentID) {
|
|
1000
|
+
extraction.completeAccepted(parentID);
|
|
1001
|
+
missedNudges.delete(parentID);
|
|
1002
|
+
extraction.consume(sessionID);
|
|
1003
|
+
} else if (targetID && targetID !== sessionID) {
|
|
1004
|
+
// A completion ack for ANOTHER session - the fact-extractor sub-agent
|
|
1005
|
+
// naming its parent. v2 links no parentID on dispatched sessions
|
|
1006
|
+
// (SessionCreateInput has none), so childToParent cannot know this
|
|
1007
|
+
// relationship and the explicit session_id is the only signal that
|
|
1008
|
+
// the target's accepted entries are accounted for. Without this
|
|
1009
|
+
// branch the accepted queue lingers forever and a tool-dense session
|
|
1010
|
+
// re-nudges every idle on never-draining exhaust.
|
|
1011
|
+
extraction.completeAccepted(targetID);
|
|
1012
|
+
missedNudges.delete(targetID);
|
|
1013
|
+
} else {
|
|
1014
|
+
extraction.accept(sessionID);
|
|
1015
|
+
}
|
|
1016
|
+
missedNudges.delete(sessionID);
|
|
1017
|
+
};
|
|
1018
|
+
const toolMemoryForget = (sessionID: string) => {
|
|
1019
|
+
const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
|
|
1020
|
+
metrics.deleted++;
|
|
1021
|
+
childMetrics.set(sessionID, metrics);
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
coreContext: buildCoreContext(db, model, repo, {
|
|
1026
|
+
extractionPayloadProvider: (sessionID: string): string | null => {
|
|
1027
|
+
const interactions = extraction.peek(sessionID);
|
|
1028
|
+
const accepted = extraction.peekAccepted(sessionID);
|
|
1029
|
+
const all = [...accepted, ...interactions];
|
|
1030
|
+
if (all.length === 0) return null;
|
|
1031
|
+
return extraction.buildPayload(all, repo);
|
|
1032
|
+
},
|
|
1033
|
+
watcherRegistry: watchers,
|
|
1034
|
+
projectDir: worktree,
|
|
1035
|
+
}),
|
|
1036
|
+
debug,
|
|
1037
|
+
childSessionIds: () => [...extractionChildren],
|
|
1038
|
+
|
|
1039
|
+
// 1. System prompt - always in context.
|
|
1040
|
+
onSystemTransform: async (output) => {
|
|
1041
|
+
output.system.push(sys);
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
// 2. Compaction context - re-familiarizes after compaction. The flag
|
|
1045
|
+
// suppresses chat.message nudges during summary generation (tool
|
|
1046
|
+
// calls are blocked there).
|
|
1047
|
+
onSessionCompacting: async (input, output) => {
|
|
1048
|
+
compacting.add(input.sessionID);
|
|
1049
|
+
output.context.push(compact);
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
// 2b. Clear the compacting flag after compaction succeeds so chat.message
|
|
1053
|
+
// nudges resume for the synthetic auto-continue turn and beyond.
|
|
1054
|
+
onCompactionAutocontinue: async (input) => {
|
|
1055
|
+
compacting.delete(input.sessionID);
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
// 3. Tool buffering - feeds the extraction pipeline (direct extraction
|
|
1059
|
+
// via SDK or nudge fallback). Excluded tools:
|
|
1060
|
+
// - thatch_*: extracting facts from memory ops would echo the store
|
|
1061
|
+
// - skill/task: meta-tools that orchestrate agent behavior (loading
|
|
1062
|
+
// skills, dispatching sub-agents). Buffering them creates a feedback
|
|
1063
|
+
// loop - extraction triggers a skill load, which gets buffered, which
|
|
1064
|
+
// triggers another extraction on the next turn.
|
|
1065
|
+
// Memory writes consume the buffer and reset the missed-nudge counter.
|
|
1066
|
+
// The buffer is NOT drained on nudge delivery - it persists until the
|
|
1067
|
+
// agent writes a memory, so ignored nudges accumulate and the payload
|
|
1068
|
+
// grows with each missed cycle.
|
|
1069
|
+
//
|
|
1070
|
+
// Buffer lifecycle is AMQP-style accept/complete, so a failed
|
|
1071
|
+
// extractor does not silently drop unprocessed interactions:
|
|
1072
|
+
// - thatch_extraction_done in the parent ACCEPTS the buffer: it moves
|
|
1073
|
+
// to a holding area and the nudge quiets, but entries are not
|
|
1074
|
+
// dropped yet.
|
|
1075
|
+
// - COMPLETE drops the held entries. Signals: a memory_remember or
|
|
1076
|
+
// extraction_done call in a child session of that parent (the
|
|
1077
|
+
// extractor confirming it processed them), or the child going idle
|
|
1078
|
+
// (a no-save run that never acks still counts as processed).
|
|
1079
|
+
// - REQUEUE returns the held entries to pending. Signals: the child
|
|
1080
|
+
// session errors or is deleted before completing.
|
|
1081
|
+
//
|
|
1082
|
+
// A memory_remember call in a child session (sub-agent) also drains
|
|
1083
|
+
// the parent's pending buffer - but only the entries that existed at
|
|
1084
|
+
// dispatch time (the snapshot). Entries from interleaved turns survive
|
|
1085
|
+
// so their facts aren't silently dropped.
|
|
1086
|
+
//
|
|
1087
|
+
// Code Mode `execute` calls are unwrapped before this filter applies
|
|
1088
|
+
// (unwrapExecuteThatchCalls): an execute call whose code invokes
|
|
1089
|
+
// tools.thatch_* runs the wrapped tool's hook semantics and is never
|
|
1090
|
+
// buffered. Matching on the outer tool name alone would re-queue the
|
|
1091
|
+
// pipeline's own dispatch/ack traffic every cycle - the self-feeding
|
|
1092
|
+
// extraction loop this removes (September 2026, observed live in an
|
|
1093
|
+
// oink session).
|
|
1094
|
+
onToolExecuteAfter: async (input, output) => {
|
|
1095
|
+
const { tool, sessionID } = input;
|
|
1096
|
+
if (tool === "thatch_memory_remember") {
|
|
1097
|
+
await toolMemoryRemember(sessionID, (input.args as Record<string, unknown> | undefined)?.overwrite);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
// thatch_extraction_done has two roles depending on the session:
|
|
1101
|
+
// - parent, after dispatching the fact-extractor: ACCEPT the buffer
|
|
1102
|
+
// (quiet the nudge, hold the entries for completion).
|
|
1103
|
+
// - child extractor, at the end of its run: COMPLETE the parent's
|
|
1104
|
+
// accepted entries, including no-save runs that write no memory,
|
|
1105
|
+
// and drop the child's own buffer (its work is done).
|
|
1106
|
+
if (tool === "thatch_extraction_done") {
|
|
1107
|
+
toolExtractionDone(sessionID, (input.args as { session_id?: string } | undefined)?.session_id);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
// Track memory deletions in child sessions for the toast metrics.
|
|
1111
|
+
// Same scoping as the remember handler above - only child sessions,
|
|
1112
|
+
// not the parent or manual memory writes from the user's session.
|
|
1113
|
+
if (tool === "thatch_memory_forget" && childToParent.has(sessionID)) {
|
|
1114
|
+
toolMemoryForget(sessionID);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
// Chat conversational events echo back into the transcript as visible
|
|
1118
|
+
// message parts. Plugin tools render as muted one-line entries with
|
|
1119
|
+
// the output hidden behind a default-off TUI toggle, so without this
|
|
1120
|
+
// the human watching a session never sees the conversation happen.
|
|
1121
|
+
// Delivery is a non-synthetic noReply promptAsync: the part renders
|
|
1122
|
+
// as a visible bubble and starts no model turn (the server's prompt
|
|
1123
|
+
// path returns before the completion loop). Non-synthetic also means
|
|
1124
|
+
// later turns see the echo in context - a small duplication of the
|
|
1125
|
+
// tool call it mirrors, accepted for visibility. Fire-and-forget: an
|
|
1126
|
+
// echo failure must never fail the tool call it follows.
|
|
1127
|
+
if (input.tool.startsWith("thatch_chat_")) {
|
|
1128
|
+
const echo = chatEchoText(
|
|
1129
|
+
input.tool,
|
|
1130
|
+
(input.args ?? {}) as Record<string, unknown>,
|
|
1131
|
+
typeof output.output === "string" ? output.output : "",
|
|
1132
|
+
);
|
|
1133
|
+
// noReply hosts only: on a host whose prompt endpoint starts a real
|
|
1134
|
+
// model turn, delivering the echo would create a turn that calls
|
|
1135
|
+
// chat tools, which echoes again - the loop the noReply flag exists
|
|
1136
|
+
// to prevent.
|
|
1137
|
+
if (echo && caps.noReplyDelivery) {
|
|
1138
|
+
void caps
|
|
1139
|
+
.promptSession(
|
|
1140
|
+
input.sessionID,
|
|
1141
|
+
{
|
|
1142
|
+
noReply: true,
|
|
1143
|
+
parts: [{ type: "text", text: echo }],
|
|
1144
|
+
},
|
|
1145
|
+
"async",
|
|
1146
|
+
)
|
|
1147
|
+
.catch((err) => {
|
|
1148
|
+
// The echo must never fail the tool call, but a silent catch
|
|
1149
|
+
// would hide a systemic promptAsync failure (bubbles would
|
|
1150
|
+
// stop appearing everywhere with no diagnostics).
|
|
1151
|
+
console.error(`[thatch] chat echo delivery failed: ${err}`);
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
// "task" is v1's dispatch tool and "subagent" is v2's - a dispatch
|
|
1157
|
+
// buffers itself otherwise, and the nudge then fires on the pipeline's
|
|
1158
|
+
// own exhaust (the subagent-dispatch loop, sibling of the
|
|
1159
|
+
// execute-wrapped-ack loop below). isMetaToolName owns the set.
|
|
1160
|
+
if (isMetaToolName(input.tool)) return;
|
|
1161
|
+
// Code Mode execute calls: when the code wraps thatch_* invocations,
|
|
1162
|
+
// run the wrapped tools' hook semantics and never buffer the call
|
|
1163
|
+
// itself - it is the pipeline's own traffic, and buffering it feeds
|
|
1164
|
+
// the extraction loop with its own exhaust (the September 2026
|
|
1165
|
+
// infinite dispatch/ack loop). Execute calls that do not touch thatch
|
|
1166
|
+
// tools fall through and buffer as normal: they did real work.
|
|
1167
|
+
if (input.tool === "execute") {
|
|
1168
|
+
const wrapped = unwrapExecuteThatchCalls(input.args);
|
|
1169
|
+
if (wrapped.tools.length > 0) {
|
|
1170
|
+
for (const name of wrapped.tools) {
|
|
1171
|
+
if (name === "thatch_memory_remember") await toolMemoryRemember(sessionID, wrapped.overwrite);
|
|
1172
|
+
else if (name === "thatch_extraction_done") toolExtractionDone(sessionID, wrapped.sessionID);
|
|
1173
|
+
else if (name === "thatch_memory_forget" && childToParent.has(sessionID)) toolMemoryForget(sessionID);
|
|
1174
|
+
// Other thatch_* tools (recall, show, list, ...) carry no hook
|
|
1175
|
+
// semantics - doing nothing with them is the whole point.
|
|
1176
|
+
}
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
extraction.push({
|
|
1181
|
+
tool: input.tool,
|
|
1182
|
+
sessionID: input.sessionID,
|
|
1183
|
+
args: (input.args as Record<string, unknown>) ?? {},
|
|
1184
|
+
title: output.title,
|
|
1185
|
+
output: typeof output.output === "string" ? output.output : "",
|
|
1186
|
+
});
|
|
1187
|
+
// The push site is the single classification of "this call was real
|
|
1188
|
+
// work" - meta tools and execute-wrapped thatch calls never reach it -
|
|
1189
|
+
// so it is also the done-alert signal. todowrite/question reach it
|
|
1190
|
+
// (the buffer wants them) but are no-work for alerts - see
|
|
1191
|
+
// isNoWorkTool in src/alerts.ts for why they must not move into
|
|
1192
|
+
// isMetaToolName.
|
|
1193
|
+
if (!isNoWorkTool(input.tool)) {
|
|
1194
|
+
alerts.sessionRealWork(sessionID);
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
|
|
1198
|
+
// 4. Per-message nudge - two priority tiers:
|
|
1199
|
+
// a. Extraction nudge: prior tool interactions are queued for fact
|
|
1200
|
+
// extraction (carries the JSON payload for thatch-fact-extractor).
|
|
1201
|
+
// b. Recall + prediction + behavior nudge: when no extraction is pending, embed
|
|
1202
|
+
// the user's prompt (shared embedding for all three) and:
|
|
1203
|
+
// - search memories by cosine (recall nudge)
|
|
1204
|
+
// - search matchers by cosine, score predictions (prediction fire)
|
|
1205
|
+
// - search behavior matchers by cosine, score behaviors (behavior fire)
|
|
1206
|
+
// All three fire independently; any subset may inject.
|
|
1207
|
+
// The auto-fires reuse the embedding already computed for recall,
|
|
1208
|
+
// adding only cosine scans against the matchers tables.
|
|
1209
|
+
// No extra model calls.
|
|
1210
|
+
//
|
|
1211
|
+
// Skipped during compaction: the agent can't call tools while generating
|
|
1212
|
+
// a summary, so a nudge that says "use thatch_memory_recall" triggers a
|
|
1213
|
+
// blocked-tool error.
|
|
1214
|
+
// Wrap-up commands mark their session here; the actual trigger happens
|
|
1215
|
+
// on the session's next idle event, once the model's greenlight response
|
|
1216
|
+
// is complete.
|
|
1217
|
+
onCommandExecuteBefore: async (input) => {
|
|
1218
|
+
const wrapUp = WRAPUP_COMMANDS[input.command];
|
|
1219
|
+
if (wrapUp) {
|
|
1220
|
+
pendingWrapUp.set(input.sessionID, wrapUp);
|
|
1221
|
+
// Journaled so a v2 plugin reload before the session's next idle
|
|
1222
|
+
// does not silently drop the armed wrap-up.
|
|
1223
|
+
db.runtimeStatePut("wrapup", input.sessionID, wrapUp, directory);
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
|
|
1227
|
+
onChatMessage: async (input, output) => {
|
|
1228
|
+
// Chat transcript echoes are non-synthetic noReply parts, so they
|
|
1229
|
+
// arrive here like any user message - but no model turn ever reads
|
|
1230
|
+
// them, and the nudge machinery would embed and scan their text for
|
|
1231
|
+
// nothing (and fire spurious toasts when thresholds cross). Skip
|
|
1232
|
+
// them entirely.
|
|
1233
|
+
if (isChatEchoParts(output.parts)) return;
|
|
1234
|
+
|
|
1235
|
+
// Dormant watcher recovery: a resumed session's first message re-arms
|
|
1236
|
+
// its restarted watchers and hears about dead ones (see
|
|
1237
|
+
// scanDormantWatchers). Injected as synthetic parts - the same path
|
|
1238
|
+
// nudges take, so the model sees them on every call of the turn -
|
|
1239
|
+
// plus a best-effort toast for the human.
|
|
1240
|
+
const rearmNotices = !childToParent.has(input.sessionID) ? await scanDormantWatchers(input.sessionID) : [];
|
|
1241
|
+
if (rearmNotices.length > 0) {
|
|
1242
|
+
for (const text of rearmNotices) {
|
|
1243
|
+
output.parts.push({
|
|
1244
|
+
id: `prt_thatch_watch_${Math.random().toString(36).slice(2)}`,
|
|
1245
|
+
sessionID: input.sessionID,
|
|
1246
|
+
messageID: input.messageID ?? output.message.id,
|
|
1247
|
+
type: "text",
|
|
1248
|
+
text,
|
|
1249
|
+
synthetic: true,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
try {
|
|
1253
|
+
await caps.showToast({ message: rearmNotices[0].split("\n")[0].replace("[thatch] ", "⏰ "), variant: "info", duration: 5000 });
|
|
1254
|
+
} catch {
|
|
1255
|
+
// TUI may not be connected. Best-effort.
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Prompt-path registration: a brand-new session joins the chat
|
|
1260
|
+
// directory the moment its first real user message arrives - before
|
|
1261
|
+
// the model runs - so it is addressable within that first turn (the
|
|
1262
|
+
// first-idle path alone leaves a turn-long dark window). Local
|
|
1263
|
+
// SQLite, so it runs inline; on an already-registered session this
|
|
1264
|
+
// is a free mid-turn heartbeat touch. Sub-agent children are excluded
|
|
1265
|
+
// (they register only by mistake, and their deletion tombstones the
|
|
1266
|
+
// row), as are sessions that left via chat_unregister. The idle path
|
|
1267
|
+
// below stays as the backstop and converges the topic.
|
|
1268
|
+
if (
|
|
1269
|
+
chatOn &&
|
|
1270
|
+
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
1271
|
+
input.sessionID &&
|
|
1272
|
+
!childToParent.has(input.sessionID) &&
|
|
1273
|
+
!db.hasChatLeaveTombstone(input.sessionID)
|
|
1274
|
+
) {
|
|
1275
|
+
try {
|
|
1276
|
+
const res = db.registerChatSession(input.sessionID, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
1277
|
+
// First registration - or a startup resume reclaiming its name,
|
|
1278
|
+
// announced here because the TUI was not connected at init - is
|
|
1279
|
+
// the one moment the user should notice: a quiet toast, not a
|
|
1280
|
+
// conversation message.
|
|
1281
|
+
if (res.ok && (res.created || chatStartupNames.has(input.sessionID))) {
|
|
1282
|
+
chatStartupNames.delete(input.sessionID);
|
|
1283
|
+
try {
|
|
1284
|
+
await caps.showToast({ message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 });
|
|
1285
|
+
} catch {
|
|
1286
|
+
// Headless or disconnected TUI - registration stands.
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
console.error(`[thatch] chat prompt-register failed for ${input.sessionID}: ${err}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (compacting.has(input.sessionID)) {
|
|
1295
|
+
// The session is marked as compacting. If this message is the
|
|
1296
|
+
// compaction summary generation itself (has a compaction-type part),
|
|
1297
|
+
// suppress nudges - tools are blocked during summary generation and a
|
|
1298
|
+
// nudge would cause a blocked-tool error. If it is NOT a compaction
|
|
1299
|
+
// message, compaction has already failed (autocontinue never fired)
|
|
1300
|
+
// and the user is sending a new message. Clear the stale flag and
|
|
1301
|
+
// proceed normally - tools are available again.
|
|
1302
|
+
const isCompactionMsg = (output.parts as any[]).some((p) => p.type === "compaction");
|
|
1303
|
+
if (isCompactionMsg) return;
|
|
1304
|
+
compacting.delete(input.sessionID);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Version warning: check if the on-disk package.json version differs
|
|
1308
|
+
// from the running version (user upgraded but didn't restart opencode),
|
|
1309
|
+
// or if the npm poller found a newer version. Best-effort: any failure
|
|
1310
|
+
// silently skips. Injected as a synthetic part so the LLM sees it and
|
|
1311
|
+
// can tell the user to restart.
|
|
1312
|
+
try {
|
|
1313
|
+
const onDisk = readOnDiskVersion();
|
|
1314
|
+
const checker = getVersionChecker();
|
|
1315
|
+
const npmUpdate = checker?.getUpdateWarning() ?? null;
|
|
1316
|
+
const skewWarning = onDisk && compareSemver(runningVersion, onDisk) < 0
|
|
1317
|
+
? skewWarningText(onDisk, runningVersion)
|
|
1318
|
+
: null;
|
|
1319
|
+
const warning = skewWarning ?? npmUpdate;
|
|
1320
|
+
if (warning) {
|
|
1321
|
+
output.parts.push({
|
|
1322
|
+
id: `prt_thatch_ver_${Math.random().toString(36).slice(2)}`,
|
|
1323
|
+
sessionID: input.sessionID,
|
|
1324
|
+
messageID: input.messageID ?? output.message.id,
|
|
1325
|
+
type: "text",
|
|
1326
|
+
text: versionWarningNudge(warning),
|
|
1327
|
+
synthetic: true,
|
|
1328
|
+
});
|
|
1329
|
+
try {
|
|
1330
|
+
const short = skewWarning
|
|
1331
|
+
? `⚠️ thatch upgraded to v${onDisk} (running v${runningVersion})`
|
|
1332
|
+
: `⚠️ thatch v${checker?.getLatestVersion()} is available`;
|
|
1333
|
+
await caps.showToast({
|
|
1334
|
+
message: short,
|
|
1335
|
+
variant: "warning",
|
|
1336
|
+
duration: 5000,
|
|
1337
|
+
});
|
|
1338
|
+
} catch {
|
|
1339
|
+
// TUI may not be connected. Best-effort.
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
} catch {
|
|
1343
|
+
// Best-effort. Version check failure must not block nudges.
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Extraction nudge (fallback path). Skipped when the extracting set
|
|
1347
|
+
// is active - that means a direct-extraction child session is running
|
|
1348
|
+
// and the plugin is handling extraction via the SDK. The nudge fires
|
|
1349
|
+
// here only when direct extraction was never triggered or threw.
|
|
1350
|
+
// Accepted entries whose completion signal never came (crashed
|
|
1351
|
+
// extractor, or a v2 dispatch whose ack never named the parent)
|
|
1352
|
+
// requeue here so they are honestly re-extracted instead of lingering
|
|
1353
|
+
// as silent loss.
|
|
1354
|
+
extraction.requeueStaleAccepted();
|
|
1355
|
+
if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
|
|
1356
|
+
const batch = extraction.peek(input.sessionID);
|
|
1357
|
+
const missed = missedNudges.get(input.sessionID) ?? 0;
|
|
1358
|
+
const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
|
|
1359
|
+
missedNudges.set(input.sessionID, missed + 1);
|
|
1360
|
+
|
|
1361
|
+
output.parts.push({
|
|
1362
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1363
|
+
sessionID: input.sessionID,
|
|
1364
|
+
messageID: input.messageID ?? output.message.id,
|
|
1365
|
+
type: "text",
|
|
1366
|
+
text,
|
|
1367
|
+
synthetic: true,
|
|
1368
|
+
});
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// No extraction pending - try the prompt-aware recall nudge. Extract
|
|
1373
|
+
// the user's prompt text from the message parts, embed it with the
|
|
1374
|
+
// warm in-process model, and search for matches. Best-effort: any
|
|
1375
|
+
// failure (no text, model not loaded, empty store) silently skips.
|
|
1376
|
+
try {
|
|
1377
|
+
const promptText = (output.parts as any[])
|
|
1378
|
+
.filter((p) => p.type === "text" && !p.synthetic)
|
|
1379
|
+
.map((p) => p.text)
|
|
1380
|
+
.join(" ");
|
|
1381
|
+
if (promptText.length < MIN_PROMPT_LEN) return;
|
|
1382
|
+
|
|
1383
|
+
const embedding = await model.queryEmbed(promptText);
|
|
1384
|
+
|
|
1385
|
+
// Recall nudge: separate try/catch so a memory-search failure
|
|
1386
|
+
// does not block the prediction fire (they share only the embedding).
|
|
1387
|
+
try {
|
|
1388
|
+
const results = db.search([repo, "global"], embedding, { limit: 5 });
|
|
1389
|
+
const matches: NudgeMatch[] = results
|
|
1390
|
+
.filter((r) => r._score >= RECALL_THRESHOLD)
|
|
1391
|
+
.map((r) => ({ label: r.label, score: Math.round(r._score * 1000) / 1000 }));
|
|
1392
|
+
|
|
1393
|
+
if (matches.length > 0) {
|
|
1394
|
+
output.parts.push({
|
|
1395
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1396
|
+
sessionID: input.sessionID,
|
|
1397
|
+
messageID: input.messageID ?? output.message.id,
|
|
1398
|
+
type: "text",
|
|
1399
|
+
text: recallNudge(matches),
|
|
1400
|
+
synthetic: true,
|
|
1401
|
+
});
|
|
1402
|
+
try {
|
|
1403
|
+
await caps.showToast({
|
|
1404
|
+
message: `💭 recalled ${matches.length} memor${matches.length === 1 ? "y" : "ies"}`,
|
|
1405
|
+
variant: "info",
|
|
1406
|
+
duration: 3000,
|
|
1407
|
+
});
|
|
1408
|
+
} catch {
|
|
1409
|
+
// TUI may not be connected. Best-effort.
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
console.error(`[thatch] recall nudge failed: ${err}`);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Prediction fire: independent of recall; a failure here does
|
|
1417
|
+
// not affect the recall nudge that may have already been pushed.
|
|
1418
|
+
try {
|
|
1419
|
+
const predItems = db.scorePredictionNudge([repo, "global"], embedding, PREDICTION_THRESHOLD);
|
|
1420
|
+
if (predItems.length > 0) {
|
|
1421
|
+
output.parts.push({
|
|
1422
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1423
|
+
sessionID: input.sessionID,
|
|
1424
|
+
messageID: input.messageID ?? output.message.id,
|
|
1425
|
+
type: "text",
|
|
1426
|
+
text: predictionNudge(predItems),
|
|
1427
|
+
synthetic: true,
|
|
1428
|
+
});
|
|
1429
|
+
try {
|
|
1430
|
+
await caps.showToast({
|
|
1431
|
+
message: `💭 ${predItems.length} prediction${predItems.length === 1 ? "" : "s"} surfaced`,
|
|
1432
|
+
variant: "info",
|
|
1433
|
+
duration: 3000,
|
|
1434
|
+
});
|
|
1435
|
+
} catch {
|
|
1436
|
+
// TUI may not be connected. Best-effort.
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
console.error(`[thatch] prediction nudge failed: ${err}`);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Behavior fire: independent of recall and prediction. Same
|
|
1444
|
+
// embedding, one more cosine scan against behavior_matchers.
|
|
1445
|
+
try {
|
|
1446
|
+
const behaviorItems = db.scoreBehaviorNudge([repo, "global"], embedding, BEHAVIOR_THRESHOLD);
|
|
1447
|
+
if (behaviorItems.length > 0) {
|
|
1448
|
+
output.parts.push({
|
|
1449
|
+
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1450
|
+
sessionID: input.sessionID,
|
|
1451
|
+
messageID: input.messageID ?? output.message.id,
|
|
1452
|
+
type: "text",
|
|
1453
|
+
text: behaviorNudge(behaviorItems),
|
|
1454
|
+
synthetic: true,
|
|
1455
|
+
});
|
|
1456
|
+
try {
|
|
1457
|
+
await caps.showToast({
|
|
1458
|
+
message: `💭 ${behaviorItems.length} behavior${behaviorItems.length === 1 ? "" : "s"} surfaced`,
|
|
1459
|
+
variant: "info",
|
|
1460
|
+
duration: 3000,
|
|
1461
|
+
});
|
|
1462
|
+
} catch {
|
|
1463
|
+
// TUI may not be connected. Best-effort.
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
console.error(`[thatch] behavior nudge failed: ${err}`);
|
|
1468
|
+
}
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
console.error(`[thatch] nudge hook failed: ${err}`);
|
|
1471
|
+
}
|
|
1472
|
+
},
|
|
1473
|
+
|
|
1474
|
+
// 5. Session-start reminder, carrying the hygiene heartbeat. Hygiene is
|
|
1475
|
+
// best-effort: a failure there must not cost the reminder itself.
|
|
1476
|
+
//
|
|
1477
|
+
// Also tracks parent-child session relationships for cross-session buffer
|
|
1478
|
+
// drain. session.created with a parentID records the mapping AND
|
|
1479
|
+
// snapshots the parent's current buffer so the child's later
|
|
1480
|
+
// memory_remember drains only those snapshot entries, not the parent's
|
|
1481
|
+
// entire buffer (which may have grown from interleaved turns).
|
|
1482
|
+
//
|
|
1483
|
+
// Child lifecycle events: idle means the extractor finished; error or
|
|
1484
|
+
// deletion before completion means the facts were never extracted, so
|
|
1485
|
+
// entries go back to pending. Only extraction children (created by
|
|
1486
|
+
// triggerExtraction, tracked in extractionChildren) get the full cleanup
|
|
1487
|
+
// (buffer drain, session deletion, toast). Task-dispatched sub-agents
|
|
1488
|
+
// get the old behavior (completeAccepted + missedNudges.reset) so their
|
|
1489
|
+
// sessions are not deleted out from under the task tool.
|
|
1490
|
+
onEvent: async (event) => {
|
|
1491
|
+
// Pause alerts: the LLM blocked on a human decision. Both lines
|
|
1492
|
+
// publish these on the bus; v2's adapter translates them into the
|
|
1493
|
+
// same property names (requestID on all reply events).
|
|
1494
|
+
if (event.type === "question.asked") {
|
|
1495
|
+
await alerts.questionAsked(event.properties.sessionID, event.properties.id);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (event.type === "question.replied" || event.type === "question.rejected") {
|
|
1499
|
+
alerts.questionResolved(event.properties.sessionID, replyRequestID(event.properties));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
if (event.type === "permission.asked") {
|
|
1503
|
+
await alerts.permissionAsked(event.properties.sessionID, event.properties.id);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (event.type === "permission.replied") {
|
|
1507
|
+
alerts.permissionResolved(event.properties.sessionID, replyRequestID(event.properties));
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (event.type === "session.created") {
|
|
1511
|
+
const info = event.properties.info;
|
|
1512
|
+
if (info.parentID) {
|
|
1513
|
+
childToParent.set(info.id, info.parentID);
|
|
1514
|
+
parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
|
|
1515
|
+
journalChild(info.id);
|
|
1516
|
+
// Child sessions don't need the session-start reminder - only
|
|
1517
|
+
// top-level sessions do. Returning here prevents extraction
|
|
1518
|
+
// children from receiving the hygiene report and tool overview.
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (event.type === "session.error") {
|
|
1523
|
+
const childID = event.properties.sessionID;
|
|
1524
|
+
// Alert bookkeeping first: the error name decides abort-silence vs
|
|
1525
|
+
// the needs-attention alert when the session then goes idle (on v2
|
|
1526
|
+
// the adapter emits session.error + idle as a pair for terminal
|
|
1527
|
+
// failures and interrupts, so the verdict always follows). Children
|
|
1528
|
+
// are excluded - their failure requeues extraction here and the
|
|
1529
|
+
// parent turn continues; the child session itself never alerts.
|
|
1530
|
+
const errorObj = event.properties.error as { name?: string; type?: string } | undefined;
|
|
1531
|
+
if (!childToParent.has(childID ?? "")) {
|
|
1532
|
+
alerts.sessionError(childID ?? "", errorObj?.name ?? errorObj?.type ?? null);
|
|
1533
|
+
}
|
|
1534
|
+
const parentID = childID ? childToParent.get(childID) : undefined;
|
|
1535
|
+
if (parentID && childID) {
|
|
1536
|
+
extraction.requeueAccepted(parentID);
|
|
1537
|
+
extracting.delete(parentID);
|
|
1538
|
+
childToParent.delete(childID);
|
|
1539
|
+
parentSnapshots.delete(childID);
|
|
1540
|
+
childMetrics.delete(childID);
|
|
1541
|
+
extractionChildren.delete(childID);
|
|
1542
|
+
journalChild(childID);
|
|
1543
|
+
}
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
if (event.type === "session.status") {
|
|
1547
|
+
const sessionID = event.properties.sessionID;
|
|
1548
|
+
const statusType = event.properties.status?.type;
|
|
1549
|
+
// Record the latest status so the watcher registry can gate
|
|
1550
|
+
// proactive prompt delivery on idle sessions.
|
|
1551
|
+
if (sessionID && statusType) sessionStatus.set(sessionID, statusType);
|
|
1552
|
+
// Busy resets the round; a retry (v1 only) clears a recorded error
|
|
1553
|
+
// but preserves the real-work flag - the tool calls before the
|
|
1554
|
+
// retry happened.
|
|
1555
|
+
if (sessionID && statusType === "busy") alerts.sessionBusy(sessionID);
|
|
1556
|
+
else if (sessionID && statusType === "retry") alerts.sessionRetry(sessionID);
|
|
1557
|
+
if (statusType !== "idle") return;
|
|
1558
|
+
const parentID = sessionID ? childToParent.get(sessionID) : undefined;
|
|
1559
|
+
if (parentID && sessionID) {
|
|
1560
|
+
// A child session went idle. Two cases:
|
|
1561
|
+
// - Extraction child (created by triggerExtraction, tracked in
|
|
1562
|
+
// extractionChildren): drain the parent's snapshot, fire a toast
|
|
1563
|
+
// with metrics, delete the child session, clean up all maps.
|
|
1564
|
+
// - Task-dispatched sub-agent (code review specialist, nudge-path
|
|
1565
|
+
// fact-extractor, any model-dispatched task): complete the
|
|
1566
|
+
// parent's accepted entries and reset missedNudges. Do NOT drain
|
|
1567
|
+
// the buffer or delete the session - the task tool that
|
|
1568
|
+
// dispatched the sub-agent needs to read its output.
|
|
1569
|
+
await finishExtractionChild(sessionID, parentID);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
// Turn-end alert: fires only when the round did real work (the
|
|
1573
|
+
// classifier skips nudge/completion-driven bookkeeping rounds).
|
|
1574
|
+
// Runs before the wrap-up branches so a greenlit compact or exit
|
|
1575
|
+
// still ends with its completion alert.
|
|
1576
|
+
if (sessionID) await alerts.sessionIdle(sessionID);
|
|
1577
|
+
// Wrap-up command resolution: the session went idle right after
|
|
1578
|
+
// /thatch/compact or /thatch/exit. The final assistant message's
|
|
1579
|
+
// trailing token is the greenlight - the command instructs the model
|
|
1580
|
+
// to end with it only after flushing persistence and confirming no
|
|
1581
|
+
// loose ends. Token absence means the model listed blockers, so the
|
|
1582
|
+
// action never fires; a toast points the user at the blockers above.
|
|
1583
|
+
const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
|
|
1584
|
+
if (wrapUp) {
|
|
1585
|
+
pendingWrapUp.delete(sessionID);
|
|
1586
|
+
db.runtimeStateDelete("wrapup", sessionID);
|
|
1587
|
+
let ready = false;
|
|
1588
|
+
try {
|
|
1589
|
+
const data = await caps.sessionMessages(sessionID);
|
|
1590
|
+
const last = [...(data ?? [])].reverse().find((m) => m.info.role === "assistant");
|
|
1591
|
+
const text = (last?.parts ?? [])
|
|
1592
|
+
.filter((p) => p.type === "text")
|
|
1593
|
+
.map((p) => p.text ?? "")
|
|
1594
|
+
.join("\n")
|
|
1595
|
+
.trimEnd();
|
|
1596
|
+
ready = text.endsWith(wrapUp.token);
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
console.error(`[thatch] wrap-up message fetch failed: ${err}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (ready) {
|
|
1601
|
+
// An exit-greenlit session is leaving the process: leave the
|
|
1602
|
+
// chat directory too, so other sessions stop addressing mail to
|
|
1603
|
+
// a roster entry whose host is about to vanish. The unregister
|
|
1604
|
+
// tombstone also stops any straggler auto-register from
|
|
1605
|
+
// resurrecting the row during shutdown. Compact keeps the
|
|
1606
|
+
// session alive, so only the exit path does this.
|
|
1607
|
+
if (wrapUp.kind === "exit") {
|
|
1608
|
+
try {
|
|
1609
|
+
db.unregisterChatSession(sessionID);
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
console.error(`[thatch] chat unregister on exit failed: ${err}`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
try {
|
|
1615
|
+
if (wrapUp.kind === "compact") {
|
|
1616
|
+
await caps.compactSession(sessionID);
|
|
1617
|
+
} else {
|
|
1618
|
+
await caps.exitHost();
|
|
1619
|
+
}
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
console.error(`[thatch] wrap-up action failed: ${err}`);
|
|
1622
|
+
}
|
|
1623
|
+
// Skip extraction and pending deliveries: compaction is starting
|
|
1624
|
+
// (the checklist drained the buffer) or the process is exiting.
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
await caps.showToast({
|
|
1629
|
+
message:
|
|
1630
|
+
wrapUp.kind === "compact"
|
|
1631
|
+
? "⏸ Not compacting - resolve the items listed above, then run /thatch/compact again"
|
|
1632
|
+
: "⏸ Not exiting - resolve the items listed above, then run /thatch/exit again",
|
|
1633
|
+
variant: "warning",
|
|
1634
|
+
duration: 8000,
|
|
1635
|
+
});
|
|
1636
|
+
} catch {
|
|
1637
|
+
// TUI may not be connected (e.g. headless mode).
|
|
1638
|
+
}
|
|
1639
|
+
// Blocked: fall through so extraction and pending deliveries still
|
|
1640
|
+
// run - the model may have buffered tool interactions to flush.
|
|
1641
|
+
}
|
|
1642
|
+
// Auto-registration: every top-level opencode session joins the
|
|
1643
|
+
// chat directory on its first idle event (unless the user turned
|
|
1644
|
+
// auto-registration off). The name is assigned, never chosen - a
|
|
1645
|
+
// pool draw plus a never-reused counter, kept short because it
|
|
1646
|
+
// appears in wake prompts and rosters. The live session title rides
|
|
1647
|
+
// the topic column instead (refreshed on every idle), so the name
|
|
1648
|
+
// and the description stay separate identities. Fire-and-forget: a
|
|
1649
|
+
// failed registration or title fetch must never block event
|
|
1650
|
+
// delivery.
|
|
1651
|
+
if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
|
|
1652
|
+
void (async () => {
|
|
1653
|
+
try {
|
|
1654
|
+
const data = await caps.sessionGet(sessionID);
|
|
1655
|
+
const title = data?.title ?? "";
|
|
1656
|
+
// Placeholder titles never become topics (the real one
|
|
1657
|
+
// converges on a later idle via refreshChatTopic).
|
|
1658
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
1659
|
+
const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
|
|
1660
|
+
// The topic converges as the auto-titler lands a real title:
|
|
1661
|
+
// registration is once-per-session, so later idles must
|
|
1662
|
+
// refresh it explicitly.
|
|
1663
|
+
if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
|
|
1664
|
+
// First registration - or a startup resume reclaiming its
|
|
1665
|
+
// name, announced here because the TUI was not connected at
|
|
1666
|
+
// init - is the one moment the user should notice: a quiet
|
|
1667
|
+
// toast, not a conversation message.
|
|
1668
|
+
if (res.ok && (res.created || chatStartupNames.has(sessionID))) {
|
|
1669
|
+
chatStartupNames.delete(sessionID);
|
|
1670
|
+
try {
|
|
1671
|
+
await caps.showToast({ message: res.created ? `registered in chat as ${res.name}` : `rejoined chat as ${res.name}`, variant: "info", duration: 4000 });
|
|
1672
|
+
} catch {
|
|
1673
|
+
// Headless or disconnected TUI - registration stands.
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
} catch (err) {
|
|
1677
|
+
console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
|
|
1678
|
+
}
|
|
1679
|
+
})();
|
|
1680
|
+
}
|
|
1681
|
+
// Parent went idle - trigger direct extraction if there are pending
|
|
1682
|
+
// tool interactions and no extraction is already running. Falls
|
|
1683
|
+
// back to the nudge path on the next chat.message if this throws.
|
|
1684
|
+
if (
|
|
1685
|
+
sessionID &&
|
|
1686
|
+
!compacting.has(sessionID) &&
|
|
1687
|
+
!extracting.has(sessionID) &&
|
|
1688
|
+
extraction.pending(sessionID)
|
|
1689
|
+
) {
|
|
1690
|
+
try {
|
|
1691
|
+
await triggerExtraction(sessionID);
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
console.error(`[thatch] direct extraction trigger failed: ${err}`);
|
|
1694
|
+
debug("extraction", `direct extraction trigger failed for ${sessionID}: ${err}`);
|
|
1695
|
+
extracting.delete(sessionID);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
// The session just became idle, so watcher events queued while it
|
|
1699
|
+
// was busy can be delivered now - the poll interval may otherwise
|
|
1700
|
+
// hold them for up to a full cycle. Best-effort; failures stay
|
|
1701
|
+
// pending and retry on the next poll.
|
|
1702
|
+
try {
|
|
1703
|
+
await watchers.deliverPending();
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
console.error(`[thatch] watcher delivery on idle failed: ${err}`);
|
|
1706
|
+
}
|
|
1707
|
+
// Same for chat messages: mail that arrived mid-turn lands now
|
|
1708
|
+
// instead of waiting for the next poll cycle.
|
|
1709
|
+
try {
|
|
1710
|
+
await chatPoller.deliverPending();
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
console.error(`[thatch] chat delivery on idle failed: ${err}`);
|
|
1713
|
+
}
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (event.type === "session.deleted") {
|
|
1717
|
+
const id = event.properties.info.id;
|
|
1718
|
+
// Drop the session's alert state first - it has no reason to outlive
|
|
1719
|
+
// the session.
|
|
1720
|
+
alerts.sessionDeleted(id);
|
|
1721
|
+
// Shrink the chat poller's hosted set FIRST: the rest of this
|
|
1722
|
+
// branch runs extraction calls that could throw on a transient DB
|
|
1723
|
+
// error, and a leaked status key would keep heartbeat-ing a dead
|
|
1724
|
+
// session as permanently fresh.
|
|
1725
|
+
sessionStatus.delete(id);
|
|
1726
|
+
// Tombstone the auto-registerer: an idle IIFE already in flight
|
|
1727
|
+
// (title fetch pending) would otherwise re-insert a directory row
|
|
1728
|
+
// for a session the user just deleted - a week-long roster zombie
|
|
1729
|
+
// whose host is gone. The unregister below tombstones (see
|
|
1730
|
+
// ChatStore.unregister), and the tombstone is cleared only by an
|
|
1731
|
+
// explicit chat_register rejoin, so a genuinely new session is
|
|
1732
|
+
// unaffected.
|
|
1733
|
+
// A child deleted before completing never processed its payload.
|
|
1734
|
+
const parentID = childToParent.get(id);
|
|
1735
|
+
if (parentID) {
|
|
1736
|
+
extraction.requeueAccepted(parentID);
|
|
1737
|
+
extracting.delete(parentID);
|
|
1738
|
+
}
|
|
1739
|
+
childToParent.delete(id);
|
|
1740
|
+
parentSnapshots.delete(id);
|
|
1741
|
+
childMetrics.delete(id);
|
|
1742
|
+
extractionChildren.delete(id);
|
|
1743
|
+
journalChild(id);
|
|
1744
|
+
pendingWrapUp.delete(id);
|
|
1745
|
+
db.runtimeStateDelete("wrapup", id);
|
|
1746
|
+
// A deleted parent takes its accepted entries with it, and its
|
|
1747
|
+
// watchers die with it - the session that would receive their
|
|
1748
|
+
// notifications no longer exists.
|
|
1749
|
+
extraction.completeAccepted(id);
|
|
1750
|
+
extracting.delete(id);
|
|
1751
|
+
watchers.cancelSession(id);
|
|
1752
|
+
// Leaving the chat directory is the graceful-exit fast path; a
|
|
1753
|
+
// crashed process never fires session.deleted, so the heartbeat
|
|
1754
|
+
// staleness in chat_list is the covering signal for that case.
|
|
1755
|
+
try {
|
|
1756
|
+
db.unregisterChatSession(id);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
console.error(`[thatch] chat unregister on delete failed: ${err}`);
|
|
1759
|
+
}
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// session.compacted fires on successful compaction. Redundant with
|
|
1764
|
+
// experimental.compaction.autocontinue, but belt-and-suspenders: if
|
|
1765
|
+
// autocontinue didn't fire or wasn't installed, the event still clears
|
|
1766
|
+
// the compacting flag so nudges resume.
|
|
1767
|
+
if (event.type === "session.compacted") {
|
|
1768
|
+
compacting.delete(event.properties.sessionID);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
if (event.type !== "session.created") return;
|
|
1773
|
+
const id = event.properties.info.id;
|
|
1774
|
+
|
|
1775
|
+
// noReply hosts only: a host without noReply delivery would turn the
|
|
1776
|
+
// reminder into a real model turn replying to itself. The system
|
|
1777
|
+
// prompt (onSystemTransform) still carries the essentials everywhere.
|
|
1778
|
+
// The gate precedes the hygiene scan so v2 sessions (and any other
|
|
1779
|
+
// noReply-less host) do not pay a DB + repo scan for a skipped report.
|
|
1780
|
+
if (!caps.noReplyDelivery) return;
|
|
1781
|
+
|
|
1782
|
+
let hygiene: string | null = null;
|
|
1783
|
+
try {
|
|
1784
|
+
hygiene = await hygieneReport(db, repo, worktree, repoCache);
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
console.error(`[thatch] hygiene report failed: ${err}`);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
try {
|
|
1790
|
+
await caps.promptSession(
|
|
1791
|
+
id,
|
|
1792
|
+
{
|
|
1793
|
+
noReply: true,
|
|
1794
|
+
parts: [{ type: "text", text: sessionStartReminder(repo, hygiene), synthetic: true }],
|
|
1795
|
+
},
|
|
1796
|
+
"sync",
|
|
1797
|
+
);
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
console.error(`[thatch] session-start reminder failed: ${err}`);
|
|
1800
|
+
}
|
|
1801
|
+
},
|
|
1802
|
+
|
|
1803
|
+
dispose: async () => {
|
|
1804
|
+
stopVersionChecker();
|
|
1805
|
+
watchers.dispose();
|
|
1806
|
+
chatPoller.dispose();
|
|
1807
|
+
await releaseModel();
|
|
1808
|
+
// Note: the runtime_state journal is deliberately NOT cleared here.
|
|
1809
|
+
// Disposal happens before every v2 plugin reload, and the reloaded
|
|
1810
|
+
// setup() rehydrates from it; stale rows from dead processes are
|
|
1811
|
+
// pruned by the setup-time partition instead.
|
|
1812
|
+
db.close();
|
|
1813
|
+
},
|
|
1814
|
+
};
|
|
1815
|
+
}
|