@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.
- package/README.md +56 -26
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +103 -5
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +16 -8
- package/artifacts/skills/thatch-coordination.md +69 -0
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-pr-description.md +21 -13
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-no-slop.md +15 -0
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +21 -1
- package/bin/thatch +48 -15
- package/docs/assets/deepwiki-badge.svg +1 -0
- package/docs/dev/README.md +18 -10
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +4 -1
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +194 -23
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/notifications.md +1 -2
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +56 -15
- package/docs/dev/features/prediction-engine.md +71 -6
- package/docs/dev/features/qa-system.md +19 -3
- package/docs/dev/features/session-lifecycle.md +6 -6
- package/docs/dev/features/session-tabs.md +161 -0
- package/docs/dev/features/setup.md +5 -0
- package/docs/dev/features/watchers.md +62 -6
- package/docs/dev/gotchas.md +89 -0
- package/docs/dev/mcp-parity.md +5 -5
- package/docs/dev/setup-and-hooks.md +7 -2
- package/docs/dev/skills.md +12 -9
- package/docs/plans/generalized-session-heartbeat.md +212 -0
- package/docs/plans/prediction-consolidation.md +88 -68
- package/docs/user/README.md +29 -13
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +5 -1
- package/docs/user/code-review.md +43 -2
- package/docs/user/commands.md +37 -10
- package/docs/user/cross-session-chat.md +76 -26
- package/docs/user/prediction-engine.md +23 -1
- package/docs/user/setup.md +16 -0
- package/docs/user/skills.md +9 -7
- package/docs/user/watchers.md +22 -7
- package/package.json +10 -2
- package/src/alerts.ts +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +358 -43
- package/src/commands.ts +123 -18
- package/src/db.ts +74 -3
- package/src/git.ts +3 -1
- package/src/hygiene.ts +5 -0
- package/src/mcp.ts +101 -9
- package/src/opencode/tui-plugin.ts +90 -0
- package/src/opencode/v2.ts +440 -66
- package/src/prediction.ts +90 -0
- package/src/prompts.ts +164 -29
- package/src/runtime.ts +298 -15
- package/src/scoring-engine.ts +40 -9
- package/src/session-db.ts +4 -4
- package/src/session-tab-shared.ts +231 -0
- package/src/setup.ts +9 -3
- package/src/skills.ts +6 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +454 -76
- package/src/tools.ts +2 -0
- package/src/watchers.ts +263 -26
- package/docs/plans/intuition-drives.md +0 -147
- package/docs/plans/llm-alerts.md +0 -287
- package/docs/plans/thatch-actions.md +0 -179
- 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
package/src/tab-state.ts
ADDED
|
@@ -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
|
+
}
|