@jeffober/thatch 0.2.2 → 0.2.4
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/bin/thatch +77 -46
- package/docs/dev/README.md +21 -25
- package/docs/dev/features/cli.md +1 -0
- package/docs/dev/features/extraction.md +120 -81
- package/docs/dev/features/nudge-pipeline.md +44 -39
- package/docs/dev/features/session-lifecycle.md +34 -20
- package/docs/dev/features/setup.md +7 -0
- package/docs/dev/features/watchers.md +9 -0
- package/docs/dev/gotchas.md +20 -26
- package/docs/dev/mcp-parity.md +16 -17
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/cli.md +2 -1
- package/docs/user/setup.md +13 -0
- package/docs/user/watchers.md +6 -0
- package/package.json +1 -1
- package/src/chat.ts +1 -1
- package/src/extract-queue.ts +16 -1
- package/src/extraction.ts +4 -3
- package/src/opencode/v2.ts +4 -3
- package/src/prompts.ts +12 -33
- package/src/runtime.ts +110 -107
- package/src/setup.ts +50 -0
- package/src/tool-defs.ts +39 -31
- package/src/watchers.ts +31 -0
package/src/prompts.ts
CHANGED
|
@@ -802,46 +802,25 @@ export function reflectCore(tool: ToolNamer): string {
|
|
|
802
802
|
}
|
|
803
803
|
|
|
804
804
|
/**
|
|
805
|
-
* Extraction nudge with escalation
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
805
|
+
* Extraction nudge with escalation - MCP hosts only (Claude Code / Cursor).
|
|
806
|
+
* There is deliberately no opencode variant: the opencode plugin extracts via
|
|
807
|
+
* its own child sessions (triggerExtraction at session idle) and no longer
|
|
808
|
+
* asks the model to run the handshake. Only hosts with no plugin lifecycle
|
|
809
|
+
* (each hook a fresh process, no session-creation API) still need a model-
|
|
810
|
+
* driven dispatch, and their file-backed queue (extract-queue.ts) is durable
|
|
811
|
+
* until the extractor's completion, so the handshake cannot lose entries
|
|
812
|
+
* there.
|
|
813
|
+
*
|
|
814
|
+
* The missedCount parameter tracks consecutive nudges delivered without any
|
|
815
|
+
* memory_remember call in between, escalating the tone from polite to
|
|
816
|
+
* insistent to shouting.
|
|
810
817
|
*
|
|
811
818
|
* The sessionID replaces the inline payload: the nudge tells the model to
|
|
812
819
|
* dispatch a sub-agent that calls get_extraction_payload to fetch the queued
|
|
813
820
|
* interactions as a tool response. This keeps the full payload out of the
|
|
814
821
|
* main session's context window.
|
|
815
|
-
*
|
|
816
|
-
* Call extractionNudgeOpencode for the opencode plugin path (task tool with
|
|
817
|
-
* background parameter) or extractionNudgeMcp for the Claude Code/Cursor
|
|
818
|
-
* CLI path (generic sub-agent wording).
|
|
819
822
|
*/
|
|
820
823
|
export function extractionNudge(
|
|
821
|
-
count: number,
|
|
822
|
-
missedCount: number,
|
|
823
|
-
toolName: string,
|
|
824
|
-
sessionID: string,
|
|
825
|
-
): string {
|
|
826
|
-
const isOpencode = toolName.startsWith("thatch_");
|
|
827
|
-
return isOpencode
|
|
828
|
-
? extractionNudgeOpencode(count, missedCount, sessionID)
|
|
829
|
-
: extractionNudgeMcp(count, missedCount, sessionID);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function extractionNudgeOpencode(
|
|
833
|
-
count: number,
|
|
834
|
-
missedCount: number,
|
|
835
|
-
sessionID: string,
|
|
836
|
-
): string {
|
|
837
|
-
const plural = count === 1 ? "" : "s";
|
|
838
|
-
const dispatch = 'Dispatch a task with background: true and subagent_type: "general" (required for thatch tool access)';
|
|
839
|
-
const drainTool = "thatch_extraction_done";
|
|
840
|
-
const fetchTool = "thatch_get_extraction_payload";
|
|
841
|
-
return buildExtractionNudge(count, missedCount, plural, dispatch, drainTool, fetchTool, sessionID);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function extractionNudgeMcp(
|
|
845
824
|
count: number,
|
|
846
825
|
missedCount: number,
|
|
847
826
|
sessionID: string,
|
package/src/runtime.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
compactionContext,
|
|
10
10
|
sessionStartReminder,
|
|
11
11
|
recallNudge,
|
|
12
|
-
extractionNudge,
|
|
13
12
|
extractionDirectPrompt,
|
|
14
13
|
predictionNudge,
|
|
15
14
|
behaviorNudge,
|
|
@@ -497,12 +496,6 @@ export async function createRuntime(input: {
|
|
|
497
496
|
// degradation: nudges stay off for that session, but no crash).
|
|
498
497
|
const compacting = new Set<string>();
|
|
499
498
|
|
|
500
|
-
// Per-session count of consecutive extraction nudges delivered without any
|
|
501
|
-
// memory_remember call in between. Drives nudge escalation: the agent gets
|
|
502
|
-
// a couple of polite chances, then the tone shifts to directive, then to
|
|
503
|
-
// all-caps shouting. Reset to 0 whenever the agent writes a memory.
|
|
504
|
-
const missedNudges = new Map<string, number>();
|
|
505
|
-
|
|
506
499
|
// Parent-child session mapping for cross-session buffer drain. opencode
|
|
507
500
|
// creates real child sessions with their own IDs for sub-agents; without
|
|
508
501
|
// this map, a memory_remember call in a child (e.g. a background
|
|
@@ -531,24 +524,21 @@ export async function createRuntime(input: {
|
|
|
531
524
|
|
|
532
525
|
// Parent sessions with an active direct-extraction child. When the parent
|
|
533
526
|
// goes idle with pending tool interactions, the plugin creates a child
|
|
534
|
-
// session and prompts it directly through the host's prompt capability
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// or is deleted. If direct extraction fails, the set is cleared so the nudge
|
|
540
|
-
// path takes over as a fallback on the next chat.message.
|
|
527
|
+
// session and prompts it directly through the host's prompt capability.
|
|
528
|
+
// This set prevents re-triggering if the parent goes idle again before
|
|
529
|
+
// the child finishes. Cleared when the child goes idle, errors, or is
|
|
530
|
+
// deleted. If direct extraction fails, the set is cleared so the next
|
|
531
|
+
// idle retries - there is no model-facing nudge fallback.
|
|
541
532
|
const extracting = new Set<string>();
|
|
542
533
|
|
|
543
534
|
// Child session IDs created by triggerExtraction (direct extraction only).
|
|
544
535
|
// The child idle handler uses this to distinguish plugin-created extraction
|
|
545
|
-
// children from task-dispatched sub-agents (code review specialists,
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
// them.
|
|
536
|
+
// children from task-dispatched sub-agents (code review specialists, any
|
|
537
|
+
// model-dispatched task). Only extraction children get the full cleanup:
|
|
538
|
+
// buffer drain, session deletion, toast. Non-extraction children get
|
|
539
|
+
// claim-scoped completion (a no-op for a child that never fetched a
|
|
540
|
+
// payload) so their sessions are not deleted out from under the task tool
|
|
541
|
+
// that dispatched them.
|
|
552
542
|
const extractionChildren = new Set<string>();
|
|
553
543
|
|
|
554
544
|
// Per-child-session extraction metrics for the toast notification. When the
|
|
@@ -876,7 +866,6 @@ export async function createRuntime(input: {
|
|
|
876
866
|
// that crashed before its payload call) has no claim and must not
|
|
877
867
|
// drop the accepted set - another extractor may hold it in flight.
|
|
878
868
|
extraction.completeClaimed(sessionID);
|
|
879
|
-
missedNudges.delete(parentID);
|
|
880
869
|
extracting.delete(parentID);
|
|
881
870
|
childKinds.delete(sessionID);
|
|
882
871
|
childToParent.delete(sessionID);
|
|
@@ -899,7 +888,6 @@ export async function createRuntime(input: {
|
|
|
899
888
|
// fetched leaves its entries held; requeueStaleAccepted bounds the
|
|
900
889
|
// linger.
|
|
901
890
|
extraction.completeClaimed(sessionID);
|
|
902
|
-
missedNudges.delete(parentID);
|
|
903
891
|
}
|
|
904
892
|
};
|
|
905
893
|
|
|
@@ -912,12 +900,17 @@ export async function createRuntime(input: {
|
|
|
912
900
|
// own idle event, and the finalize is idempotent if both paths race.
|
|
913
901
|
const reconcileRestoredChildren = async () => {
|
|
914
902
|
if (restoredChildren.size === 0) return;
|
|
903
|
+
// The reload window: how long to let the event pump repopulate the
|
|
904
|
+
// status map before judging a restored child "no longer running".
|
|
905
|
+
// Read at call time (not module load) so the test suite can shorten it
|
|
906
|
+
// per server() instance instead of waiting out the production 10s.
|
|
907
|
+
const windowMs = Number(process.env.THATCH_CHILD_RECONCILE_MS) || 10_000;
|
|
915
908
|
await new Promise<void>((resolve) => {
|
|
916
|
-
const t = setTimeout(resolve,
|
|
909
|
+
const t = setTimeout(resolve, windowMs);
|
|
917
910
|
t.unref?.();
|
|
918
911
|
});
|
|
919
912
|
// Dispose (a v2 plugin reload or process exit) may have closed the db
|
|
920
|
-
// during the
|
|
913
|
+
// during the window; finalizing into it would throw on the journal
|
|
921
914
|
// write. The reloaded instance's own reconciler owns the rows now.
|
|
922
915
|
if (disposed) return;
|
|
923
916
|
let live: Record<string, { type: string }> = {};
|
|
@@ -966,13 +959,16 @@ export async function createRuntime(input: {
|
|
|
966
959
|
|
|
967
960
|
// Direct extraction: create a child session linked to the parent and prompt
|
|
968
961
|
// it with the extraction payload. The child runs the fact-extractor skill,
|
|
969
|
-
// writes memories, and goes idle. The
|
|
970
|
-
//
|
|
971
|
-
//
|
|
962
|
+
// writes memories, and goes idle. The childToParent / claim machinery
|
|
963
|
+
// handles buffer cleanup. If this throws, the entries stay pending and
|
|
964
|
+
// the next idle retries.
|
|
972
965
|
//
|
|
973
|
-
// Does NOT call extraction.accept - entries stay in pending
|
|
974
|
-
//
|
|
975
|
-
//
|
|
966
|
+
// Does NOT call extraction.accept - entries stay in pending. The child's
|
|
967
|
+
// payload fetch claims them (the fetch is the delivery record); entries
|
|
968
|
+
// that arrive after the fetch stay pending for the next idle. The
|
|
969
|
+
// extracting set (not accept) suppresses nothing any more - there is no
|
|
970
|
+
// model-facing extraction nudge - but it still gates re-triggering while
|
|
971
|
+
// a child runs.
|
|
976
972
|
//
|
|
977
973
|
// The child ID is added to extractionChildren so the idle handler can
|
|
978
974
|
// distinguish plugin-created extraction children from task-dispatched
|
|
@@ -1060,7 +1056,6 @@ export async function createRuntime(input: {
|
|
|
1060
1056
|
// same logic, so the bodies live here instead of inline in the hook.
|
|
1061
1057
|
const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
|
|
1062
1058
|
extraction.consume(sessionID);
|
|
1063
|
-
missedNudges.delete(sessionID);
|
|
1064
1059
|
// A session that fetched its own payload (direct parent-side fetch or a
|
|
1065
1060
|
// child's payload call) completes its claim when it writes a memory -
|
|
1066
1061
|
// the write proves the delivered entries were processed.
|
|
@@ -1079,52 +1074,56 @@ export async function createRuntime(input: {
|
|
|
1079
1074
|
// Complete the parent's accepted entries (the extractor confirmed
|
|
1080
1075
|
// it is alive and saving). Completion is scoped to THIS child's
|
|
1081
1076
|
// claimed delivery - the fetch IS the delivery record, so whatever
|
|
1082
|
-
// the child received is consumed here and nothing else.
|
|
1083
|
-
// entries the child never received
|
|
1084
|
-
//
|
|
1085
|
-
// (The old dispatch-time snapshot drain was either a no-op under
|
|
1086
|
-
// claim semantics or a silent-loss vector for children that never
|
|
1087
|
-
// fetched - removed September 2026. The journal's snapshot field
|
|
1088
|
-
// remains for restart recovery.)
|
|
1077
|
+
// the child received is consumed here and nothing else. Accepted
|
|
1078
|
+
// entries the child never received stay held; requeueStaleAccepted
|
|
1079
|
+
// bounds their linger: no silent loss, no whole-set wipe.
|
|
1089
1080
|
extraction.completeClaimed(sessionID);
|
|
1090
1081
|
journalChild(sessionID);
|
|
1091
|
-
missedNudges.delete(parentID);
|
|
1092
|
-
journalChild(sessionID);
|
|
1093
|
-
missedNudges.delete(parentID);
|
|
1094
1082
|
}
|
|
1095
1083
|
};
|
|
1096
1084
|
const toolExtractionDone = (sessionID: string, targetID?: string) => {
|
|
1097
1085
|
const parentID = childToParent.get(sessionID);
|
|
1098
1086
|
if (parentID) {
|
|
1099
|
-
// The child's completion consumes its own claimed delivery.
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
missedNudges.delete(parentID);
|
|
1087
|
+
// The child's completion consumes its own claimed delivery. A child
|
|
1088
|
+
// that acks without ever fetching (its payload call errored, or it
|
|
1089
|
+
// acked before fetching) has no claim - it processed NOTHING, so its
|
|
1090
|
+
// ack must be a no-op here, never a whole-set drop: the accepted set
|
|
1091
|
+
// may be another extractor's in-flight delivery. Held entries without
|
|
1092
|
+
// a completer requeue via requeueStaleAccepted (15 min bound).
|
|
1093
|
+
extraction.completeClaimed(sessionID);
|
|
1108
1094
|
extraction.consume(sessionID);
|
|
1109
1095
|
} else if (targetID && targetID !== sessionID) {
|
|
1110
1096
|
// A completion ack for ANOTHER session - the fact-extractor sub-agent
|
|
1111
1097
|
// naming its parent. v2 links no parentID on dispatched sessions
|
|
1112
1098
|
// (SessionCreateInput has none), so childToParent cannot know this
|
|
1113
1099
|
// relationship and the explicit session_id is the only signal that
|
|
1114
|
-
// the target's accepted entries are accounted for.
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1100
|
+
// the target's accepted entries are accounted for. Completion is
|
|
1101
|
+
// scoped to the child's claimed delivery; a no-claim ack is a no-op
|
|
1102
|
+
// for the same reason as above - the ack proves nothing was
|
|
1103
|
+
// processed, so it must not drop entries another extractor holds.
|
|
1104
|
+
// (The old whole-set completeAccepted fallback here was the
|
|
1105
|
+
// accept-before-fetch interaction loss: a child acking - or mis-
|
|
1106
|
+
// targeting - before its fetch wiped the accepted set the new
|
|
1107
|
+
// extractor was about to claim, silently, every round. September
|
|
1108
|
+
// 2026 dispatch-loop report, defect 1.)
|
|
1109
|
+
extraction.completeClaimed(sessionID);
|
|
1110
|
+
} else {
|
|
1111
|
+
// A top-level session acking its own buffer. Two legitimate shapes:
|
|
1112
|
+
// - The wrap-up checklist (/thatch/compact, /thatch/exit): the
|
|
1113
|
+
// parent fetched its OWN payload (recording a claim on itself),
|
|
1114
|
+
// processed it inline, and acks - a no-save run writes no memory,
|
|
1115
|
+
// so this ack is the only completion signal for that claim.
|
|
1116
|
+
// - A legacy dispatch-time ack (MCP-style habit, or an in-flight
|
|
1117
|
+
// nudge from an older plugin version) with no claim: accept is
|
|
1118
|
+
// non-destructive - entries move to the holding area and a
|
|
1119
|
+
// crashed extractor's entries requeue via requeueStaleAccepted.
|
|
1120
|
+
// Note the opencode plugin no longer ASKS the parent to ack after a
|
|
1121
|
+
// dispatch - direct extraction (triggerExtraction at idle) owns the
|
|
1122
|
+
// whole lifecycle; the accept branch is tolerance, not protocol.
|
|
1120
1123
|
if (!extraction.completeClaimed(sessionID)) {
|
|
1121
|
-
extraction.
|
|
1124
|
+
extraction.accept(sessionID);
|
|
1122
1125
|
}
|
|
1123
|
-
missedNudges.delete(targetID);
|
|
1124
|
-
} else {
|
|
1125
|
-
extraction.accept(sessionID);
|
|
1126
1126
|
}
|
|
1127
|
-
missedNudges.delete(sessionID);
|
|
1128
1127
|
};
|
|
1129
1128
|
const toolMemoryForget = (sessionID: string) => {
|
|
1130
1129
|
const metrics = childMetrics.get(sessionID) ?? { new: 0, updated: 0, deleted: 0 };
|
|
@@ -1140,9 +1139,21 @@ export async function createRuntime(input: {
|
|
|
1140
1139
|
// signal consumes only them (claim semantics). Without a fetcher
|
|
1141
1140
|
// (no host session context) the fetch is a pure peek - legacy
|
|
1142
1141
|
// behavior.
|
|
1142
|
+
//
|
|
1143
|
+
// An omitted (or self-named) target resolves to the fetcher's own
|
|
1144
|
+
// session - which for a child is the WRONG key: the buffer belongs
|
|
1145
|
+
// to the parent. When the plugin knows the parent link
|
|
1146
|
+
// (childToParent - populated for plugin-created extraction
|
|
1147
|
+
// children, and for v1 model-dispatched ones), retarget to the
|
|
1148
|
+
// parent so the model never has to copy a session ID at all. v2
|
|
1149
|
+
// model-dispatched sub-agents carry no parentID, so no link exists
|
|
1150
|
+
// for them and the explicit-ID path remains the only signal.
|
|
1151
|
+
const resolved = (fetcherID && targetID === fetcherID && childToParent.has(fetcherID))
|
|
1152
|
+
? childToParent.get(fetcherID)!
|
|
1153
|
+
: targetID;
|
|
1143
1154
|
const all = fetcherID
|
|
1144
|
-
? extraction.claim(
|
|
1145
|
-
: [...extraction.peekAccepted(
|
|
1155
|
+
? extraction.claim(resolved, fetcherID)
|
|
1156
|
+
: [...extraction.peekAccepted(resolved), ...extraction.peek(resolved)];
|
|
1146
1157
|
if (all.length === 0) return null;
|
|
1147
1158
|
return extraction.buildPayload(all, repo);
|
|
1148
1159
|
},
|
|
@@ -1172,7 +1183,7 @@ export async function createRuntime(input: {
|
|
|
1172
1183
|
},
|
|
1173
1184
|
|
|
1174
1185
|
// 3. Tool buffering - feeds the extraction pipeline (direct extraction
|
|
1175
|
-
// via SDK
|
|
1186
|
+
// via SDK at session idle). Excluded tools:
|
|
1176
1187
|
// - thatch_*: extracting facts from memory ops would echo the store
|
|
1177
1188
|
// - skill/task: meta-tools that orchestrate agent behavior (loading
|
|
1178
1189
|
// skills, dispatching sub-agents). Buffering them creates a feedback
|
|
@@ -1471,48 +1482,34 @@ export async function createRuntime(input: {
|
|
|
1471
1482
|
// Best-effort. Version check failure must not block nudges.
|
|
1472
1483
|
}
|
|
1473
1484
|
|
|
1474
|
-
// Extraction
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1485
|
+
// Extraction is plugin-driven on opencode (triggerExtraction at
|
|
1486
|
+
// session idle) - there is deliberately NO model-facing extraction
|
|
1487
|
+
// nudge here. The old nudge-driven handshake (model dispatches a
|
|
1488
|
+
// sub-agent, parent acks with extraction_done, child fetches by
|
|
1489
|
+
// copied session id) raced its own state machine: acks landed before
|
|
1490
|
+
// fetches, no-claim completions wiped in-flight accepted sets, and
|
|
1491
|
+
// mis-targeted ids made every transition a silent no-op - the
|
|
1492
|
+
// buffered count then re-fired identically forever (the September
|
|
1493
|
+
// 2026 dispatch-loop report, defects 1 and 2). Accepted entries whose
|
|
1494
|
+
// completion signal never came (crashed extractor, a v2 dispatch
|
|
1495
|
+
// whose ack never named the parent) requeue at the idle site, the
|
|
1496
|
+
// only consumer of pending state now.
|
|
1483
1497
|
// Nudges skip injected prompts. A background-task completion, chat
|
|
1484
1498
|
// notification, or watcher notification arrives as a prompt whose
|
|
1485
1499
|
// parts are all synthetic - it is not user input, and nudging on it
|
|
1486
1500
|
// feeds the pipeline its own exhaust: an extractor sub-agent's
|
|
1487
|
-
// completion would otherwise
|
|
1501
|
+
// completion would otherwise re-trigger the nudge machinery for the
|
|
1488
1502
|
// entries the handling turn itself queued (the circular-nudge
|
|
1489
1503
|
// pattern). Real user messages carry at least one non-synthetic
|
|
1490
1504
|
// part, and an empty-parts message keeps nudging (unchanged
|
|
1491
1505
|
// behavior).
|
|
1492
1506
|
if (output.parts.length > 0 && (output.parts as any[]).every((p) => p.synthetic)) return;
|
|
1493
1507
|
// Task-dispatched sub-agent sessions may have restricted tool lists
|
|
1494
|
-
// that exclude the thatch tools,
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
// extractionDirectPrompt.
|
|
1508
|
+
// that exclude the thatch tools, so recall/prediction/behavior
|
|
1509
|
+
// nudges there only burn "No tool named ..." error rounds or add
|
|
1510
|
+
// noise. Extraction children are excluded: they run with full tools
|
|
1511
|
+
// and are driven by extractionDirectPrompt.
|
|
1499
1512
|
if (childToParent.has(input.sessionID) && !extractionChildren.has(input.sessionID)) return;
|
|
1500
|
-
if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
|
|
1501
|
-
const batch = extraction.peek(input.sessionID);
|
|
1502
|
-
const missed = missedNudges.get(input.sessionID) ?? 0;
|
|
1503
|
-
const text = extractionNudge(batch.length, missed, "thatch_memory_remember", input.sessionID);
|
|
1504
|
-
missedNudges.set(input.sessionID, missed + 1);
|
|
1505
|
-
|
|
1506
|
-
output.parts.push({
|
|
1507
|
-
id: `prt_thatch_${Math.random().toString(36).slice(2)}`,
|
|
1508
|
-
sessionID: input.sessionID,
|
|
1509
|
-
messageID: input.messageID ?? output.message.id,
|
|
1510
|
-
type: "text",
|
|
1511
|
-
text,
|
|
1512
|
-
synthetic: true,
|
|
1513
|
-
});
|
|
1514
|
-
return;
|
|
1515
|
-
}
|
|
1516
1513
|
|
|
1517
1514
|
// No extraction pending - try the prompt-aware recall nudge. Extract
|
|
1518
1515
|
// the user's prompt text from the message parts, embed it with the
|
|
@@ -1630,8 +1627,8 @@ export async function createRuntime(input: {
|
|
|
1630
1627
|
// entries go back to pending. Only extraction children (created by
|
|
1631
1628
|
// triggerExtraction, tracked in extractionChildren) get the full cleanup
|
|
1632
1629
|
// (buffer drain, session deletion, toast). Task-dispatched sub-agents
|
|
1633
|
-
// get
|
|
1634
|
-
//
|
|
1630
|
+
// get claim-scoped completion only, so their sessions are not deleted
|
|
1631
|
+
// out from under the task tool.
|
|
1635
1632
|
onEvent: async (event) => {
|
|
1636
1633
|
// Pause alerts: the LLM blocked on a human decision. Both lines
|
|
1637
1634
|
// publish these on the bus; v2's adapter translates them into the
|
|
@@ -1714,13 +1711,14 @@ export async function createRuntime(input: {
|
|
|
1714
1711
|
if (parentID && sessionID) {
|
|
1715
1712
|
// A child session went idle. Two cases:
|
|
1716
1713
|
// - Extraction child (created by triggerExtraction, tracked in
|
|
1717
|
-
// extractionChildren):
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1714
|
+
// extractionChildren): fire a toast with metrics, delete the
|
|
1715
|
+
// child session, clean up all maps. Completion of the parent's
|
|
1716
|
+
// accepted entries is claim-scoped inside.
|
|
1717
|
+
// - Task-dispatched sub-agent (code review specialist, any
|
|
1718
|
+
// model-dispatched task): complete what THIS child claimed via
|
|
1719
|
+
// its payload fetch, nothing more. Do NOT drain the buffer or
|
|
1720
|
+
// delete the session - the task tool that dispatched the
|
|
1721
|
+
// sub-agent needs to read its output.
|
|
1724
1722
|
await finishExtractionChild(sessionID, parentID);
|
|
1725
1723
|
return;
|
|
1726
1724
|
}
|
|
@@ -1844,8 +1842,13 @@ export async function createRuntime(input: {
|
|
|
1844
1842
|
})();
|
|
1845
1843
|
}
|
|
1846
1844
|
// Parent went idle - trigger direct extraction if there are pending
|
|
1847
|
-
// tool interactions and no extraction is already running.
|
|
1848
|
-
//
|
|
1845
|
+
// tool interactions and no extraction is already running. This is
|
|
1846
|
+
// the ONLY extraction trigger on opencode: no model-facing nudge
|
|
1847
|
+
// exists any more. Stale accepted entries (a crashed extractor,
|
|
1848
|
+
// a v2 dispatch whose ack never named the parent) requeue first so
|
|
1849
|
+
// this same idle re-extracts them instead of leaving them held
|
|
1850
|
+
// until the next idle.
|
|
1851
|
+
extraction.requeueStaleAccepted();
|
|
1849
1852
|
if (
|
|
1850
1853
|
sessionID &&
|
|
1851
1854
|
!compacting.has(sessionID) &&
|
package/src/setup.ts
CHANGED
|
@@ -643,6 +643,56 @@ export function setupCursor(
|
|
|
643
643
|
};
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Skills-only setup - refresh skill files without touching anything else
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
export interface SkillsOnlyResult {
|
|
651
|
+
/** Skill install report for the directory this run wrote to. */
|
|
652
|
+
skills: InstallReport;
|
|
653
|
+
/** Thatch skills in the opposite scope's dir, if any; never written by this run. */
|
|
654
|
+
otherScopeSkills: OtherScopeSkills | null;
|
|
655
|
+
global: boolean;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Installs only the skill files, skipping MCP config, instructions, hooks,
|
|
660
|
+
* and commands. Intended for refreshing skills in an already-set-up host
|
|
661
|
+
* without rewriting the rest of the configuration. Same scope rules as full
|
|
662
|
+
* setup: project-local runs write to the repo's host skills dir, global runs
|
|
663
|
+
* to the host config dir, and the other scope is probed (surfaced in the
|
|
664
|
+
* report) but never written.
|
|
665
|
+
*/
|
|
666
|
+
export function setupSkillsOnly(
|
|
667
|
+
host: HostKind,
|
|
668
|
+
global: boolean,
|
|
669
|
+
projectDir: string,
|
|
670
|
+
homeDir?: string,
|
|
671
|
+
): SkillsOnlyResult {
|
|
672
|
+
const { homedir } = require("node:os");
|
|
673
|
+
const home = homeDir ?? homedir();
|
|
674
|
+
|
|
675
|
+
const skillsDir = host === "claude"
|
|
676
|
+
? resolvePaths(global, projectDir, home).skillsDir
|
|
677
|
+
: resolveCursorPaths(global, projectDir, home).skillsDir;
|
|
678
|
+
|
|
679
|
+
// Same other-scope logic as the full setup paths: surface skills this run
|
|
680
|
+
// did not touch so the CLI can warn about leftovers.
|
|
681
|
+
const otherDir = host === "claude"
|
|
682
|
+
? (global
|
|
683
|
+
? join(projectDir, ".claude", "skills")
|
|
684
|
+
: join(claudeConfigDir(home), "skills"))
|
|
685
|
+
: (global
|
|
686
|
+
? join(projectDir, ".cursor", "skills")
|
|
687
|
+
: join(cursorConfigDir(home), "skills"));
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
skills: installSkills(skillsDir),
|
|
691
|
+
otherScopeSkills: otherScopeInfo(otherDir),
|
|
692
|
+
global,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
646
696
|
// ---------------------------------------------------------------------------
|
|
647
697
|
// Setup detection - check whether setup was completed for the current host
|
|
648
698
|
// ---------------------------------------------------------------------------
|
package/src/tool-defs.ts
CHANGED
|
@@ -24,8 +24,8 @@ import {
|
|
|
24
24
|
import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
|
|
25
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 Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
28
|
-
import { CHAT_STALE_MS, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
27
|
+
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, describeBaselineCheckRuns, type WatcherRegistry, type Watcher, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
28
|
+
import { CHAT_STALE_MS, MAX_BODY_LEN, chatLiveness, humanAge, renderChatParticipant, sortChatRoster, splitChatRoster, type ChatHostKind } from "./chat";
|
|
29
29
|
import { detectWorktreeKind } from "./git";
|
|
30
30
|
|
|
31
31
|
// Near-duplicate thresholds for matcher/prediction/behavior dedup at
|
|
@@ -602,7 +602,9 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
602
602
|
"the session_id from the extraction nudge to get the JSON payload " +
|
|
603
603
|
"(interactions, projectStore, globalStore). Then run the " +
|
|
604
604
|
"thatch-fact-extractor skill on the returned payload. Omit session_id " +
|
|
605
|
-
"when acting on the session you are running in."
|
|
605
|
+
"when acting on the session you are running in. If you are an " +
|
|
606
|
+
"extraction child whose parent link the plugin knows, an omitted or " +
|
|
607
|
+
"self-named session_id resolves to the parent's queue automatically.",
|
|
606
608
|
args: {
|
|
607
609
|
session_id: z.string().optional().describe(
|
|
608
610
|
"The session whose tool interactions are queued for extraction. " +
|
|
@@ -632,45 +634,47 @@ const getExtractionPayloadDef: ToolDef = {
|
|
|
632
634
|
};
|
|
633
635
|
|
|
634
636
|
/**
|
|
635
|
-
* Extraction-buffer acknowledgment
|
|
637
|
+
* Extraction-buffer acknowledgment.
|
|
636
638
|
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* includes no-save runs that write no memory. If the child errors or is
|
|
639
|
+
* In the fact-extractor sub-agent, call at the end of the run to mark the
|
|
640
|
+
* entries THIS child claimed via its payload fetch complete - never the
|
|
641
|
+
* whole accepted set, so a sibling sub-agent's completion cannot drop
|
|
642
|
+
* another extractor's in-flight payload. Completion includes no-save runs
|
|
643
|
+
* that write no memory. Pass session_id (the parent's) to drain the
|
|
644
|
+
* parent's file-backed queue on the MCP path. If the child errors or is
|
|
644
645
|
* deleted before either signal, the host requeues the entries so the facts
|
|
645
646
|
* are not lost.
|
|
646
647
|
*
|
|
648
|
+
* On the MCP path the parent session may also call this after dispatching
|
|
649
|
+
* the fact-extractor, to reset the nudge escalation counter. The ack does
|
|
650
|
+
* NOT drain the parent's queue - a drain at ack time deleted the queue
|
|
651
|
+
* before the sub-agent's payload fetch (the accept-before-fetch loss); the
|
|
652
|
+
* queue is consumed by the extractor's completion instead.
|
|
653
|
+
*
|
|
647
654
|
* The actual state changes happen in the host's post-tool hook
|
|
648
655
|
* (tool.execute.after for opencode, PostToolBatch/appendBatch for MCP) -
|
|
649
656
|
* this tool's execute function is a no-op confirmation. The tool exists so
|
|
650
|
-
* the model has a recognizable tool name to key on.
|
|
651
|
-
* file-backed queue is consumed on this call (or on any memory_remember),
|
|
652
|
-
* which is durable across interruption because the queue persists on disk
|
|
653
|
-
* until then.
|
|
657
|
+
* the model has a recognizable tool name to key on.
|
|
654
658
|
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
+
* On the opencode plugin path there is no parent ack any more: the plugin
|
|
660
|
+
* drives extraction end-to-end (triggerExtraction at session idle), so the
|
|
661
|
+
* model never runs the dispatch handshake. A parent-side extraction_done
|
|
662
|
+
* there is tolerated as a non-destructive accept (entries held, not
|
|
663
|
+
* dropped) for backward compatibility with an in-flight older nudge.
|
|
659
664
|
*/
|
|
660
665
|
const extractionDoneDef: ToolDef = {
|
|
661
666
|
name: "extraction_done",
|
|
662
667
|
description:
|
|
663
|
-
"Acknowledge extraction-buffer work. In
|
|
664
|
-
"
|
|
665
|
-
"
|
|
666
|
-
"
|
|
667
|
-
"entries complete, even when nothing was worth saving. Pass session_id " +
|
|
668
|
-
"when running as a sub-agent to drain the parent session's queue.",
|
|
668
|
+
"Acknowledge extraction-buffer work. In the fact-extractor sub-agent, " +
|
|
669
|
+
"call at the end of the run to mark the entries you claimed complete, " +
|
|
670
|
+
"even when nothing was worth saving. Pass session_id (the parent " +
|
|
671
|
+
"session's ID from the extraction nudge) to drain the parent's queue.",
|
|
669
672
|
args: {
|
|
670
673
|
session_id: z.string().optional().describe(
|
|
671
|
-
"The parent session's ID, when called from a sub-agent
|
|
672
|
-
"
|
|
673
|
-
"
|
|
674
|
+
"The parent session's ID, when called from a sub-agent. Drains the " +
|
|
675
|
+
"parent's file-backed queue (MCP path) and completes the entries " +
|
|
676
|
+
"this sub-agent claimed. Omit when called from the parent session " +
|
|
677
|
+
"itself.",
|
|
674
678
|
),
|
|
675
679
|
},
|
|
676
680
|
async execute(args, ctx) {
|
|
@@ -1523,12 +1527,14 @@ const watchCreateDef: ToolDef = {
|
|
|
1523
1527
|
const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
|
|
1524
1528
|
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
1525
1529
|
const w = result.watcher;
|
|
1530
|
+
const ciNote = w.events.includes("pr_ci") ? describeBaselineCheckRuns(w.state.checkRuns, w.state.headSha) : null;
|
|
1526
1531
|
return (
|
|
1527
1532
|
`[watching] ${w.repo}#${w.pr}\n` +
|
|
1528
1533
|
`id: ${w.id}\n` +
|
|
1529
1534
|
`events: ${w.events.join(", ")}\n` +
|
|
1530
1535
|
(w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
|
|
1531
1536
|
`head: ${w.state.headSha.slice(0, 7)} (${w.state.state}${w.state.merged ? ", merged" : ""})\n\n` +
|
|
1537
|
+
(ciNote ? `${ciNote}\n\n` : "") +
|
|
1532
1538
|
`The baseline is captured now - only changes from this point notify, ` +
|
|
1533
1539
|
`and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
|
|
1534
1540
|
`You will receive a system notification in this session when a watched event happens. ` +
|
|
@@ -1636,6 +1642,7 @@ const watchBranchCreateDef: ToolDef = {
|
|
|
1636
1642
|
);
|
|
1637
1643
|
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
1638
1644
|
const w = result.watcher;
|
|
1645
|
+
const ciNote = w.events.includes("branch_ci") ? describeBaselineCheckRuns(w.state.checkRuns, w.state.headSha) : null;
|
|
1639
1646
|
return (
|
|
1640
1647
|
`[watching] ${w.repo}@${w.branch}\n` +
|
|
1641
1648
|
`id: ${w.id}\n` +
|
|
@@ -1643,6 +1650,7 @@ const watchBranchCreateDef: ToolDef = {
|
|
|
1643
1650
|
(w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
|
|
1644
1651
|
(w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
|
|
1645
1652
|
`head: ${w.state.headSha.slice(0, 7)}\n\n` +
|
|
1653
|
+
(ciNote ? `${ciNote}\n\n` : "") +
|
|
1646
1654
|
`The baseline is captured now - only changes from this point notify, ` +
|
|
1647
1655
|
`and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
|
|
1648
1656
|
`You will receive a system notification in this session when a watched event happens. ` +
|
|
@@ -2039,7 +2047,7 @@ const chatSendDef: ToolDef = {
|
|
|
2039
2047
|
"sessions on other machines cannot be reached.",
|
|
2040
2048
|
args: {
|
|
2041
2049
|
to: z.string().describe("Recipient display name (see chat_list)."),
|
|
2042
|
-
body: z.string().describe(
|
|
2050
|
+
body: z.string().describe(`Message body, at most ${MAX_BODY_LEN} characters (longer is refused, not truncated). Keep it short and self-contained - the recipient may lack your context.`),
|
|
2043
2051
|
as: mcpIdentityArg(),
|
|
2044
2052
|
},
|
|
2045
2053
|
async execute(args, ctx, host) {
|
|
@@ -2144,8 +2152,8 @@ const chatBroadcastDef: ToolDef = {
|
|
|
2144
2152
|
"turn on it.",
|
|
2145
2153
|
args: {
|
|
2146
2154
|
body: z.string().describe(
|
|
2147
|
-
|
|
2148
|
-
"short and self-contained - the recipients may lack your context.",
|
|
2155
|
+
`Message body, delivered to every other registered session, at most ${MAX_BODY_LEN} characters ` +
|
|
2156
|
+
"(longer is refused, not truncated). Keep it short and self-contained - the recipients may lack your context.",
|
|
2149
2157
|
),
|
|
2150
2158
|
as: mcpIdentityArg(),
|
|
2151
2159
|
},
|