@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/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. Both the opencode plugin (src/runtime.ts)
806
- * and the Claude Code/Cursor CLI (bin/thatch flush-tools) call this with
807
- * their respective tool name prefix. The missedCount parameter tracks
808
- * consecutive nudges delivered without any memory_remember call in between,
809
- * escalating the tone from polite to insistent to shouting.
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
- // instead of injecting a nudge into the next user message. This set
536
- // suppresses the nudge path
537
- // while the child runs, and prevents re-triggering if the parent goes idle
538
- // again before the child finishes. Cleared when the child goes idle, errors,
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, the
546
- // nudge-path fact-extractor, any model-dispatched task). Only extraction
547
- // children get the full cleanup: buffer drain, session deletion, toast.
548
- // Non-extraction children get claim-scoped completion (a no-op for a
549
- // child that never fetched a payload) + missedNudges.reset so their
550
- // sessions are not deleted out from under the task tool that dispatched
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, 10_000);
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 10s window; finalizing into it would throw on the journal
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 existing childToParent / snapshot
970
- // drain machinery handles buffer cleanup. The nudge path is a fallback if
971
- // this throws.
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 so consumeSnapshot
974
- // can drain them by reference identity when the child writes a memory. The
975
- // extracting set (not accept) suppresses the nudge in chat.message.
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. Pending
1083
- // entries the child never received (post-fetch arrivals, or a child
1084
- // that never fetched) stay pending and re-nudge: no silent loss.
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. The
1100
- // whole-set fallback covers a child that acks without ever fetching
1101
- // (its payload call errored) - rare, and the ack is a deliberate
1102
- // processed signal, unlike the automatic idle path which is
1103
- // claim-scoped only.
1104
- if (!extraction.completeClaimed(sessionID)) {
1105
- extraction.completeAccepted(parentID);
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. Without this
1115
- // branch the accepted queue lingers forever and a tool-dense session
1116
- // re-nudges every idle on never-draining exhaust. Completion is
1117
- // scoped to the child's claimed delivery; the whole-set fallback
1118
- // covers the no-claim case (a fetch that errored, or the MCP path
1119
- // where the file-backed queue was drained instead).
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.completeAccepted(targetID);
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(targetID, fetcherID)
1145
- : [...extraction.peekAccepted(targetID), ...extraction.peek(targetID)];
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 or nudge fallback). Excluded tools:
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 nudge (fallback path). Skipped when the extracting set
1475
- // is active - that means a direct-extraction child session is running
1476
- // and the plugin is handling extraction via the SDK. The nudge fires
1477
- // here only when direct extraction was never triggered or threw.
1478
- // Accepted entries whose completion signal never came (crashed
1479
- // extractor, or a v2 dispatch whose ack never named the parent)
1480
- // requeue here so they are honestly re-extracted instead of lingering
1481
- // as silent loss.
1482
- extraction.requeueStaleAccepted();
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 fire a fresh extraction nudge for the
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, and even where the tools exist the
1495
- // child is driven by its dispatch prompt, so nudges only burn "No
1496
- // tool named ..." error rounds or add noise there. Extraction
1497
- // children are excluded: they run with full tools and are driven by
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 the old behavior (completeAccepted + missedNudges.reset) so their
1634
- // sessions are not deleted out from under the task tool.
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): drain the parent's snapshot, fire a toast
1718
- // with metrics, delete the child session, clean up all maps.
1719
- // - Task-dispatched sub-agent (code review specialist, nudge-path
1720
- // fact-extractor, any model-dispatched task): complete the
1721
- // parent's accepted entries and reset missedNudges. Do NOT drain
1722
- // the buffer or delete the session - the task tool that
1723
- // dispatched the sub-agent needs to read its output.
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. Falls
1848
- // back to the nudge path on the next chat.message if this throws.
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, with AMQP-style accept/complete roles.
637
+ * Extraction-buffer acknowledgment.
636
638
  *
637
- * Called in a PARENT session after dispatching the fact-extractor, it accepts
638
- * the buffer: entries move to a holding area and the nudge quiets, but they
639
- * are not dropped until the extractor completes. Called in a CHILD extractor
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
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. In the MCP path the
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
- * The optional session_id parameter lets a sub-agent drain the PARENT's
656
- * file-backed queue on the MCP path, where the sub-agent's session ID
657
- * differs from the parent's and appendBatch's self-detection would drain
658
- * the wrong (empty) queue.
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 a parent session, call after " +
664
- "dispatching the fact-extractor to a sub-agent: accepts the buffer " +
665
- "(quiets the nudge) while keeping entries until the extractor completes. " +
666
- "In the fact-extractor sub-agent, call at the end of the run to mark the " +
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 on the MCP " +
672
- "path. Drains the parent's file-backed queue. Omit when called from " +
673
- "the parent session itself (opencode path handles drain via hooks).",
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("Message body. Keep it short and self-contained - the recipient may lack your context."),
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
- "Message body, delivered to every other registered session. Keep it " +
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
  },