@jeffober/thatch 0.2.5 → 0.2.7
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 +48 -25
- package/artifacts/skills/common.md +1 -0
- package/artifacts/skills/thatch-clear-writing.md +11 -0
- package/artifacts/skills/thatch-code-archaeology.md +9 -0
- package/artifacts/skills/thatch-code-review.md +25 -15
- package/artifacts/skills/thatch-plan-refinement.md +33 -4
- package/artifacts/skills/thatch-refine-personal.md +5 -0
- package/artifacts/skills/thatch-refine.md +7 -1
- package/artifacts/skills/thatch-review-clarity.md +101 -0
- package/artifacts/skills/thatch-review-context.md +55 -2
- package/artifacts/skills/thatch-review-followup.md +4 -4
- package/artifacts/skills/thatch-review-response.md +1 -1
- package/artifacts/skills/thatch-review-synthesizer.md +22 -2
- package/bin/thatch +103 -33
- package/docs/dev/README.md +11 -8
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/behavior-engine.md +10 -0
- package/docs/dev/features/cli.md +23 -7
- package/docs/dev/features/commands.md +40 -9
- package/docs/dev/features/cross-session-chat.md +99 -27
- package/docs/dev/features/database.md +4 -4
- package/docs/dev/features/nudge-pipeline.md +14 -0
- package/docs/dev/features/opencode-plugin.md +41 -15
- package/docs/dev/features/prediction-engine.md +27 -1
- 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 +11 -5
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +3 -3
- package/docs/dev/skills.md +12 -8
- package/docs/user/README.md +22 -14
- package/docs/user/behavior-engine.md +7 -1
- package/docs/user/cli.md +28 -15
- package/docs/user/code-review.md +46 -4
- package/docs/user/commands.md +27 -7
- package/docs/user/cross-session-chat.md +48 -20
- package/docs/user/prediction-engine.md +12 -1
- package/docs/user/skills.md +9 -6
- package/package.json +1 -1
- package/src/capabilities.ts +20 -6
- package/src/chat.ts +56 -24
- package/src/commands.ts +109 -18
- package/src/db.ts +23 -2
- package/src/mcp.ts +6 -2
- package/src/opencode/tui-plugin.ts +29 -9
- package/src/opencode/v2.ts +247 -66
- package/src/prompts.ts +55 -6
- package/src/runtime.ts +122 -17
- package/src/scoring-engine.ts +40 -9
- package/src/session-tab-shared.ts +55 -4
- package/src/skills.ts +5 -9
- package/src/tab-state.ts +125 -0
- package/src/tool-defs.ts +55 -22
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { join } from "node:path";
|
|
2
|
-
import { createDebugLog } from "./debug";
|
|
3
|
+
import { createDebugLog, type DebugLog } from "./debug";
|
|
3
4
|
import { ThatchDB, repoPathCache } from "./db";
|
|
5
|
+
import { chatOpenTabs as chatOpenTabsProvider } from "./tab-state";
|
|
4
6
|
import { SharedModelPool } from "./embeddings";
|
|
5
7
|
import { detectRepo, detectWorktreeKind, resolveSpawnCwd } from "./git";
|
|
6
8
|
import { buildCoreContext } from "./tool-defs";
|
|
@@ -134,6 +136,47 @@ export interface ThatchRuntime {
|
|
|
134
136
|
dispose(): Promise<void>;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Whether a wake delivery's target directory can host the attempt right
|
|
141
|
+
* now. existsSync cannot say WHY a path is absent - deleted worktree,
|
|
142
|
+
* broken symlink, permission on a parent - and none of those states can
|
|
143
|
+
* host a wake attempt, so one false answer covers the class (deliberate:
|
|
144
|
+
* the alternative, surfacing non-ENOENT errors through the attempt, buys
|
|
145
|
+
* a toast back for no recovery the mail does not already get from its
|
|
146
|
+
* pending-and-retry cycle). Synchronous on purpose: fire-and-forget
|
|
147
|
+
* callers and test harnesses observe promptSession timing with no added
|
|
148
|
+
* microtask between the gate and the wrapped call.
|
|
149
|
+
*/
|
|
150
|
+
export function directoryAvailable(path: string): boolean {
|
|
151
|
+
return existsSync(path);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The delivery guard: wraps the host's promptSession so a wake attempt
|
|
156
|
+
* never runs against a session whose directory has been deleted. The
|
|
157
|
+
* attempt itself would error, and opencode surfaces that failure to the
|
|
158
|
+
* user as a toast. Skipping instead leaves the pending mail queued - the
|
|
159
|
+
* check runs fresh on every attempt, so a worktree recreated at the same
|
|
160
|
+
* path (or a session revived in a live directory) resumes delivery with
|
|
161
|
+
* nothing lost. This covers every wake site at once because chat nudges,
|
|
162
|
+
* watcher notifications, watcher death notices, rehydrate pings, chat
|
|
163
|
+
* echoes, and extraction-child dispatch all funnel through promptSession,
|
|
164
|
+
* and a host instance is bound to its sessions' own directory.
|
|
165
|
+
*/
|
|
166
|
+
export function guardDelivery(
|
|
167
|
+
promptSession: HostCapabilities["promptSession"],
|
|
168
|
+
directory: string,
|
|
169
|
+
debug: DebugLog,
|
|
170
|
+
): HostCapabilities["promptSession"] {
|
|
171
|
+
return (sessionID, body, mode) => {
|
|
172
|
+
if (!directoryAvailable(directory)) {
|
|
173
|
+
debug("runtime:delivery", `skipped wake to ${sessionID}: directory ${directory} no longer exists`);
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
return promptSession(sessionID, body, mode);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
137
180
|
export async function createRuntime(input: {
|
|
138
181
|
capabilities: HostCapabilities;
|
|
139
182
|
directory: string;
|
|
@@ -146,7 +189,7 @@ export async function createRuntime(input: {
|
|
|
146
189
|
*/
|
|
147
190
|
v2Tools?: boolean;
|
|
148
191
|
}): Promise<ThatchRuntime> {
|
|
149
|
-
const { capabilities
|
|
192
|
+
const { capabilities, directory, worktree, v2Tools } = input;
|
|
150
193
|
// The opencode server's cwd is wherever the server happened to start;
|
|
151
194
|
// `worktree` is the project this plugin instance actually serves.
|
|
152
195
|
// `directory` is the session's own directory - on resume after the
|
|
@@ -169,7 +212,10 @@ export async function createRuntime(input: {
|
|
|
169
212
|
|
|
170
213
|
// The DB must exist before identity detection: detectRepo consults the
|
|
171
214
|
// repo_paths cache when the session directory is gone.
|
|
172
|
-
|
|
215
|
+
// chatOpenTabs composes the tab-state reader (tabs.json across channels,
|
|
216
|
+
// minus confirmed closes) lazily per call - never cached - so roster
|
|
217
|
+
// rendering and broadcast targeting see the current tab state.
|
|
218
|
+
const db: ThatchDB = new ThatchDB(dbPath, { chatOpenTabs: chatOpenTabsProvider(() => db.chatTabClosures()) });
|
|
173
219
|
const repoCache = repoPathCache(db);
|
|
174
220
|
const repo = await detectRepo(directory ?? worktree, repoCache);
|
|
175
221
|
|
|
@@ -181,6 +227,16 @@ export async function createRuntime(input: {
|
|
|
181
227
|
|
|
182
228
|
const debug = createDebugLog(dbPath);
|
|
183
229
|
|
|
230
|
+
// The delivery guard wraps the host's promptSession once, so no wake
|
|
231
|
+
// attempt in this instance runs against a deleted directory (see
|
|
232
|
+
// guardDelivery). The v2 wrap-up command in opencode/v2.ts calls
|
|
233
|
+
// capabilities.promptSession directly and deliberately bypasses this
|
|
234
|
+
// wrapper: it is user-invoked in a live tab, not a wake.
|
|
235
|
+
const caps: HostCapabilities = {
|
|
236
|
+
...capabilities,
|
|
237
|
+
promptSession: guardDelivery(capabilities.promptSession, directory ?? worktree, debug),
|
|
238
|
+
};
|
|
239
|
+
|
|
184
240
|
// Shared embedding model: refcounted per db path (see SharedModelPool).
|
|
185
241
|
const model = sharedModels.acquire(dbPath, modelName);
|
|
186
242
|
const releaseModel = () => sharedModels.release(dbPath);
|
|
@@ -297,15 +353,24 @@ export async function createRuntime(input: {
|
|
|
297
353
|
/** Tells one live same-project session about a dead session's watchers
|
|
298
354
|
* (promptAsync, the watcher-notification path) and clears the durable
|
|
299
355
|
* death row on success. No live peer = the row waits for the next
|
|
300
|
-
* prompt-time surfacing pass.
|
|
301
|
-
|
|
356
|
+
* prompt-time surfacing pass. The dead session's project is the one
|
|
357
|
+
* captured at death time (onSessionDeath), not a re-read of the chat
|
|
358
|
+
* roster: the wrap-up exit unregisters the row between the death record
|
|
359
|
+
* and this delivery, and a re-read would strand a placed death as
|
|
360
|
+
* unplaceable. */
|
|
361
|
+
const deliverWatcherDeathNotices = async (
|
|
362
|
+
deadSessionID: string,
|
|
363
|
+
death: { chatName: string | null; targets: string[] },
|
|
364
|
+
deadProject: string | null,
|
|
365
|
+
) => {
|
|
302
366
|
if (death.targets.length === 0) {
|
|
303
|
-
// A watchless
|
|
304
|
-
|
|
367
|
+
// A watchless death is nobody's news, and onSessionDeath never
|
|
368
|
+
// writes a row for one - so there is nothing to deliver or clean
|
|
369
|
+
// here. Deleting unconditionally would erase an earlier placed row
|
|
370
|
+
// for the same session (a reopened session's second greenlit
|
|
371
|
+
// /thatch/exit); the surfacing pass cleans any legacy empty row.
|
|
305
372
|
return;
|
|
306
373
|
}
|
|
307
|
-
const deadRow = db.findChatSession(deadSessionID);
|
|
308
|
-
const deadProject = deadRow?.project ?? null;
|
|
309
374
|
if (!deadProject) return; // unplaceable death - the row waits for surfacing
|
|
310
375
|
for (const peerID of sessionStatus.keys()) {
|
|
311
376
|
if (peerID === deadSessionID) continue;
|
|
@@ -318,6 +383,10 @@ export async function createRuntime(input: {
|
|
|
318
383
|
{ parts: [{ type: "text", text: watcherDeathNotice(death.targets, { name: death.chatName, sessionID: deadSessionID }, { rearmsOnResume: false }), synthetic: true }] },
|
|
319
384
|
"async",
|
|
320
385
|
);
|
|
386
|
+
// Delayed journal writer (post-await): the reload ownership rule
|
|
387
|
+
// applies - a disposed runtime's rows belong to the reloaded
|
|
388
|
+
// instance (docs/dev/gotchas.md).
|
|
389
|
+
if (disposed) return;
|
|
321
390
|
db.runtimeStateDelete("watcher_death", deadSessionID);
|
|
322
391
|
return;
|
|
323
392
|
} catch (err) {
|
|
@@ -399,11 +468,19 @@ export async function createRuntime(input: {
|
|
|
399
468
|
db.runtimeStatePut("hosted", directory, (hostedRow.value as string[]).filter((sid) => sid !== deadSessionID), directory);
|
|
400
469
|
}
|
|
401
470
|
// Durable death record first, then best-effort live delivery - the
|
|
402
|
-
// row is deleted when a live same-project session has the news.
|
|
471
|
+
// row is deleted when a live same-project session has the news. The
|
|
472
|
+
// project is captured HERE (while the chat row still places it): the
|
|
473
|
+
// wrap-up exit unregisters the row right after recording, so the
|
|
474
|
+
// project cannot be re-derived at delivery time. A watchless death
|
|
475
|
+
// writes no row at all: runtimeStatePut upserts, so an empty-targets
|
|
476
|
+
// write would erase an earlier placed row for the same session (a
|
|
477
|
+
// reopened session that greenlights /thatch/exit a second time).
|
|
403
478
|
const deadRow = db.findChatSession(deadSessionID);
|
|
404
479
|
const deadProject = deadRow?.project ?? null;
|
|
405
|
-
|
|
406
|
-
|
|
480
|
+
if (death.targets.length > 0) {
|
|
481
|
+
db.runtimeStatePut("watcher_death", deadSessionID, { name: death.chatName, targets: death.targets, project: deadProject, at: nowIso() }, directory);
|
|
482
|
+
}
|
|
483
|
+
void deliverWatcherDeathNotices(deadSessionID, death, deadProject);
|
|
407
484
|
},
|
|
408
485
|
});
|
|
409
486
|
// gh presence decides whether watch_create and watch_branch_create work;
|
|
@@ -1282,6 +1359,7 @@ export async function createRuntime(input: {
|
|
|
1282
1359
|
|
|
1283
1360
|
return {
|
|
1284
1361
|
coreContext: buildCoreContext(db, model, repo, {
|
|
1362
|
+
openTabs: chatOpenTabsProvider(() => db.chatTabClosures()),
|
|
1285
1363
|
extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
|
|
1286
1364
|
// A fetcher identity turns the fetch into the delivery record: the
|
|
1287
1365
|
// claimed entries stay accepted, and THAT fetcher's completion
|
|
@@ -1854,7 +1932,16 @@ export async function createRuntime(input: {
|
|
|
1854
1932
|
// initiated only). CONFIRMED death: skip the delivery-persistence
|
|
1855
1933
|
// heuristic entirely and run the death path now.
|
|
1856
1934
|
const props = event.properties as { sessionID?: string; chatName?: string | null };
|
|
1857
|
-
if (props.sessionID)
|
|
1935
|
+
if (props.sessionID) {
|
|
1936
|
+
// Roster demotion record: a confirmed close lets every reader
|
|
1937
|
+
// (roster render, broadcast targeting) distrust a residue tab
|
|
1938
|
+
// row for this session instantly, instead of waiting for
|
|
1939
|
+
// tabs.json to agree or the heartbeat to age out. Self-expiring
|
|
1940
|
+
// (CHAT_TAB_CLOSED_OVERRIDE_MS) and superseded by any later
|
|
1941
|
+
// heartbeat, so a session that regains a tab reads OPEN again.
|
|
1942
|
+
db.runtimeStatePut("chat_tab_closed", props.sessionID, { at: nowIso() }, directory);
|
|
1943
|
+
watchers.sessionDied(props.sessionID, { chatName: props.chatName ?? null });
|
|
1944
|
+
}
|
|
1858
1945
|
return;
|
|
1859
1946
|
}
|
|
1860
1947
|
if (event.type === "session.status") {
|
|
@@ -1898,6 +1985,13 @@ export async function createRuntime(input: {
|
|
|
1898
1985
|
const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
|
|
1899
1986
|
if (wrapUp) {
|
|
1900
1987
|
pendingWrapUp.delete(sessionID);
|
|
1988
|
+
// Reload ownership rule (docs/dev/gotchas.md): after a v2 plugin
|
|
1989
|
+
// reload this instance's journal rows belong to the reloaded
|
|
1990
|
+
// instance - resolve nothing further once disposed. Safe today
|
|
1991
|
+
// because the only producer of this branch (the v2 pump) is
|
|
1992
|
+
// settled before dispose, but the guard makes that invariant
|
|
1993
|
+
// local instead of transitive.
|
|
1994
|
+
if (disposed) return;
|
|
1901
1995
|
db.runtimeStateDelete("wrapup", sessionID);
|
|
1902
1996
|
let ready = false;
|
|
1903
1997
|
try {
|
|
@@ -1913,13 +2007,24 @@ export async function createRuntime(input: {
|
|
|
1913
2007
|
console.error(`[thatch] wrap-up message fetch failed: ${err}`);
|
|
1914
2008
|
}
|
|
1915
2009
|
if (ready) {
|
|
1916
|
-
// An exit-greenlit session is
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
2010
|
+
// An exit-greenlit session is closing out: leave the chat
|
|
2011
|
+
// directory too, so other sessions stop addressing mail to a
|
|
2012
|
+
// roster entry whose host is about to vanish. The unregister
|
|
1919
2013
|
// tombstone also stops any straggler auto-register from
|
|
1920
2014
|
// resurrecting the row during shutdown. Compact keeps the
|
|
1921
2015
|
// session alive, so only the exit path does this.
|
|
1922
2016
|
if (wrapUp.kind === "exit") {
|
|
2017
|
+
// Watcher deaths run BEFORE the unregister: onSessionDeath
|
|
2018
|
+
// places the death row by the session's chat row, and the
|
|
2019
|
+
// unregister deletes that row. The v2 exit action's emit is
|
|
2020
|
+
// TUI-only (the pump never translates it into a second
|
|
2021
|
+
// death pass, which would erase the placed row), so this is
|
|
2022
|
+
// the exit's only death recording.
|
|
2023
|
+
try {
|
|
2024
|
+
watchers.sessionDied(sessionID, { chatName: db.findChatSession(sessionID)?.name ?? null });
|
|
2025
|
+
} catch (err) {
|
|
2026
|
+
console.error(`[thatch] watcher death recording on exit failed: ${err}`);
|
|
2027
|
+
}
|
|
1923
2028
|
try {
|
|
1924
2029
|
db.unregisterChatSession(sessionID);
|
|
1925
2030
|
} catch (err) {
|
|
@@ -1930,7 +2035,7 @@ export async function createRuntime(input: {
|
|
|
1930
2035
|
if (wrapUp.kind === "compact") {
|
|
1931
2036
|
await caps.compactSession(sessionID);
|
|
1932
2037
|
} else {
|
|
1933
|
-
await caps.exitHost();
|
|
2038
|
+
await caps.exitHost(sessionID);
|
|
1934
2039
|
}
|
|
1935
2040
|
} catch (err) {
|
|
1936
2041
|
console.error(`[thatch] wrap-up action failed: ${err}`);
|
package/src/scoring-engine.ts
CHANGED
|
@@ -288,13 +288,29 @@ export class ScoringEngine {
|
|
|
288
288
|
|
|
289
289
|
createEdge(matcherId: string, itemId: string, weight: number): void {
|
|
290
290
|
const now = new Date().toISOString();
|
|
291
|
-
const { edgesTable, itemForeignKey } = this.#cfg;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
291
|
+
const { edgesTable, itemForeignKey, matchersTable } = this.#cfg;
|
|
292
|
+
// Insert and bump run atomically: an edge that landed without its
|
|
293
|
+
// updated_at bump would read as older than it is. bun:sqlite nests
|
|
294
|
+
// transaction functions via savepoints, so this composes with a
|
|
295
|
+
// caller's outer transaction (prediction_update's, for one).
|
|
296
|
+
// Bump only when the insert landed - a re-link to an existing edge
|
|
297
|
+
// changes nothing.
|
|
298
|
+
// A matcher's updated_at means "last change to the matcher row or its
|
|
299
|
+
// edge set" (not "matcher text written"): re-linking a matcher to a
|
|
300
|
+
// corrected item is graph activity the planned staleness audit must
|
|
301
|
+
// see. No reader consumes matcher updated_at yet; the bump keeps the
|
|
302
|
+
// timestamp truthful for when that audit lands.
|
|
303
|
+
this.#db.transaction(() => {
|
|
304
|
+
const result = this.#db.run(
|
|
305
|
+
`INSERT INTO ${edgesTable} (matcher_id, ${itemForeignKey}, weight, created_at)
|
|
306
|
+
VALUES (?, ?, ?, ?)
|
|
307
|
+
ON CONFLICT(matcher_id, ${itemForeignKey}) DO NOTHING`,
|
|
308
|
+
[matcherId, itemId, weight, now] as any,
|
|
309
|
+
);
|
|
310
|
+
if (result.changes > 0) {
|
|
311
|
+
this.#db.run(`UPDATE ${matchersTable} SET updated_at = ? WHERE id = ?`, [now, matcherId] as any);
|
|
312
|
+
}
|
|
313
|
+
})();
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
adjustConfidence(itemId: string, signal: "confirm" | "disconfirm" | "soft"): void {
|
|
@@ -349,8 +365,23 @@ export class ScoringEngine {
|
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
deleteItem(itemId: string): boolean {
|
|
352
|
-
const
|
|
353
|
-
|
|
368
|
+
const { itemsTable, edgesTable, itemForeignKey, matchersTable } = this.#cfg;
|
|
369
|
+
// Bump the matchers whose edges are about to cascade away with the
|
|
370
|
+
// item (edge rows have ON DELETE CASCADE, so the DELETE below removes
|
|
371
|
+
// them implicitly). Same updated_at meaning as createEdge: last change
|
|
372
|
+
// to the matcher row or its edge set. Bump and delete run atomically
|
|
373
|
+
// (bun:sqlite nests via savepoints): a bump whose delete then failed
|
|
374
|
+
// would record graph activity that never happened.
|
|
375
|
+
return this.#db.transaction(() => {
|
|
376
|
+
const now = new Date().toISOString();
|
|
377
|
+
this.#db.run(
|
|
378
|
+
`UPDATE ${matchersTable} SET updated_at = ?
|
|
379
|
+
WHERE id IN (SELECT matcher_id FROM ${edgesTable} WHERE ${itemForeignKey} = ?)`,
|
|
380
|
+
[now, itemId] as any,
|
|
381
|
+
);
|
|
382
|
+
const result = this.#db.run(`DELETE FROM ${itemsTable} WHERE id = ?`, [itemId]);
|
|
383
|
+
return result.changes > 0;
|
|
384
|
+
})();
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
listItems(store: string): {
|
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
* The schemas are literal JSON Schema objects because
|
|
18
18
|
* Rpc.PortableEventValueSchema accepts StandardSchemaV1 or a JSON Schema
|
|
19
19
|
* object with type "object"; a literal object keeps this module
|
|
20
|
-
* dependency-free. The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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).
|
|
24
32
|
*/
|
|
25
33
|
|
|
26
34
|
import { realpathSync } from "node:fs";
|
|
@@ -30,6 +38,8 @@ import { resolveMainCheckout } from "./git";
|
|
|
30
38
|
export const SESSION_TAB_RPC_ID = "thatch-tabs";
|
|
31
39
|
export const TAB_OPENED_EVENT = "tab-opened";
|
|
32
40
|
export const TAB_CLOSED_EVENT = "tab-closed";
|
|
41
|
+
export const EXIT_TAB_CLOSED_EVENT = "exit-tab-closed";
|
|
42
|
+
export const TOAST_EVENT = "toast";
|
|
33
43
|
|
|
34
44
|
/** Full wire type of an emitted event, e.g. "rpc.thatch-tabs.tab-opened". */
|
|
35
45
|
export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}`;
|
|
@@ -38,6 +48,12 @@ export const tabOpenedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_OPENED_EVENT}
|
|
|
38
48
|
* heartbeat plan's confirmed-close consumer matches on this. */
|
|
39
49
|
export const tabClosedEventType = `rpc.${SESSION_TAB_RPC_ID}.${TAB_CLOSED_EVENT}`;
|
|
40
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
|
+
|
|
41
57
|
export const SESSION_TAB_RPC = {
|
|
42
58
|
id: SESSION_TAB_RPC_ID,
|
|
43
59
|
methods: {},
|
|
@@ -67,6 +83,31 @@ export const SESSION_TAB_RPC = {
|
|
|
67
83
|
additionalProperties: false,
|
|
68
84
|
},
|
|
69
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
|
+
},
|
|
70
111
|
},
|
|
71
112
|
} as const;
|
|
72
113
|
|
|
@@ -168,6 +209,16 @@ export function isTabClosedEvent(type: string): boolean {
|
|
|
168
209
|
return type === tabClosedEventType;
|
|
169
210
|
}
|
|
170
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
|
+
|
|
171
222
|
/**
|
|
172
223
|
* The directory guard mirrors the built-in tui.* handlers: an event only
|
|
173
224
|
* acts on the window whose directory matches the event's location (the
|
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",
|
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
|
+
}
|