@jeffober/thatch 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/docs/dev/README.md +11 -7
- package/docs/dev/features/cross-session-chat.md +8 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/nudge-pipeline.md +12 -0
- package/docs/dev/features/opencode-plugin.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +58 -1
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/README.md +1 -1
- package/docs/user/memory.md +1 -1
- package/package.json +1 -1
- package/src/chat.ts +17 -0
- package/src/db.ts +77 -12
- package/src/extraction.ts +89 -3
- package/src/index.ts +45 -0
- package/src/opencode/v2.ts +48 -4
- package/src/prompts.ts +104 -30
- package/src/runtime.ts +261 -72
- package/src/tool-defs.ts +156 -28
- package/src/watchers.ts +14 -0
package/src/tool-defs.ts
CHANGED
|
@@ -22,9 +22,9 @@ import {
|
|
|
22
22
|
type NotificationPrefs,
|
|
23
23
|
} from "./config";
|
|
24
24
|
import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
|
|
25
|
-
import { predictionVerb, chatInboxFrame } from "./prompts";
|
|
25
|
+
import { predictionVerb, formatWhenLine, chatInboxFrame } from "./prompts";
|
|
26
26
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
|
|
27
|
-
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
27
|
+
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
28
28
|
import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
29
29
|
import { detectWorktreeKind } from "./git";
|
|
30
30
|
|
|
@@ -100,12 +100,16 @@ export interface CoreContext {
|
|
|
100
100
|
db: ThatchDB;
|
|
101
101
|
model: EmbeddingModel;
|
|
102
102
|
defaultStore: string;
|
|
103
|
-
/**
|
|
104
|
-
* JSON payload (same shape as buildExtractionPayload)
|
|
105
|
-
* no interactions are queued.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
|
|
103
|
+
/** Fetches a session's extraction payload for a fetcher. Returns the
|
|
104
|
+
* serialized JSON payload (same shape as buildExtractionPayload), or
|
|
105
|
+
* null when no interactions are queued. With a fetcherID, the fetch is
|
|
106
|
+
* a CLAIM: the fetched entries stay accepted and that fetcher's
|
|
107
|
+
* completion signal consumes only this delivery (sibling sub-agents
|
|
108
|
+
* going idle cannot drop it). Without a fetcherID, the fetch is a pure
|
|
109
|
+
* peek. Used by the get_extraction_payload tool so the sub-agent
|
|
110
|
+
* fetches the payload as a tool response instead of receiving it
|
|
111
|
+
* inline in the nudge text. */
|
|
112
|
+
extractionPayloadProvider?: (sessionID: string, fetcherID?: string) => string | null;
|
|
109
113
|
/** Drains a session's extraction queue. On the MCP path this deletes the
|
|
110
114
|
* file-backed queue and resets the missed-nudge counter. On the opencode
|
|
111
115
|
* path this is unused (the tool.execute.after hook handles drain via
|
|
@@ -165,6 +169,73 @@ export interface ToolDef {
|
|
|
165
169
|
// Formatting helpers - shared by all tools that render entries
|
|
166
170
|
// ---------------------------------------------------------------------------
|
|
167
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Human-readable age of a memory, rendered at output time so the reading
|
|
174
|
+
* model can weigh staleness semantically (an ISO timestamp does not get
|
|
175
|
+
* weighed; "47 days ago" does). Keyed on updated_at - the last CONTENT
|
|
176
|
+
* change - and deliberately NOT on last_recalled_at: hygiene's staleness
|
|
177
|
+
* sweep treats a recall as refreshing the clock, but for content accuracy
|
|
178
|
+
* the opposite holds. A memory recalled yesterday but written eight months
|
|
179
|
+
* ago is exactly the case where the world may have moved on since the
|
|
180
|
+
* claim was recorded. Freshness-of-use is not freshness-of-claim.
|
|
181
|
+
*
|
|
182
|
+
* Bucket boundaries are a formatting choice, independent of hygiene's
|
|
183
|
+
* 90-day staleness window (db.ts STALE_DAYS) - the two 90s are a
|
|
184
|
+
* coincidence, do not couple them. Compact sibling: humanAge() in
|
|
185
|
+
* chat.ts renders roster-style "5m ago" for chat surfaces; keep the two
|
|
186
|
+
* apart when changing either.
|
|
187
|
+
*/
|
|
188
|
+
export function formatAge(iso: string, now: Date = new Date()): string {
|
|
189
|
+
const then = new Date(iso).getTime();
|
|
190
|
+
if (!Number.isFinite(then)) return "unknown age";
|
|
191
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
|
|
192
|
+
if (seconds < 60) return "just now";
|
|
193
|
+
const minutes = Math.floor(seconds / 60);
|
|
194
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
195
|
+
const hours = Math.floor(minutes / 60);
|
|
196
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
197
|
+
const days = Math.floor(hours / 24);
|
|
198
|
+
if (days < 90) return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
199
|
+
if (days < 365) return `${Math.floor(days / 30)} month${Math.floor(days / 30) === 1 ? "" : "s"} ago`;
|
|
200
|
+
const years = Math.floor(days / 365);
|
|
201
|
+
return `${years} year${years === 1 ? "" : "s"} ago`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The age sentence inserted between the meta line and a memory's content,
|
|
206
|
+
* so the model weighs staleness BEFORE reading the claim it qualifies.
|
|
207
|
+
*/
|
|
208
|
+
function ageLine(iso: string): string {
|
|
209
|
+
return `This memory was last updated ${formatAge(iso)}.`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Caveat attached to memory_recall and memory_show tool output at render
|
|
214
|
+
* time - the persisted rows are untouched. The sibling of chatInboxFrame's
|
|
215
|
+
* untrusted-content framing in prompts.ts: a content-classification line
|
|
216
|
+
* that rides with the claims it qualifies, at the moment they enter the
|
|
217
|
+
* model's context, instead of a general rule it has to recall from the
|
|
218
|
+
* system prompt (or a nudge it learns to tune out). memory_list is
|
|
219
|
+
* excluded: a labels-only listing carries no artifact claims to qualify.
|
|
220
|
+
*
|
|
221
|
+
* Tool names use the BARE form (memory_remember, memory_forget) on
|
|
222
|
+
* purpose: tool-defs.ts is host-shared and has no host context. On MCP
|
|
223
|
+
* hosts the bare-name convention is established by the instructions in
|
|
224
|
+
* prompts.ts ("Bare names used below for readability"); on opencode the
|
|
225
|
+
* system prompt uses thatch_ spellings, but the model sees its own tool
|
|
226
|
+
* list and maps the bare name without a stated convention. If either
|
|
227
|
+
* convention changes, update this string with it.
|
|
228
|
+
*/
|
|
229
|
+
const memoryCaveat =
|
|
230
|
+
`[thatch] Memories are a point-in-time record - a guide to where to look, ` +
|
|
231
|
+
`not a source of truth. Verify artifact claims (code, docs, tickets, git) ` +
|
|
232
|
+
`against current sources before relying on them; if a memory has drifted, ` +
|
|
233
|
+
`correct it with memory_remember (overwrite: true) or memory_forget.`;
|
|
234
|
+
|
|
235
|
+
function withMemoryCaveat(body: string): string {
|
|
236
|
+
return `${memoryCaveat}\n\n${body}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
168
239
|
function formatEntry(
|
|
169
240
|
entry: Awaited<ReturnType<ThatchDB["showEntry"]>>,
|
|
170
241
|
): string | null {
|
|
@@ -175,8 +246,8 @@ function formatEntry(
|
|
|
175
246
|
if (entry.branch) meta += ` branch:${entry.branch}`;
|
|
176
247
|
if (entry.confidence) meta += ` confidence:${entry.confidence}`;
|
|
177
248
|
if (entry.archived) meta += " archived:true";
|
|
178
|
-
meta += ` created:${entry.created_at}
|
|
179
|
-
parts.push(meta, "", entry.content);
|
|
249
|
+
meta += ` created:${entry.created_at}`;
|
|
250
|
+
parts.push(meta, "", ageLine(entry.updated_at), entry.content);
|
|
180
251
|
return parts.join("\n");
|
|
181
252
|
}
|
|
182
253
|
|
|
@@ -185,9 +256,8 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
|
|
|
185
256
|
if (entry.branch) meta += ` branch:${entry.branch}`;
|
|
186
257
|
if (entry.confidence) meta += ` confidence:${entry.confidence}`;
|
|
187
258
|
if (entry.archived) meta += " archived:true";
|
|
188
|
-
meta += ` updated:${entry.updated_at}`;
|
|
189
259
|
const score = entry._score.toFixed(3);
|
|
190
|
-
return `[${meta}] [score:${score}]\n${entry.content}`;
|
|
260
|
+
return `[${meta}] [score:${score}]\n${ageLine(entry.updated_at)}\n${entry.content}`;
|
|
191
261
|
}
|
|
192
262
|
|
|
193
263
|
/**
|
|
@@ -195,8 +265,26 @@ function formatRecallResult(entry: MemoryRow & { _score: number }): string {
|
|
|
195
265
|
* so a topic fragmented across N entries reads as one cluster instead of
|
|
196
266
|
* O(N²) pairs. Verdicts stay pairwise (markChecked) - this is presentation only.
|
|
197
267
|
*/
|
|
198
|
-
|
|
199
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Self-heal the watcher registry from the journal before a watch tool reads
|
|
270
|
+
* it. The runtime (and its registry) is rebuilt on session directory changes
|
|
271
|
+
* and plugin reloads, leaving journaled watchers absent from the live set
|
|
272
|
+
* while the old instance's poller keeps delivering: watch_list reported "No
|
|
273
|
+
* active watchers" and watch_cancel "No watcher in this session" for
|
|
274
|
+
* watchers that were alive and firing (observed + reproduced September
|
|
275
|
+
* 2026). Hydrating the CURRENT process's journal row for the session makes
|
|
276
|
+
* every watch-tool call converge. Cross-process (dormant) rows stay owned
|
|
277
|
+
* by the runtime's scanDormantWatchers and its revalidation.
|
|
278
|
+
*/
|
|
279
|
+
function reconcileSessionWatchers(ctx: CoreContext, sessionID: string): void {
|
|
280
|
+
if (!ctx.watchers) return;
|
|
281
|
+
const row = ctx.db
|
|
282
|
+
.runtimeStateAll()
|
|
283
|
+
.find((r) => r.kind === "watchers" && r.sessionID === sessionID && r.pid === process.pid);
|
|
284
|
+
if (row && Array.isArray(row.value)) ctx.watchers.hydrate(row.value as Watcher[]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderClusters(candidates: DedupCandidate[]): string { const parent = new Map<string, string>();
|
|
200
288
|
const find = (x: string): string => {
|
|
201
289
|
let root = parent.get(x) ?? x;
|
|
202
290
|
while (root !== (parent.get(root) ?? root)) root = parent.get(root) ?? root;
|
|
@@ -238,8 +326,9 @@ const rememberDef: ToolDef = {
|
|
|
238
326
|
"Each memory should be focused on a single topic. Write memories as " +
|
|
239
327
|
"reference material for a future instance of yourself with zero context.",
|
|
240
328
|
args: {
|
|
241
|
-
label: z.string().describe(
|
|
242
|
-
"Short descriptive title. Used for deduplication - same label in the same store is the same entry."
|
|
329
|
+
label: z.string().optional().describe(
|
|
330
|
+
"Short descriptive title. Used for deduplication - same label in the same store is the same entry. " +
|
|
331
|
+
"Omit to derive it from the content's first heading or line (capped at 80 characters).",
|
|
243
332
|
),
|
|
244
333
|
content: z.string().describe(
|
|
245
334
|
"The information to remember. Self-contained, understandable without session context.",
|
|
@@ -264,8 +353,32 @@ const rememberDef: ToolDef = {
|
|
|
264
353
|
},
|
|
265
354
|
async execute(args, ctx) {
|
|
266
355
|
const store = resolveStore(args, ctx);
|
|
267
|
-
const
|
|
268
|
-
|
|
356
|
+
const rawContent = (args.content as string | undefined)?.trim();
|
|
357
|
+
if (!rawContent) {
|
|
358
|
+
return "No content provided - pass the information to remember in the content parameter.";
|
|
359
|
+
}
|
|
360
|
+
let label = (args.label as string | undefined)?.trim();
|
|
361
|
+
let content = rawContent;
|
|
362
|
+
if (!label) {
|
|
363
|
+
// Label omitted (or arrived under a misnamed key that schema
|
|
364
|
+
// validation stripped): derive it from the content so the write
|
|
365
|
+
// still lands instead of erroring. A leading markdown heading
|
|
366
|
+
// becomes the label and is stripped from the stored content
|
|
367
|
+
// (heading-only content keeps the heading text as the body so the
|
|
368
|
+
// entry is never empty). The 80-char cap keeps a derived label -
|
|
369
|
+
// which can be a whole first line - from bloating list output and
|
|
370
|
+
// slug keys; matches deriveTitle's cap in extraction.ts.
|
|
371
|
+
const firstLine = rawContent.split("\n")[0]?.trim() ?? "";
|
|
372
|
+
const heading = /^#{1,6}\s+(.+)$/.exec(firstLine);
|
|
373
|
+
if (heading) {
|
|
374
|
+
label = heading[1].trim().slice(0, 80);
|
|
375
|
+
const rest = rawContent.split("\n").slice(1).join("\n").trim();
|
|
376
|
+
content = rest.length > 0 ? rest : heading[1].trim();
|
|
377
|
+
} else {
|
|
378
|
+
label = firstLine.slice(0, 80);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
content = `# ${label}\n\n${content}`;
|
|
269
382
|
const embedding = await ctx.model.passageEmbed(content);
|
|
270
383
|
|
|
271
384
|
const similar = ctx.db.findSimilar(store, embedding, {
|
|
@@ -332,7 +445,7 @@ const recallDef: ToolDef = {
|
|
|
332
445
|
|
|
333
446
|
if (results.length === 0) return "No matching memories found.";
|
|
334
447
|
|
|
335
|
-
return results.map(formatRecallResult).join("\n\n-----\n\n");
|
|
448
|
+
return withMemoryCaveat(results.map(formatRecallResult).join("\n\n-----\n\n"));
|
|
336
449
|
},
|
|
337
450
|
};
|
|
338
451
|
|
|
@@ -382,7 +495,7 @@ const showDef: ToolDef = {
|
|
|
382
495
|
|
|
383
496
|
if (!entry) return `No memory labeled "${args.label as string}" found in store "${store}".`;
|
|
384
497
|
|
|
385
|
-
return formatEntry(entry) || "Error formatting entry.";
|
|
498
|
+
return withMemoryCaveat(formatEntry(entry) || "Error formatting entry.");
|
|
386
499
|
},
|
|
387
500
|
};
|
|
388
501
|
|
|
@@ -510,7 +623,7 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
510
623
|
if (!sessionID) {
|
|
511
624
|
return 'session_id is required on this host (it has no session context). Pass the parent session\'s session_id from the extraction nudge.';
|
|
512
625
|
}
|
|
513
|
-
const payload = ctx.extractionPayloadProvider(sessionID);
|
|
626
|
+
const payload = ctx.extractionPayloadProvider(sessionID, host?.sessionID);
|
|
514
627
|
if (!payload) {
|
|
515
628
|
return "No queued tool interactions found for this session.";
|
|
516
629
|
}
|
|
@@ -524,8 +637,10 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
524
637
|
* Called in a PARENT session after dispatching the fact-extractor, it accepts
|
|
525
638
|
* the buffer: entries move to a holding area and the nudge quiets, but they
|
|
526
639
|
* are not dropped until the extractor completes. Called in a CHILD extractor
|
|
527
|
-
* at the end of its run, it completes the
|
|
528
|
-
*
|
|
640
|
+
* at the end of its run, it completes the entries THAT child claimed via its
|
|
641
|
+
* payload fetch - never the whole accepted set, so a sibling sub-agent's
|
|
642
|
+
* completion cannot drop another extractor's in-flight payload. Completion
|
|
643
|
+
* includes no-save runs that write no memory. If the child errors or is
|
|
529
644
|
* deleted before either signal, the host requeues the entries so the facts
|
|
530
645
|
* are not lost.
|
|
531
646
|
*
|
|
@@ -599,8 +714,7 @@ const predictionQueryDef: ToolDef = {
|
|
|
599
714
|
if (scored.length === 0) return "No matching predictions found.";
|
|
600
715
|
return scored.map((s) => {
|
|
601
716
|
const verb = predictionVerb(s.evidence_count);
|
|
602
|
-
return
|
|
603
|
-
`When ${s.matcher_description}: ${verb} ${s.statement}`;
|
|
717
|
+
return formatWhenLine(s.confidence, s.evidence_count, s.matcher_description, s.statement, verb);
|
|
604
718
|
}).join("\n");
|
|
605
719
|
},
|
|
606
720
|
};
|
|
@@ -612,7 +726,11 @@ const predictionUpdateDef: ToolDef = {
|
|
|
612
726
|
"model. Use when the user corrects you, answers a question, or " +
|
|
613
727
|
"provides a clear signal about their preferences or decision-making " +
|
|
614
728
|
"strategy. The tool handles matcher and prediction lookup, dedup, " +
|
|
615
|
-
"and confidence adjustment automatically."
|
|
729
|
+
"and confidence adjustment automatically. " +
|
|
730
|
+
"ALL FOUR of matcher, prediction, signal, and rationale are required " +
|
|
731
|
+
"in the SAME call - calls omitting any of them fail validation. " +
|
|
732
|
+
"signal must be exactly one of: create, confirm, disconfirm, soft " +
|
|
733
|
+
"(use \"create\" for a new observation - never a prose description).",
|
|
616
734
|
args: {
|
|
617
735
|
matcher: z.string().describe(
|
|
618
736
|
"Description of the situation. What decision was being made? Word it " +
|
|
@@ -622,7 +740,11 @@ const predictionUpdateDef: ToolDef = {
|
|
|
622
740
|
"incident never fires for the next one.",
|
|
623
741
|
),
|
|
624
742
|
prediction: z.string().describe(
|
|
625
|
-
"The user's preference or tendency in this situation."
|
|
743
|
+
"The user's preference or tendency in this situation. Phrase it as a " +
|
|
744
|
+
"tendency starting with a base-form verb (e.g. \"prefer X over Y\", " +
|
|
745
|
+
"\"push back on Z\") rather than " +
|
|
746
|
+
"a full sentence - the nudge and query renderers prefix the evidence " +
|
|
747
|
+
"verb (\"you tend to ...\") onto it.",
|
|
626
748
|
),
|
|
627
749
|
signal: z.enum(["confirm", "disconfirm", "soft", "create"]).describe(
|
|
628
750
|
"What happened: confirm (user confirmed the prediction), " +
|
|
@@ -782,7 +904,8 @@ const behaviorCodifyDef: ToolDef = {
|
|
|
782
904
|
"tests before touching the area; read a large function fully before " +
|
|
783
905
|
"editing it. When a review, bug, or incident reveals a CLASS of " +
|
|
784
906
|
"mistake you are prone to (not a one-off), codify a guard against the " +
|
|
785
|
-
"class."
|
|
907
|
+
"class. ALL THREE of situation, behavior, and rationale are required " +
|
|
908
|
+
"in the SAME call - calls omitting any of them fail validation.",
|
|
786
909
|
args: {
|
|
787
910
|
situation: z.string().describe(
|
|
788
911
|
"Description of the situation that triggers this behavior. What context " +
|
|
@@ -1394,6 +1517,7 @@ const watchCreateDef: ToolDef = {
|
|
|
1394
1517
|
if (!ctx.watchers) {
|
|
1395
1518
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1396
1519
|
}
|
|
1520
|
+
reconcileSessionWatchers(ctx, host.sessionID);
|
|
1397
1521
|
const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
|
|
1398
1522
|
const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
|
|
1399
1523
|
const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
|
|
@@ -1433,6 +1557,7 @@ const watchListDef: ToolDef = {
|
|
|
1433
1557
|
if (!ctx.watchers) {
|
|
1434
1558
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1435
1559
|
}
|
|
1560
|
+
reconcileSessionWatchers(ctx, host.sessionID);
|
|
1436
1561
|
const watchers = ctx.watchers.listForSession(host.sessionID);
|
|
1437
1562
|
if (watchers.length === 0) return "No active watchers.";
|
|
1438
1563
|
const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
|
|
@@ -1498,6 +1623,7 @@ const watchBranchCreateDef: ToolDef = {
|
|
|
1498
1623
|
if (!ctx.watchers) {
|
|
1499
1624
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1500
1625
|
}
|
|
1626
|
+
reconcileSessionWatchers(ctx, host.sessionID);
|
|
1501
1627
|
const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
|
|
1502
1628
|
const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
|
|
1503
1629
|
const result = await ctx.watchers.createBranch(
|
|
@@ -1582,6 +1708,7 @@ const watchCommandCreateDef: ToolDef = {
|
|
|
1582
1708
|
if (!ctx.watchers) {
|
|
1583
1709
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1584
1710
|
}
|
|
1711
|
+
reconcileSessionWatchers(ctx, host.sessionID);
|
|
1585
1712
|
const dir = (args.cd as string | undefined) ?? ctx.projectDir;
|
|
1586
1713
|
if (!dir) {
|
|
1587
1714
|
return "Watching is unavailable: no project directory was wired by this host.";
|
|
@@ -1622,6 +1749,7 @@ const watchCancelDef: ToolDef = {
|
|
|
1622
1749
|
if (!ctx.watchers) {
|
|
1623
1750
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1624
1751
|
}
|
|
1752
|
+
reconcileSessionWatchers(ctx, host.sessionID);
|
|
1625
1753
|
const cancelled = ctx.watchers.cancel(host.sessionID, args.id as string);
|
|
1626
1754
|
if (!cancelled) return `No watcher "${args.id}" in this session.`;
|
|
1627
1755
|
return `[cancelled] ${args.id}`;
|
package/src/watchers.ts
CHANGED
|
@@ -854,6 +854,18 @@ export class WatcherRegistry {
|
|
|
854
854
|
commandRunner: CommandRunner;
|
|
855
855
|
commandTimeoutMs: number;
|
|
856
856
|
};
|
|
857
|
+
/**
|
|
858
|
+
* The most recently constructed instance in this process. opencode v2
|
|
859
|
+
* re-runs plugin setup() when the session's directory changes
|
|
860
|
+
* (session_move between directories of the same repo) or on plugin-file
|
|
861
|
+
* changes; each setup() built a fresh registry while the OLD instance's
|
|
862
|
+
* poller timer kept running - the watch tools then queried an empty
|
|
863
|
+
* registry ("No active watchers" / "No watcher in this session") while
|
|
864
|
+
* delivery kept flowing from the orphaned poller. The handoff stops the
|
|
865
|
+
* predecessor's poller at construction so at most one registry polls per
|
|
866
|
+
* process; the watch tools' journal reconcile covers the state transfer.
|
|
867
|
+
*/
|
|
868
|
+
static #live: WatcherRegistry | undefined;
|
|
857
869
|
|
|
858
870
|
constructor(options: WatcherRegistryOptions) {
|
|
859
871
|
this.#opts = {
|
|
@@ -865,6 +877,8 @@ export class WatcherRegistry {
|
|
|
865
877
|
...options,
|
|
866
878
|
};
|
|
867
879
|
this.#journal = options.journal;
|
|
880
|
+
WatcherRegistry.#live?.stop();
|
|
881
|
+
WatcherRegistry.#live = this;
|
|
868
882
|
}
|
|
869
883
|
|
|
870
884
|
// -- Lifecycle -----------------------------------------------------------
|