@jeffober/thatch 0.2.4 → 0.2.6

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 (76) hide show
  1. package/README.md +56 -26
  2. package/artifacts/skills/common.md +1 -0
  3. package/artifacts/skills/thatch-clear-writing.md +103 -5
  4. package/artifacts/skills/thatch-code-archaeology.md +9 -0
  5. package/artifacts/skills/thatch-code-review.md +16 -8
  6. package/artifacts/skills/thatch-coordination.md +69 -0
  7. package/artifacts/skills/thatch-plan-refinement.md +33 -4
  8. package/artifacts/skills/thatch-pr-description.md +21 -13
  9. package/artifacts/skills/thatch-refine-personal.md +5 -0
  10. package/artifacts/skills/thatch-refine.md +7 -1
  11. package/artifacts/skills/thatch-review-context.md +55 -2
  12. package/artifacts/skills/thatch-review-followup.md +4 -4
  13. package/artifacts/skills/thatch-review-no-slop.md +15 -0
  14. package/artifacts/skills/thatch-review-response.md +1 -1
  15. package/artifacts/skills/thatch-review-synthesizer.md +21 -1
  16. package/bin/thatch +48 -15
  17. package/docs/assets/deepwiki-badge.svg +1 -0
  18. package/docs/dev/README.md +18 -10
  19. package/docs/dev/features/README.md +1 -1
  20. package/docs/dev/features/behavior-engine.md +10 -0
  21. package/docs/dev/features/cli.md +4 -1
  22. package/docs/dev/features/commands.md +40 -9
  23. package/docs/dev/features/cross-session-chat.md +194 -23
  24. package/docs/dev/features/database.md +4 -4
  25. package/docs/dev/features/notifications.md +1 -2
  26. package/docs/dev/features/nudge-pipeline.md +14 -0
  27. package/docs/dev/features/opencode-plugin.md +56 -15
  28. package/docs/dev/features/prediction-engine.md +71 -6
  29. package/docs/dev/features/qa-system.md +19 -3
  30. package/docs/dev/features/session-lifecycle.md +6 -6
  31. package/docs/dev/features/session-tabs.md +161 -0
  32. package/docs/dev/features/setup.md +5 -0
  33. package/docs/dev/features/watchers.md +62 -6
  34. package/docs/dev/gotchas.md +89 -0
  35. package/docs/dev/mcp-parity.md +5 -5
  36. package/docs/dev/setup-and-hooks.md +7 -2
  37. package/docs/dev/skills.md +12 -9
  38. package/docs/plans/generalized-session-heartbeat.md +212 -0
  39. package/docs/plans/prediction-consolidation.md +88 -68
  40. package/docs/user/README.md +29 -13
  41. package/docs/user/behavior-engine.md +7 -1
  42. package/docs/user/cli.md +5 -1
  43. package/docs/user/code-review.md +43 -2
  44. package/docs/user/commands.md +37 -10
  45. package/docs/user/cross-session-chat.md +76 -26
  46. package/docs/user/prediction-engine.md +23 -1
  47. package/docs/user/setup.md +16 -0
  48. package/docs/user/skills.md +9 -7
  49. package/docs/user/watchers.md +22 -7
  50. package/package.json +10 -2
  51. package/src/alerts.ts +1 -1
  52. package/src/capabilities.ts +20 -6
  53. package/src/chat.ts +358 -43
  54. package/src/commands.ts +123 -18
  55. package/src/db.ts +74 -3
  56. package/src/git.ts +3 -1
  57. package/src/hygiene.ts +5 -0
  58. package/src/mcp.ts +101 -9
  59. package/src/opencode/tui-plugin.ts +90 -0
  60. package/src/opencode/v2.ts +440 -66
  61. package/src/prediction.ts +90 -0
  62. package/src/prompts.ts +164 -29
  63. package/src/runtime.ts +298 -15
  64. package/src/scoring-engine.ts +40 -9
  65. package/src/session-db.ts +4 -4
  66. package/src/session-tab-shared.ts +231 -0
  67. package/src/setup.ts +9 -3
  68. package/src/skills.ts +6 -9
  69. package/src/tab-state.ts +125 -0
  70. package/src/tool-defs.ts +454 -76
  71. package/src/tools.ts +2 -0
  72. package/src/watchers.ts +263 -26
  73. package/docs/plans/intuition-drives.md +0 -147
  74. package/docs/plans/llm-alerts.md +0 -287
  75. package/docs/plans/thatch-actions.md +0 -179
  76. package/docs/plans/worktree-deletion-recovery.md +0 -246
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Cross-entry contract for the session-tab feature: the single module both
3
+ * the opencode v2 server adapter (src/opencode/v2.ts) and the v2 TUI CLI
4
+ * plugin (src/opencode/tui-plugin.ts) import. SDK-free under the same
5
+ * isolation rule as src/index.ts - the TUI entry may import only
6
+ * @opencode/plugin/tui and this module, and the server entry graph must
7
+ * never evaluate the v1 SDK.
8
+ *
9
+ * The transport is the v2 plugin rpc surface: the server adapter registers
10
+ * this definition and emits events; the TUI CLI plugin receives them on the
11
+ * ordinary event feed (isOpenCodeEvent passes any `rpc.` type) and acts on
12
+ * them with the TUI-only tab primitives. Events are ephemeral - a missed
13
+ * event means "no tab", never "no session" (the session exists before the
14
+ * first emit and the tool response words the tab as requested, not
15
+ * guaranteed).
16
+ *
17
+ * The schemas are literal JSON Schema objects because
18
+ * Rpc.PortableEventValueSchema accepts StandardSchemaV1 or a JSON Schema
19
+ * object with type "object"; a literal object keeps this module
20
+ * dependency-free. The registration is the server-to-TUI transport for
21
+ * everything thatch pushes at the TUI (tab lifecycle plus toasts), so its
22
+ * id says "tabs" only historically. One event has a consumer beyond the
23
+ * TUI plugin's tab strip: the v2 pump (src/opencode/v2.ts) translates
24
+ * tab-closed into the runtime's confirmed-death path (the
25
+ * session_tab_close tool's only route to it), so that shape must change
26
+ * jointly with all consumers (docs/dev/features/session-tabs.md).
27
+ * exit-tab-closed is deliberately TUI-only: the wrap-up exit records the
28
+ * session's watcher deaths synchronously before emitting
29
+ * (src/runtime.ts), so the pump ignores it - a pump translation would run
30
+ * the death path a second time and erase the durable death row the first
31
+ * pass wrote. toast is TUI-only by nature (nobody else consumes it).
32
+ */
33
+
34
+ import { realpathSync } from "node:fs";
35
+ import { EXTRACTION_CHILD_TITLE } from "./chat";
36
+ import { resolveMainCheckout } from "./git";
37
+
38
+ export const SESSION_TAB_RPC_ID = "thatch-tabs";
39
+ export const TAB_OPENED_EVENT = "tab-opened";
40
+ export const TAB_CLOSED_EVENT = "tab-closed";
41
+ export const EXIT_TAB_CLOSED_EVENT = "exit-tab-closed";
42
+ export const TOAST_EVENT = "toast";
43
+
44
+ /** Full wire type of an emitted event, e.g. "rpc.thatch-tabs.tab-opened". */
45
+ export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}`;
46
+
47
+ /** Full wire type of the tab-closed event - the generalized-session-
48
+ * heartbeat plan's confirmed-close consumer matches on this. */
49
+ export const tabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_CLOSED_EVENT}`;
50
+
51
+ /** Full wire type of the wrap-up exit's TUI-only close event. */
52
+ export const exitTabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${EXIT_TAB_CLOSED_EVENT}`;
53
+
54
+ /** Full wire type of the toast request event. */
55
+ export const toastEventType = `rpc.${SESSION_TAB_RPC_ID}.${TOAST_EVENT}`;
56
+
57
+ export const SESSION_TAB_RPC = {
58
+ id: SESSION_TAB_RPC_ID,
59
+ methods: {},
60
+ events: {
61
+ [TAB_OPENED_EVENT]: {
62
+ schema: {
63
+ type: "object",
64
+ properties: {
65
+ sessionID: { type: "string" },
66
+ directory: { type: "string" },
67
+ },
68
+ required: ["sessionID", "directory"],
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ [TAB_CLOSED_EVENT]: {
73
+ schema: {
74
+ type: "object",
75
+ properties: {
76
+ sessionID: { type: "string" },
77
+ // Nullable: the chat roster row may have been reaped by the time
78
+ // the close fires. Consumers address death notices by name when
79
+ // present and fall back to the session id.
80
+ chatName: { type: ["string", "null"] },
81
+ },
82
+ required: ["sessionID", "chatName"],
83
+ additionalProperties: false,
84
+ },
85
+ },
86
+ [EXIT_TAB_CLOSED_EVENT]: {
87
+ // TUI-only (see the module header): carries just the tab to close.
88
+ schema: {
89
+ type: "object",
90
+ properties: {
91
+ sessionID: { type: "string" },
92
+ },
93
+ required: ["sessionID"],
94
+ additionalProperties: false,
95
+ },
96
+ },
97
+ [TOAST_EVENT]: {
98
+ // TUI-only by nature. The payload is thatch's ToastInput verbatim
99
+ // (all three fields required there, so required here).
100
+ schema: {
101
+ type: "object",
102
+ properties: {
103
+ message: { type: "string" },
104
+ variant: { type: "string", enum: ["info", "success", "warning", "error"] },
105
+ duration: { type: "number" },
106
+ },
107
+ required: ["message", "variant", "duration"],
108
+ additionalProperties: false,
109
+ },
110
+ },
111
+ },
112
+ } as const;
113
+
114
+ /**
115
+ * The coordinator-authority framing delivered as the subordinate's first
116
+ * user message. The wording is the feature's contract with the subordinate
117
+ * LLM (docs/dev/features/session-tabs.md): coordinator instructions outrank
118
+ * nothing the user says directly.
119
+ */
120
+ export function buildSubordinatePrompt(coordinatorName: string, task: string): string {
121
+ const preamble =
122
+ `Your work session was created by ${coordinatorName}, who is coordinating your actions. ` +
123
+ "Please treat their instructions as accepted priority, unless they conflict with the user's " +
124
+ "explicit preferences or instructions. If the user prompts you directly, their instructions " +
125
+ "supersede the coordinating LLM's.";
126
+ return `${preamble}\n\n${task}`;
127
+ }
128
+
129
+ /** Result of validating the session_tab title arg. */
130
+ export type TitleValidation = { ok: true; title: string } | { ok: false; error: string };
131
+
132
+ /**
133
+ * Titles render in the tab strip: soft target ~50 characters, hard max 80.
134
+ * opencode imposes no length limit (title is an unbounded optional string)
135
+ * and the strip truncates visually, so a hard fail at 50 would only cost
136
+ * retry friction - hence the loose cap.
137
+ *
138
+ * EXTRACTION_CHILD_TITLE is rejected because the chat auto-register paths
139
+ * treat that exact title as plugin machinery and refuse the session
140
+ * (src/chat.ts isMachinerySessionTitle) - a subordinate titled into
141
+ * machinery would silently lose its roster row and with it the
142
+ * coordination loop's addressing.
143
+ */
144
+ export function validateTitle(raw: unknown): TitleValidation {
145
+ if (typeof raw !== "string") return { ok: false, error: "title must be a string." };
146
+ const title = raw.trim();
147
+ if (!title) return { ok: false, error: "title must not be empty (it was blank after trimming)." };
148
+ if (title.length > 80) {
149
+ return {
150
+ ok: false,
151
+ error: `title is too long: ${title.length} characters against a hard max of 80. Aim for about 50 - it renders in the session tab strip.`,
152
+ };
153
+ }
154
+ if (title === EXTRACTION_CHILD_TITLE) {
155
+ return { ok: false, error: "that title is reserved for plugin machinery sessions; pick a task title." };
156
+ }
157
+ return { ok: true, title };
158
+ }
159
+
160
+ /** Result of validating the exactly-one-of worktree/directory rule. */
161
+ export type LocationValidation =
162
+ | { ok: true; kind: "worktree" | "directory"; path: string }
163
+ | { ok: false; error: string };
164
+
165
+ /**
166
+ * The tool takes exactly one location arg. The two kinds differ in
167
+ * validation and intent, not in tab placement: either way the tab opens in
168
+ * the coordinator's window (the tab-open event routes by the coordinator
169
+ * instance's location, never the session's).
170
+ */
171
+ export function validateLocationArgs(args: { worktree?: unknown; directory?: unknown }): LocationValidation {
172
+ const hasWorktree = typeof args.worktree === "string" && args.worktree.trim() !== "";
173
+ const hasDirectory = typeof args.directory === "string" && args.directory.trim() !== "";
174
+ if (hasWorktree && hasDirectory) {
175
+ return { ok: false, error: "Pass exactly ONE location: worktree (a git worktree of the current repository) or directory (any existing directory), not both." };
176
+ }
177
+ if (hasWorktree) return { ok: true, kind: "worktree", path: (args.worktree as string).trim() };
178
+ if (hasDirectory) return { ok: true, kind: "directory", path: (args.directory as string).trim() };
179
+ return { ok: false, error: "Pass exactly one location: worktree (a git worktree of the current repository) or directory (any existing directory)." };
180
+ }
181
+
182
+ /**
183
+ * Whether two directories belong to the same repository, by main-checkout
184
+ * identity (the parent of the absolute git common dir) rather than path
185
+ * equality - the coordinator may itself run in a worktree of the repo the
186
+ * candidate worktree hangs off. Realpaths on both sides: on macOS /tmp
187
+ * resolves to /private/tmp, and the two resolutions must agree on the
188
+ * comparison. Null (outside git, bare repo, spawn failure) never matches.
189
+ */
190
+ export async function isSameMainCheckout(a: string, b: string): Promise<boolean> {
191
+ const [mainA, mainB] = await Promise.all([resolveMainCheckout(a), resolveMainCheckout(b)]);
192
+ if (!mainA || !mainB) return false;
193
+ try {
194
+ return realpathSync(mainA) === realpathSync(mainB);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /** TUI-side, pure so the session-tab tests can run without the TUI runtime. */
201
+
202
+ /** The event-type filter the TUI CLI plugin applies to every fed event. */
203
+ export function isTabOpenedEvent(type: string): boolean {
204
+ return type === tabOpenedEventType;
205
+ }
206
+
207
+ /** The close-request filter: same transport, opposite action. */
208
+ export function isTabClosedEvent(type: string): boolean {
209
+ return type === tabClosedEventType;
210
+ }
211
+
212
+ /** The wrap-up exit's close filter: same TUI action, no pump translation. */
213
+ export function isExitTabClosedEvent(type: string): boolean {
214
+ return type === exitTabClosedEventType;
215
+ }
216
+
217
+ /** The toast-request filter: TUI-only by nature. */
218
+ export function isToastEvent(type: string): boolean {
219
+ return type === toastEventType;
220
+ }
221
+
222
+ /**
223
+ * The directory guard mirrors the built-in tui.* handlers: an event only
224
+ * acts on the window whose directory matches the event's location (the
225
+ * coordinator instance's directory). An event with no location never acts -
226
+ * failing closed, the same as the built-ins' inequality check.
227
+ */
228
+ export function passesDirectoryGuard(eventDirectory: string | undefined, windowDirectory: string | undefined): boolean {
229
+ if (!eventDirectory || !windowDirectory) return false;
230
+ return eventDirectory === windowDirectory;
231
+ }
package/src/setup.ts CHANGED
@@ -66,7 +66,7 @@ function resolvePaths(global: boolean, projectDir: string, homeDir: string): Set
66
66
  // .mcp.json - MCP server registration
67
67
  // ---------------------------------------------------------------------------
68
68
 
69
- function writeMcpConfig(path: string, thatchBin: string): void {
69
+ function writeMcpConfig(path: string, thatchBin: string, projectDir?: string): void {
70
70
  let config: any = { mcpServers: {} };
71
71
  if (existsSync(path)) {
72
72
  try {
@@ -81,6 +81,12 @@ function writeMcpConfig(path: string, thatchBin: string): void {
81
81
  type: "stdio",
82
82
  command: thatchBin,
83
83
  args: ["mcp"],
84
+ // Project-LOCAL configs pin the project directory so the server's
85
+ // identity resolution is deterministic regardless of the cwd the host
86
+ // spawns it with. Global-scope configs must NEVER pin: one entry serves
87
+ // every project, and a first-in-chain env override would clobber all
88
+ // of them. (setup.ts:projectDir chain in src/mcp.ts.)
89
+ ...(projectDir ? { env: { THATCH_PROJECT_DIR: projectDir } } : {}),
84
90
  };
85
91
 
86
92
  mkdirSync(join(path, ".."), { recursive: true });
@@ -356,7 +362,7 @@ export function setupClaudeCode(
356
362
  const paths = resolvePaths(global, projectDir, home);
357
363
 
358
364
  if (paths.mcpConfigPath) {
359
- writeMcpConfig(paths.mcpConfigPath, thatchBin);
365
+ writeMcpConfig(paths.mcpConfigPath, thatchBin, projectDir);
360
366
  }
361
367
 
362
368
  appendInstructions(paths.claudeMdPath);
@@ -622,7 +628,7 @@ export function setupCursor(
622
628
  const home = homeDir ?? homedir();
623
629
  const paths = resolveCursorPaths(global, projectDir, home);
624
630
 
625
- writeMcpConfig(paths.mcpConfigPath, thatchBin);
631
+ writeMcpConfig(paths.mcpConfigPath, thatchBin, global ? undefined : projectDir);
626
632
  appendCursorInstructions(paths.agentsMdPath);
627
633
  writeCursorHooks(paths.hooksPath, thatchBin);
628
634
  const skills = installSkills(paths.skillsDir);
package/src/skills.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { REVIEW_SPECIALISTS } from "./prompts";
3
4
 
4
5
  export interface SkillDef {
5
6
  name: string;
@@ -53,15 +54,10 @@ function loadSharedSkills(): SkillDef[] {
53
54
  "thatch-fact-extractor",
54
55
  "thatch-dedup-classifier",
55
56
  "thatch-project-primer",
56
- "thatch-review-pedantic",
57
- "thatch-review-acceptance",
58
- "thatch-review-state-flow",
59
- "thatch-review-economy",
60
- "thatch-review-no-slop",
61
- "thatch-review-breadcrumbs",
62
- "thatch-review-mark-and-sweep",
63
- "thatch-review-highlights",
64
- "thatch-review-technique",
57
+ // The specialist roster (order and count) comes from prompts.ts so the
58
+ // install set and the review-mode commands' inline procedure cannot
59
+ // diverge.
60
+ ...REVIEW_SPECIALISTS,
65
61
  "thatch-review-synthesizer",
66
62
  "thatch-review-context",
67
63
  "thatch-code-archaeology",
@@ -92,6 +88,7 @@ function loadSharedSkills(): SkillDef[] {
92
88
  function loadOpencodeOnlySkills(): SkillDef[] {
93
89
  return [
94
90
  { name: "thatch-code-review", content: loadSkillFile("thatch-code-review") },
91
+ { name: "thatch-coordination", content: loadSkillFile("thatch-coordination") },
95
92
  ];
96
93
  }
97
94
 
@@ -0,0 +1,125 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CHAT_STALE_MS } from "./chat";
5
+
6
+ /**
7
+ * Reader for opencode's per-TUI-instance tab persistence - the roster's
8
+ * OPEN-vs-RECENT signal. Each opencode TUI window records its open tabs in
9
+ * `<XDG_STATE_HOME>/opencode/<channel>/tui/tabs.json` (the middle segment is
10
+ * the release CHANNEL - "latest" today, so a reader must glob, never
11
+ * hardcode). The file answers "which sessions have a tab right now" for
12
+ * roster classification and broadcast targeting; it is READ-ONLY here -
13
+ * the heartbeat stays the only written liveness signal.
14
+ *
15
+ * This is a presence refinement, not a truth source. A killed window leaves
16
+ * its tab rows behind (the file is restart persistence by design), so a row
17
+ * classified OPEN may occasionally be residue; that trade is accepted in
18
+ * exchange for zero new writers. With tabs disabled (or no state tree at
19
+ * all) the reader returns an empty set and every row honestly degrades to
20
+ * RECENT - never a false OPEN.
21
+ */
22
+
23
+ /** Test/QA override for the opencode state root (the directory that holds
24
+ * one subdirectory per release channel). Unset means the real location. */
25
+ export const OPEN_TABS_STATE_ROOT_ENV = "THATCH_OPENCODE_STATE_ROOT";
26
+
27
+ /** The opencode state root: the directory containing one directory per
28
+ * release channel ("latest" today). */
29
+ export function openTabsStateRoot(env: Record<string, string | undefined> = process.env): string {
30
+ const override = env[OPEN_TABS_STATE_ROOT_ENV];
31
+ if (override) return override;
32
+ const stateHome = env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
33
+ return join(stateHome, "opencode");
34
+ }
35
+
36
+ /** Shape of tabs.json (opencode v2.0.25, session-tabs storage): an optional
37
+ * global section and a per-cwd section, each carrying tab rows with a
38
+ * sessionID. Both sections are read because the scope depends on the
39
+ * `tabs.scope` config ("cwd" or "global") and the reader must honor both. */
40
+ interface TabsFile {
41
+ global?: { tabs?: { sessionID?: string }[] };
42
+ cwd?: Record<string, { tabs?: { sessionID?: string }[] }>;
43
+ }
44
+
45
+ function collectTabs(rows: { sessionID?: string }[] | undefined, into: Set<string>): void {
46
+ if (!Array.isArray(rows)) return;
47
+ for (const row of rows) {
48
+ if (row && typeof row.sessionID === "string" && row.sessionID) into.add(row.sessionID);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Every sessionID carrying a tab in ANY channel's tabs.json, from both
54
+ * scope modes (global section and cwd sections). Per-file failures (no
55
+ * file, bad JSON, malformed sections) are swallowed and contribute
56
+ * nothing: a missing or broken signal degrades to empty, never to an
57
+ * error, so roster rendering and broadcast targeting keep working.
58
+ */
59
+ export function readOpenTabSessions(stateRoot: string = openTabsStateRoot()): Set<string> {
60
+ const open = new Set<string>();
61
+ let channels: string[];
62
+ try {
63
+ channels = readdirSync(stateRoot).filter((name) => !name.startsWith("."));
64
+ } catch {
65
+ // No state tree (opencode never ran here, or tabs are disabled):
66
+ // empty set, everything degrades to RECENT.
67
+ return open;
68
+ }
69
+ for (const channel of channels) {
70
+ let parsed: TabsFile;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(join(stateRoot, channel, "tui", "tabs.json"), "utf8"));
73
+ } catch {
74
+ continue;
75
+ }
76
+ collectTabs(parsed?.global?.tabs, open);
77
+ const byCwd = parsed?.cwd;
78
+ if (byCwd && typeof byCwd === "object") {
79
+ for (const section of Object.values(byCwd)) collectTabs(section?.tabs, open);
80
+ }
81
+ }
82
+ return open;
83
+ }
84
+
85
+ /** How long a confirmed tab close keeps overriding a residue tab row. The
86
+ * close's death path stops the heartbeat, so the row can only read fresh
87
+ * until its frozen last_seen crosses CHAT_STALE_MS; one extra poll
88
+ * interval covers a beat already in flight at close time. After the
89
+ * window the marker is moot (the row is stale regardless), and a session
90
+ * that later regains a tab re-derives OPEN from tabs.json alone. */
91
+ export const CHAT_TAB_CLOSED_OVERRIDE_MS = 2 * CHAT_STALE_MS;
92
+
93
+ /** One confirmed tab close: the session's tab was closed by the
94
+ * session_tab tool and the bridge event confirmed it. */
95
+ export interface ChatTabClosure {
96
+ sessionID: string;
97
+ /** ISO timestamp of the confirmed close. */
98
+ at: string;
99
+ }
100
+
101
+ /**
102
+ * Subtracts fresh confirmed tab closes from the raw tab set, so a residue
103
+ * row in tabs.json (a window that died between the tool's close event and
104
+ * the file write - or before it) cannot read OPEN right after a confirmed
105
+ * close. Closures older than CHAT_TAB_CLOSED_OVERRIDE_MS are ignored.
106
+ */
107
+ export function effectiveOpenTabs(openTabs: ReadonlySet<string>, closures: ChatTabClosure[], now = Date.now()): Set<string> {
108
+ const effective = new Set(openTabs);
109
+ for (const closure of closures) {
110
+ const at = Date.parse(closure.at);
111
+ if (Number.isNaN(at)) continue;
112
+ if (now - at < CHAT_TAB_CLOSED_OVERRIDE_MS) effective.delete(closure.sessionID);
113
+ }
114
+ return effective;
115
+ }
116
+
117
+ /**
118
+ * The production open-tabs provider: read tabs.json at call time (never
119
+ * cached - the file changes between renders) and subtract the confirmed
120
+ * closes recorded in thatch.db. `loadClosures` is the ThatchDB lookup,
121
+ * injected so this composes with any database handle.
122
+ */
123
+ export function chatOpenTabs(loadClosures: () => ChatTabClosure[]): () => Set<string> {
124
+ return () => effectiveOpenTabs(readOpenTabSessions(), loadClosures());
125
+ }