@jeffober/thatch 0.1.47 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +43 -4
  2. package/artifacts/skills/thatch-plan-refinement.md +2 -0
  3. package/bin/thatch +31 -16
  4. package/docs/dev/README.md +28 -6
  5. package/docs/dev/features/behavior-engine.md +3 -2
  6. package/docs/dev/features/cicd.md +1 -1
  7. package/docs/dev/features/compaction-recovery.md +1 -1
  8. package/docs/dev/features/cross-session-chat.md +4 -2
  9. package/docs/dev/features/extraction.md +10 -2
  10. package/docs/dev/features/hygiene.md +3 -1
  11. package/docs/dev/features/multi-host.md +4 -2
  12. package/docs/dev/features/notifications.md +121 -4
  13. package/docs/dev/features/nudge-pipeline.md +4 -2
  14. package/docs/dev/features/opencode-plugin.md +133 -0
  15. package/docs/dev/features/qa-system.md +43 -0
  16. package/docs/dev/features/repo-identity.md +1 -1
  17. package/docs/dev/features/session-lifecycle.md +5 -2
  18. package/docs/dev/features/watchers.md +12 -5
  19. package/docs/dev/gotchas.md +36 -3
  20. package/docs/dev/mcp-parity.md +4 -3
  21. package/docs/dev/setup-and-hooks.md +6 -1
  22. package/docs/plans/llm-alerts.md +287 -0
  23. package/docs/plans/prediction-consolidation.md +3 -3
  24. package/docs/user/README.md +7 -3
  25. package/docs/user/notifications.md +63 -4
  26. package/docs/user/setup.md +4 -0
  27. package/docs/user/watchers.md +15 -7
  28. package/package.json +3 -1
  29. package/src/alerts.ts +360 -0
  30. package/src/capabilities.ts +174 -0
  31. package/src/chat.ts +63 -5
  32. package/src/commands.ts +44 -5
  33. package/src/config.ts +32 -1
  34. package/src/db.ts +80 -0
  35. package/src/embeddings.ts +43 -0
  36. package/src/extraction.ts +124 -0
  37. package/src/index.ts +38 -1345
  38. package/src/opencode/v1.ts +54 -0
  39. package/src/opencode/v2.ts +561 -0
  40. package/src/os-args.ts +99 -0
  41. package/src/prompts.ts +77 -1
  42. package/src/runtime.ts +1815 -0
  43. package/src/seed-behaviors.ts +1 -1
  44. package/src/session-db.ts +32 -0
  45. package/src/tool-defs.ts +185 -19
  46. package/src/tools.ts +16 -35
  47. package/src/watchers.ts +95 -14
@@ -30,7 +30,7 @@ interface DefaultBehavior {
30
30
  * (2) a user-codified behavior that happens to match a seed situation
31
31
  * would be found and silently deleted.
32
32
  *
33
- * Called by both the opencode plugin (src/index.ts) and the MCP server
33
+ * Called by both the opencode plugin (src/runtime.ts) and the MCP server
34
34
  * (src/mcp.ts) at startup, after DB and model initialization.
35
35
  */
36
36
  const DEFAULT_BEHAVIORS: DefaultBehavior[] = [
package/src/session-db.ts CHANGED
@@ -54,6 +54,38 @@ export function resolveOpencodeDbPath(): string | null {
54
54
  return existsSync(path) ? path : null;
55
55
  }
56
56
 
57
+ /**
58
+ * Resolves the session `opencode -c` continues: the most recently updated
59
+ * top-level session in the directory, read straight from the session
60
+ * database - the same pick the TUI makes from client.session.list(), which
61
+ * the v2 plugin context cannot call (its session domain has no list) and
62
+ * which would arrive too late for the runtime's synchronous rehydration
63
+ * partition regardless. Null when the db is unavailable or no top-level
64
+ * session exists in the directory.
65
+ */
66
+ export function mostRecentTopLevelSessionId(directory: string): string | null {
67
+ const dbPath = resolveOpencodeDbPath();
68
+ if (!dbPath) return null;
69
+ let db: Database;
70
+ try {
71
+ db = new Database(dbPath, { readonly: true });
72
+ } catch {
73
+ return null;
74
+ }
75
+ try {
76
+ const row = db
77
+ .query(
78
+ "SELECT id FROM session WHERE directory = ? AND (parent_id IS NULL OR parent_id = '') ORDER BY time_updated DESC LIMIT 1",
79
+ )
80
+ .get(directory) as { id: string } | undefined;
81
+ return row?.id ?? null;
82
+ } catch {
83
+ return null;
84
+ } finally {
85
+ db.close();
86
+ }
87
+ }
88
+
57
89
  export interface SessionRow {
58
90
  id: string;
59
91
  title: string;
package/src/tool-defs.ts CHANGED
@@ -2,15 +2,20 @@ import { z } from "zod";
2
2
  import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
  import {
5
+ ALERT_EVENT_KINDS,
5
6
  CONFIG_SECTIONS,
7
+ alertEventPrefsSchema,
8
+ alertsPrefsSchema,
6
9
  chatEnabled,
7
10
  chatPrefsSchema,
8
11
  loadConfig,
12
+ mergeAlertsPrefs,
9
13
  mergeChatPrefs,
10
14
  mergeNotificationPrefs,
11
15
  notificationDefaults,
12
16
  notificationPrefsSchema,
13
17
  saveConfig,
18
+ type AlertsPrefs,
14
19
  type ChatPrefs,
15
20
  type Config,
16
21
  type ConfigSection,
@@ -49,8 +54,39 @@ function dedupScanStores(store: string): string[] {
49
54
  return store === "global" ? ["global"] : [store, "global"];
50
55
  }
51
56
 
57
+ /**
58
+ * Resolve a behavior from feedback/delete text. The behavior nudge renders
59
+ * "When <matcher description>: <verb> <statement>" - and models copy back
60
+ * EITHER half (observed live three times: the situation half was fed back
61
+ * and statement search failed, even though the text was verbatim from the
62
+ * nudge). Try the statement first, then the matcher description resolved
63
+ * through its edge to the linked behavior. Returns the behavior row, an
64
+ * ambiguity list (the situation is shared by multiple behaviors), or null.
65
+ */
66
+ async function resolveBehaviorForFeedback(
67
+ ctx: CoreContext,
68
+ store: string,
69
+ text: string,
70
+ ): Promise<{ behavior: { id: string; statement: string }; via: string } | { ambiguous: string[] } | null> {
71
+ const embed = await ctx.model.passageEmbed(text);
72
+ const direct = ctx.db.findNearestBehavior(dedupScanStores(store), embed, BEHAVIOR_DEDUP_COSINE);
73
+ if (direct) return { behavior: direct, via: "statement" };
74
+ for (const s of dedupScanStores(store)) {
75
+ const matcher = ctx.db.findNearestBehaviorMatcher(s, embed, BEHAVIOR_DEDUP_COSINE);
76
+ if (!matcher) continue;
77
+ const linked = ctx.db.scoreBehaviors([{ id: matcher.id, description: matcher.description, score: 1 }]);
78
+ if (linked.length === 1) {
79
+ const row = ctx.db.getBehavior(linked[0].behavior_id);
80
+ if (row) return { behavior: row, via: "matcher description" };
81
+ return null;
82
+ }
83
+ if (linked.length > 1) return { ambiguous: linked.map((l) => l.statement) };
84
+ }
85
+ return null;
86
+ }
87
+
52
88
  // Minimum matcher cosine to consider a prediction relevant. Matches the
53
- // auto-fire threshold in index.ts (PREDICTION_THRESHOLD). The query tool
89
+ // auto-fire threshold in runtime.ts (PREDICTION_THRESHOLD). The query tool
54
90
  // should not return predictions from near-zero-similarity matchers that
55
91
  // would never fire in the auto-fire.
56
92
  const PREDICTION_QUERY_THRESHOLD = 0.60;
@@ -579,7 +615,11 @@ const predictionUpdateDef: ToolDef = {
579
615
  "and confidence adjustment automatically.",
580
616
  args: {
581
617
  matcher: z.string().describe(
582
- "Description of the situation. What decision was being made?",
618
+ "Description of the situation. What decision was being made? Word it " +
619
+ "for semantic matching: after writing it, re-read it against " +
620
+ "everything that ought to trigger it and widen the wording to the " +
621
+ "broadest surface of the class - a matcher that only names this " +
622
+ "incident never fires for the next one.",
583
623
  ),
584
624
  prediction: z.string().describe(
585
625
  "The user's preference or tendency in this situation.",
@@ -740,11 +780,18 @@ const behaviorCodifyDef: ToolDef = {
740
780
  "discipline, not what the user wants. Examples: check the whole " +
741
781
  "codebase for a library before importing it; investigate disabled " +
742
782
  "tests before touching the area; read a large function fully before " +
743
- "editing it.",
783
+ "editing it. When a review, bug, or incident reveals a CLASS of " +
784
+ "mistake you are prone to (not a one-off), codify a guard against the " +
785
+ "class.",
744
786
  args: {
745
787
  situation: z.string().describe(
746
788
  "Description of the situation that triggers this behavior. What context " +
747
- "or task type makes this rule apply?",
789
+ "or task type makes this rule apply? Word it for semantic matching: " +
790
+ "describe the context a future session will be in when this class of " +
791
+ "trouble starts, then re-read your wording against everything that " +
792
+ "ought to trigger it and widen it to the broadest surface of the " +
793
+ "class - a matcher that only names this incident never fires for the " +
794
+ "next one.",
748
795
  ),
749
796
  behavior: z.string().describe(
750
797
  "The behavioral rule. What should you do when this situation arises?",
@@ -802,11 +849,19 @@ const behaviorFeedbackDef: ToolDef = {
802
849
  "or relevant: false (spam) if it does not. This trains the classifier " +
803
850
  "so future nudges are more accurate. Also use when the user corrects " +
804
851
  "your behavior and you realize a codified rule led you astray or " +
805
- "should have been followed.",
852
+ "should have been followed. The nudge line carries two texts - the " +
853
+ "situation (\"When ...\") and the behavior statement (after \"do\") - " +
854
+ "and EITHER is accepted: the tool resolves the situation back to its " +
855
+ "linked behavior. If it reports no match, the behavior may live in a " +
856
+ "different store than your default (seeded behaviors live in the " +
857
+ "global store): list with behavior_list and retry, passing the store " +
858
+ "the behavior was listed from.",
806
859
  args: {
807
860
  behavior: z.string().describe(
808
- "The behavior statement to provide feedback on. Use behavior_list " +
809
- "to find the exact text; matching is semantic (cosine >= 0.85).",
861
+ "The behavior statement to provide feedback on. The situation line " +
862
+ "from the nudge (\"When ...\") is also accepted and resolves to its " +
863
+ "linked behavior. Use behavior_list to find the exact text; " +
864
+ "matching is semantic (cosine >= 0.85).",
810
865
  ),
811
866
  relevant: z.boolean().describe(
812
867
  "true (ham) if the behavior is relevant to the current situation. " +
@@ -826,9 +881,20 @@ const behaviorFeedbackDef: ToolDef = {
826
881
  const relevant = args.relevant as boolean;
827
882
  const contextText = args.context as string;
828
883
 
829
- const behaviorEmbed = await ctx.model.passageEmbed(behaviorText);
830
- const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
831
- if (!behavior) return `No behavior matching "${behaviorText}" found in "${store}".`;
884
+ const resolved = await resolveBehaviorForFeedback(ctx, store, behaviorText);
885
+ if (!resolved) {
886
+ return (
887
+ `No behavior matching "${behaviorText}" found in any of ${dedupScanStores(store).join(", ")} ` +
888
+ `(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
889
+ );
890
+ }
891
+ if ("ambiguous" in resolved) {
892
+ return (
893
+ `That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
894
+ resolved.ambiguous.map((s) => `"${s}"`).join("; ")
895
+ );
896
+ }
897
+ const behavior = resolved.behavior;
832
898
 
833
899
  const signal = relevant ? "confirm" : "disconfirm";
834
900
  ctx.db.transaction(() => {
@@ -876,7 +942,9 @@ const behaviorDeleteDef: ToolDef = {
876
942
  "automatically (cascade).",
877
943
  args: {
878
944
  statement: z.string().describe(
879
- "The behavior statement to delete. Use behavior_list to find the " +
945
+ "The behavior statement to delete. The situation line from the " +
946
+ "nudge (\"When ...\") is also accepted and resolves to its linked " +
947
+ "behavior. Use behavior_list to find the " +
880
948
  "exact text; matching is semantic (cosine >= 0.85).",
881
949
  ),
882
950
  store: z.string().optional().describe(
@@ -886,12 +954,24 @@ const behaviorDeleteDef: ToolDef = {
886
954
  async execute(args, ctx) {
887
955
  const store = resolveStore(args, ctx);
888
956
  const statementText = args.statement as string;
889
- const behaviorEmbed = await ctx.model.passageEmbed(statementText);
890
- const behavior = ctx.db.findNearestBehavior(store, behaviorEmbed, BEHAVIOR_DEDUP_COSINE);
891
- if (!behavior) return `No behavior matching "${statementText}" found in "${store}".`;
957
+ const resolved = await resolveBehaviorForFeedback(ctx, store, statementText);
958
+ if (!resolved) {
959
+ return (
960
+ `No behavior matching "${statementText}" found in any of ${dedupScanStores(store).join(", ")} ` +
961
+ `(tried both statements and matcher descriptions). Use behavior_list to see the exact statements.`
962
+ );
963
+ }
964
+ if ("ambiguous" in resolved) {
965
+ return (
966
+ `That text matches a situation shared by multiple behaviors - re-run with the exact statement: ` +
967
+ resolved.ambiguous.map((s) => `"${s}"`).join("; ")
968
+ );
969
+ }
970
+ const behavior = resolved.behavior;
971
+ const rowBefore = ctx.db.getBehavior(behavior.id);
892
972
  const deleted = ctx.db.deleteBehavior(behavior.id);
893
973
  if (!deleted) return `Failed to delete behavior "${behavior.statement}".`;
894
- return `[deleted] "${behavior.statement}" from "${store}"`;
974
+ return `[deleted] "${behavior.statement}" from "${rowBefore?.store ?? store}"`;
895
975
  },
896
976
  };
897
977
 
@@ -925,6 +1005,15 @@ function renderChatSection(prefs: ChatPrefs | undefined): string {
925
1005
  ].join("\n");
926
1006
  }
927
1007
 
1008
+ /** Renders the alerts section. Every event defaults to banner. */
1009
+ function renderAlertsSection(prefs: AlertsPrefs | undefined): string {
1010
+ const lines = ["alerts:"];
1011
+ for (const kind of ALERT_EVENT_KINDS) {
1012
+ lines.push(` ${kind}.mode: ${prefs?.[kind]?.mode ?? "<unset>"} (default: banner)`);
1013
+ }
1014
+ return lines.join("\n");
1015
+ }
1016
+
928
1017
  /** Drops keys explicitly set to undefined so a merge never overwrites. */
929
1018
  function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
930
1019
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -938,7 +1027,7 @@ const configGetDef: ToolDef = {
938
1027
  "The config file (~/.config/thatch/config.json, beside thatch.db) is " +
939
1028
  "also hand-editable. Call this before config_set.",
940
1029
  args: {
941
- section: z.enum(["notifications", "chat"]).optional().describe(
1030
+ section: z.enum(["notifications", "chat", "alerts"]).optional().describe(
942
1031
  "Restrict output to one section. Omit to read all sections.",
943
1032
  ),
944
1033
  },
@@ -956,6 +1045,9 @@ const configGetDef: ToolDef = {
956
1045
  if (name === "chat") {
957
1046
  lines.push(renderChatSection(loaded.config.chat));
958
1047
  }
1048
+ if (name === "alerts") {
1049
+ lines.push(renderAlertsSection(loaded.config.alerts));
1050
+ }
959
1051
  }
960
1052
  lines.push("", `file: ${loaded.path}`);
961
1053
  return lines.join("\n");
@@ -1002,19 +1094,40 @@ const configSetDef: ToolDef = {
1002
1094
  "Chat preferences to update. Fields you omit keep their current " +
1003
1095
  "values.",
1004
1096
  ),
1097
+ alerts: z.strictObject({
1098
+ pause: alertEventPrefsSchema.optional().describe(
1099
+ "Alert when the LLM blocks on a question or permission prompt.",
1100
+ ),
1101
+ done: alertEventPrefsSchema.optional().describe(
1102
+ "Alert when a round of real work finishes. Rounds triggered by " +
1103
+ "synthetic input (thatch nudges, task completions, watcher " +
1104
+ "wake-ups) and rounds of only bookkeeping tools stay silent.",
1105
+ ),
1106
+ error: alertEventPrefsSchema.optional().describe(
1107
+ "Alert when a session fails with nothing to recover it (user " +
1108
+ "aborts stay silent; retried failures stay silent).",
1109
+ ),
1110
+ }).optional().describe(
1111
+ "Automatic alert preferences (desktop notification when the LLM " +
1112
+ "pauses or finishes). Each event takes a mode: both, banner only, " +
1113
+ "voice only, or none. Default: banner.",
1114
+ ),
1005
1115
  },
1006
1116
  async execute(args) {
1007
1117
  const loaded = loadConfig();
1008
1118
  const config: Config = { ...loaded.config };
1009
1119
  const notifPatch = args.notifications as Record<string, unknown> | undefined;
1010
1120
  const chatPatch = args.chat as Record<string, unknown> | undefined;
1121
+ const alertsPatch = args.alerts as Record<string, unknown> | undefined;
1011
1122
  const notifClean = notifPatch ? stripUndefined(notifPatch) : {};
1012
1123
  const chatClean = chatPatch ? stripUndefined(chatPatch) : {};
1013
- if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0) {
1124
+ const alertsClean = alertsPatch ? stripUndefined(alertsPatch) : {};
1125
+ if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0 && Object.keys(alertsClean).length === 0) {
1014
1126
  return (
1015
1127
  "Nothing to update: pass a section with fields to change.\n" +
1016
1128
  renderNotificationSection(config.notifications) + "\n" +
1017
- renderChatSection(config.chat)
1129
+ renderChatSection(config.chat) + "\n" +
1130
+ renderAlertsSection(config.alerts)
1018
1131
  );
1019
1132
  }
1020
1133
  if (Object.keys(notifClean).length > 0) {
@@ -1027,11 +1140,17 @@ const configSetDef: ToolDef = {
1027
1140
  mergeChatPrefs(config.chat, chatClean),
1028
1141
  );
1029
1142
  }
1143
+ if (Object.keys(alertsClean).length > 0) {
1144
+ config.alerts = alertsPrefsSchema.parse(
1145
+ mergeAlertsPrefs(config.alerts, alertsClean),
1146
+ );
1147
+ }
1030
1148
  const path = saveConfig(config);
1031
1149
  return (
1032
1150
  `[saved] ${path}\n` +
1033
1151
  (Object.keys(notifClean).length > 0 ? renderNotificationSection(config.notifications) + "\n" : "") +
1034
- (Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) : "")
1152
+ (Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) + "\n" : "") +
1153
+ (Object.keys(alertsClean).length > 0 ? renderAlertsSection(config.alerts) : "")
1035
1154
  );
1036
1155
  },
1037
1156
  };
@@ -2006,3 +2125,50 @@ export const TOOL_DEFS: ToolDef[] = [
2006
2125
  chatBroadcastDef,
2007
2126
  chatStatusDef,
2008
2127
  ];
2128
+
2129
+ /** Extensions a host adapter passes into the shared CoreContext. */
2130
+ export interface CoreContextExtensions {
2131
+ extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
2132
+ drainExtractionQueue?: CoreContext["drainExtractionQueue"];
2133
+ watcherRegistry?: WatcherRegistry;
2134
+ projectDir?: string;
2135
+ }
2136
+
2137
+ /**
2138
+ * Builds the host-agnostic CoreContext both adapters share: the opencode
2139
+ * plugin path and the MCP server each construct a CoreContext once per
2140
+ * init and reuse it for every tool call. This helper is the plugin-side
2141
+ * constructor; the MCP server builds its own inline (src/mcp.ts).
2142
+ *
2143
+ * Lives in this module - NOT in tools.ts - because tools.ts imports the v1
2144
+ * SDK at runtime, and the v2 adapter's entry graph must stay SDK-free (the
2145
+ * v2 plugin install skips optional peers, so evaluating the v1 SDK there
2146
+ * crashes the load).
2147
+ */
2148
+ export function buildCoreContext(
2149
+ db: ThatchDB,
2150
+ model: EmbeddingModel,
2151
+ defaultStore: string,
2152
+ extensions?: CoreContextExtensions,
2153
+ ): CoreContext {
2154
+ return {
2155
+ db,
2156
+ model,
2157
+ defaultStore,
2158
+ extractionPayloadProvider: extensions?.extractionPayloadProvider,
2159
+ drainExtractionQueue: extensions?.drainExtractionQueue,
2160
+ watchers: extensions?.watcherRegistry,
2161
+ projectDir: extensions?.projectDir,
2162
+ };
2163
+ }
2164
+
2165
+ /**
2166
+ * Trim opencode's per-call ToolContext to the host-agnostic fields the
2167
+ * shared definitions know about. Both adapters do this same two-field trim;
2168
+ * tests and MCP paths pass no host context.
2169
+ */
2170
+ export function trimHostContext(
2171
+ hostContext: { sessionID: string; agent: string } | undefined,
2172
+ ): HostToolContext | undefined {
2173
+ return hostContext ? { sessionID: hostContext.sessionID, agent: hostContext.agent } : undefined;
2174
+ }
package/src/tools.ts CHANGED
@@ -1,48 +1,29 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
- import type { ThatchDB } from "./db";
3
- import type { EmbeddingModel } from "./embeddings";
4
- import { TOOL_DEFS, type CoreContext, type HostToolContext } from "./tool-defs";
5
- import type { WatcherRegistry } from "./watchers";
2
+ import { TOOL_DEFS, trimHostContext, type CoreContext } from "./tool-defs";
3
+
4
+ // V1-ONLY module: this file imports the opencode v1 SDK at runtime, so
5
+ // nothing shared may import from it. The host-agnostic pieces
6
+ // (buildCoreContext, trimHostContext, CoreContext, TOOL_DEFS) live in
7
+ // tool-defs.ts precisely so the v2 adapter and the MCP server can use them
8
+ // without evaluating the v1 SDK - the v2 plugin install skips optional
9
+ // peers, and a missing SDK import here would kill the whole plugin load.
6
10
 
7
11
  /**
8
- * Builds the opencode tool map from shared tool definitions. Each definition
9
- * in TOOL_DEFS is wrapped in opencode's `tool()` with a `thatch_` prefix
10
- * on the name - opencode uses the object key as the tool name, so the prefix
11
- * lives here, not in the shared definitions.
12
+ * Builds the v1 opencode tool map from shared tool definitions. Each
13
+ * definition in TOOL_DEFS is wrapped in opencode's `tool()` with a
14
+ * `thatch_` prefix on the name - opencode uses the object key as the tool
15
+ * name, so the prefix lives here, not in the shared definitions. Takes the
16
+ * shared CoreContext (buildCoreContext) rather than raw deps, so both host
17
+ * adapters feed the same context construction.
12
18
  */
13
- export function createTools(
14
- db: ThatchDB,
15
- model: EmbeddingModel,
16
- defaultStore: string,
17
- extensions?: {
18
- extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
19
- drainExtractionQueue?: CoreContext["drainExtractionQueue"];
20
- watcherRegistry?: WatcherRegistry;
21
- projectDir?: string;
22
- },
23
- ): Record<string, ReturnType<typeof tool>> {
24
- const ctx: CoreContext = {
25
- db,
26
- model,
27
- defaultStore,
28
- extractionPayloadProvider: extensions?.extractionPayloadProvider,
29
- drainExtractionQueue: extensions?.drainExtractionQueue,
30
- watchers: extensions?.watcherRegistry,
31
- projectDir: extensions?.projectDir,
32
- };
33
-
19
+ export function createTools(coreContext: CoreContext): Record<string, ReturnType<typeof tool>> {
34
20
  const tools: Record<string, ReturnType<typeof tool>> = {};
35
21
  for (const def of TOOL_DEFS) {
36
22
  tools[`thatch_${def.name}`] = tool({
37
23
  description: def.description,
38
24
  args: def.args,
39
25
  async execute(args, hostContext) {
40
- // Trim opencode's ToolContext to the host-agnostic fields the shared
41
- // definitions know about. Tests and MCP paths pass no host context.
42
- const host: HostToolContext | undefined = hostContext
43
- ? { sessionID: hostContext.sessionID, agent: hostContext.agent }
44
- : undefined;
45
- return def.execute(args as Record<string, unknown>, ctx, host);
26
+ return def.execute(args as Record<string, unknown>, coreContext, trimHostContext(hostContext));
46
27
  },
47
28
  });
48
29
  }
package/src/watchers.ts CHANGED
@@ -16,16 +16,14 @@
16
16
  * never its output (injection hygiene: command output can be external
17
17
  * content, so it never rides a notification).
18
18
  *
19
- * Lifetime is deliberately process-scoped. The registry lives in plugin
20
- * memory, never in SQLite: opencode loads thatch in-process, so the plugin
21
- * process is the natural owner of its watchers. This makes multi-instance
22
- * ownership structurally impossible (each process polls only what it
23
- * registered), leaves no orphan data behind, and matches the extraction
24
- * pipeline's opencode path, which is also in-memory. A crashed or restarted
25
- * opencode loses its watchers - acceptable, because the session that created
26
- * them lost its conversational context too, and a delivered notification
27
- * into a session that no longer knows why it is being watched is worse than
28
- * a lost watch.
19
+ * Lifetime is deliberately process-scoped for the LIVE registry: the
20
+ * poller and delivery state live in plugin memory, never in SQLite -
21
+ * opencode loads thatch in-process, so the plugin process is the natural
22
+ * owner of its watchers, and no cross-process "who polls this?" claim
23
+ * logic is needed. The DEFINITIONS, however, are journaled to
24
+ * runtime_state (the optional journal hook) so they survive v2 plugin
25
+ * reloads and, as dormant rows, full restarts - see the rearm() and the
26
+ * runtime's dormant-watcher scan.
29
27
  */
30
28
 
31
29
  // ---------------------------------------------------------------------------
@@ -280,6 +278,8 @@ export interface WatcherRegistryOptions {
280
278
  pollIntervalMs?: number;
281
279
  ttlMinutes?: number;
282
280
  maxPerSession?: number;
281
+ /** Persistence hook: called after membership changes with the session's watcher list. */
282
+ journal?: (sessionID: string, watchers: Watcher[]) => void;
283
283
  }
284
284
 
285
285
  // ---------------------------------------------------------------------------
@@ -391,6 +391,17 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
391
391
  }
392
392
  }
393
393
 
394
+ /**
395
+ * The stable target label a watcher reports in events, lists, and recovery
396
+ * notices: "owner/repo#N" for PRs, "owner/repo@branch" for branches, and a
397
+ * one-line clip of the command for command watches.
398
+ */
399
+ export function watcherTarget(watcher: Watcher): string {
400
+ if (watcher.source === "pr") return `${watcher.repo}#${watcher.pr}`;
401
+ if (watcher.source === "branch") return `${watcher.repo}@${watcher.branch}`;
402
+ return commandTargetLabel(watcher.command);
403
+ }
404
+
394
405
  let ghAvailability: boolean | null = null;
395
406
 
396
407
  /**
@@ -829,6 +840,13 @@ export class WatcherRegistry {
829
840
  #timer: ReturnType<typeof setInterval> | null = null;
830
841
  #delivering = false;
831
842
  #polling = false;
843
+ /**
844
+ * Persistence hook (optional): called after every membership change with
845
+ * the session's current watcher list, so the runtime can journal the
846
+ * definitions and re-arm them after a v2 plugin reload. Passing no hook
847
+ * (tests, MCP) keeps the registry in-memory only.
848
+ */
849
+ #journal?: (sessionID: string, watchers: Watcher[]) => void;
832
850
  readonly #opts: WatcherRegistryOptions & {
833
851
  pollIntervalMs: number;
834
852
  ttlMinutes: number;
@@ -846,6 +864,7 @@ export class WatcherRegistry {
846
864
  commandTimeoutMs: defaultCommandTimeoutMs(),
847
865
  ...options,
848
866
  };
867
+ this.#journal = options.journal;
849
868
  }
850
869
 
851
870
  // -- Lifecycle -----------------------------------------------------------
@@ -889,6 +908,59 @@ export class WatcherRegistry {
889
908
 
890
909
  // -- CRUD ----------------------------------------------------------------
891
910
 
911
+ /** Journal the session's current watcher list (no-op without a hook). */
912
+ #emit(sessionID: string): void {
913
+ this.#journal?.(sessionID, this.listForSession(sessionID));
914
+ }
915
+
916
+ /** Restore persisted watchers (same-process reload rehydration). */
917
+ hydrate(watchers: Watcher[]): void {
918
+ for (const w of watchers) {
919
+ // Do not clobber a watcher created after the journal was written.
920
+ if (!this.#watchers.has(w.id)) this.#watchers.set(w.id, w);
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Re-arms persisted watcher definitions recovered on session resume after
926
+ * a restart (the dormant journal rows the runtime keeps instead of
927
+ * pruning). Definitions whose TTL expired while the session was away are
928
+ * dropped - a watch that outlived its own expiry must not fire on a stale
929
+ * baseline. PR and branch definitions are REVALIDATED by re-fetching
930
+ * their baseline: a deleted branch or closed PR would otherwise re-arm
931
+ * into silent poll failure, and a stale baseline would flood the session
932
+ * with every event that happened while it was away; fetch failures drop
933
+ * the definition and count as failed. The rest join the live registry
934
+ * newest-first up to the per-session limit (the session may have created
935
+ * watchers since the restart), and the journal is rewritten either way:
936
+ * with the current process's pid when anything re-arms, deleted when
937
+ * nothing does.
938
+ */
939
+ async rearm(sessionID: string, defs: Watcher[]): Promise<{ rearmed: Watcher[]; expired: number; failed: number }> {
940
+ const now = Date.now();
941
+ const fresh = defs.filter((w) => now <= w.expiresAt).sort((a, b) => b.createdAt - a.createdAt);
942
+ const budget = Math.max(0, this.#opts.maxPerSession - this.listForSession(sessionID).length);
943
+ const rearmed: Watcher[] = [];
944
+ let failed = 0;
945
+ for (const def of fresh.slice(0, budget)) {
946
+ if (def.source !== "command") {
947
+ try {
948
+ def.state = def.source === "pr"
949
+ ? await fetchPrState(this.#opts.ghRunner, def.repo, def.pr)
950
+ : await fetchBranchState(this.#opts.ghRunner, def.repo, def.branch);
951
+ } catch {
952
+ failed++;
953
+ continue;
954
+ }
955
+ }
956
+ // Do not clobber a watcher created after the journal was written.
957
+ if (!this.#watchers.has(def.id)) this.#watchers.set(def.id, def);
958
+ rearmed.push(def);
959
+ }
960
+ this.#emit(sessionID);
961
+ return { rearmed, expired: defs.length - fresh.length, failed };
962
+ }
963
+
892
964
  /**
893
965
  * Registers a PR watcher and captures the baseline state immediately. The
894
966
  * baseline fetch doubles as validation: a bad repo, missing PR, or broken
@@ -929,6 +1001,7 @@ export class WatcherRegistry {
929
1001
  state,
930
1002
  };
931
1003
  this.#watchers.set(watcher.id, watcher);
1004
+ this.#emit(sessionID);
932
1005
  return { ok: true, watcher };
933
1006
  }
934
1007
 
@@ -973,6 +1046,7 @@ export class WatcherRegistry {
973
1046
  state,
974
1047
  };
975
1048
  this.#watchers.set(watcher.id, watcher);
1049
+ this.#emit(sessionID);
976
1050
  return { ok: true, watcher };
977
1051
  }
978
1052
 
@@ -1028,6 +1102,7 @@ export class WatcherRegistry {
1028
1102
  state: { lastExit: baseline.timedOut ? 124 : baseline.exitCode },
1029
1103
  };
1030
1104
  this.#watchers.set(watcher.id, watcher);
1105
+ this.#emit(sessionID);
1031
1106
  return { ok: true, watcher };
1032
1107
  }
1033
1108
 
@@ -1051,6 +1126,7 @@ export class WatcherRegistry {
1051
1126
  const watcher = this.#watchers.get(id);
1052
1127
  if (!watcher || watcher.sessionID !== sessionID) return false;
1053
1128
  this.#watchers.delete(id);
1129
+ this.#emit(sessionID);
1054
1130
  return true;
1055
1131
  }
1056
1132
 
@@ -1060,6 +1136,7 @@ export class WatcherRegistry {
1060
1136
  if (w.sessionID === sessionID) this.#watchers.delete(id);
1061
1137
  }
1062
1138
  this.#pending.delete(sessionID);
1139
+ this.#emit(sessionID);
1063
1140
  }
1064
1141
 
1065
1142
  // -- Polling and delivery ------------------------------------------------
@@ -1079,6 +1156,7 @@ export class WatcherRegistry {
1079
1156
  for (const [id, watcher] of this.#watchers) {
1080
1157
  if (now > watcher.expiresAt) {
1081
1158
  this.#watchers.delete(id);
1159
+ this.#emit(watcher.sessionID);
1082
1160
  continue;
1083
1161
  }
1084
1162
  try {
@@ -1092,7 +1170,10 @@ export class WatcherRegistry {
1092
1170
  // if the session is busy. Cancellation at detection time keeps
1093
1171
  // "wait for this run to finish" from leaving a standing watch
1094
1172
  // polling a target nobody is waiting on anymore.
1095
- if (watcher.once) this.#watchers.delete(id);
1173
+ if (watcher.once) {
1174
+ this.#watchers.delete(id);
1175
+ this.#emit(watcher.sessionID);
1176
+ }
1096
1177
  }
1097
1178
  } catch (err) {
1098
1179
  console.error(`[thatch] watcher ${id} poll failed: ${err}`);
@@ -1108,7 +1189,7 @@ export class WatcherRegistry {
1108
1189
  async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
1109
1190
  if (watcher.source === "pr") {
1110
1191
  const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
1111
- const events = diffPrState(watcher.state, after, `${watcher.repo}#${watcher.pr}`, this.#prUrl(watcher))
1192
+ const events = diffPrState(watcher.state, after, watcherTarget(watcher), this.#prUrl(watcher))
1112
1193
  .filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
1113
1194
  watcher.state = after;
1114
1195
  return events;
@@ -1124,13 +1205,13 @@ export class WatcherRegistry {
1124
1205
  if (result.timedOut || result.exitCode !== 0) return [];
1125
1206
  return [{
1126
1207
  type: "command_success",
1127
- target: commandTargetLabel(watcher.command),
1208
+ target: watcherTarget(watcher),
1128
1209
  summary: `command exited 0 (took ${(result.durationMs / 1000).toFixed(1)}s)`,
1129
1210
  url: "",
1130
1211
  }];
1131
1212
  }
1132
1213
  const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
1133
- const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
1214
+ const all = diffBranchState(watcher.state, after, watcherTarget(watcher), watcher.repo);
1134
1215
  watcher.state = after;
1135
1216
  // The workflow-name filter narrows branch_workflow events only -
1136
1217
  // commits and check runs pass through unfiltered, or a watch filtered