@jeffober/thatch 0.1.41 → 0.1.43
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 +28 -11
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/release +4 -1
- package/bin/thatch +268 -55
- package/docs/dev/README.md +7 -4
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +31 -11
- package/docs/dev/features/cross-session-chat.md +141 -67
- package/docs/dev/features/multi-host.md +4 -3
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/features/watchers.md +113 -23
- package/docs/dev/gotchas.md +9 -0
- package/docs/dev/mcp-parity.md +2 -1
- package/docs/dev/skills.md +1 -0
- package/docs/user/README.md +19 -3
- package/docs/user/cli.md +68 -14
- package/docs/user/cross-session-chat.md +80 -27
- package/docs/user/skills.md +1 -0
- package/docs/user/watchers.md +61 -16
- package/package.json +2 -1
- package/src/chat-names.ts +6 -7
- package/src/chat.ts +553 -152
- package/src/commands.ts +97 -0
- package/src/config.ts +34 -1
- package/src/db.ts +67 -7
- package/src/git.ts +22 -0
- package/src/index.ts +198 -25
- package/src/prompts.ts +142 -50
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +301 -98
- package/src/tools.ts +2 -0
- package/src/watchers.ts +358 -23
package/src/commands.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thatch's wrap-up slash commands for opencode, shipped as command markdown
|
|
6
|
+
* files synced into the global opencode config dir (~/.config/opencode/command/
|
|
7
|
+
* thatch/). The plugin writes them on every load so template updates self-heal
|
|
8
|
+
* without a reinstall. Config is loaded before plugins during server startup,
|
|
9
|
+
* so a first-ever install becomes visible on the next start.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Greenlight token ending /thatch/compact's assistant response when compaction is safe. */
|
|
13
|
+
export const COMPACT_READY_TOKEN = "THATCH_COMPACT_READY";
|
|
14
|
+
|
|
15
|
+
/** Greenlight token ending /thatch/exit's assistant response when exiting is safe. */
|
|
16
|
+
export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The plugin triggers compaction by running the TUI's compact action. The
|
|
20
|
+
* execute-command route only accepts legacy alias names, and "session_compact"
|
|
21
|
+
* maps to the same session.compact action the built-in /compact command runs.
|
|
22
|
+
* There is no exit alias, so the exit path publishes the TUI keymap command
|
|
23
|
+
* ("app.exit") directly via /tui/publish.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const sharedChecklist = `1. Flush pending persistence. Call thatch_get_extraction_payload; if it returns buffered tool interactions, process them now (write any memories worth keeping), then call thatch_extraction_done to mark them complete. Complete any memory writes you promised earlier but have not made.
|
|
27
|
+
2. Check for loose ends. Review the conversation for todos, follow-ups, or open questions raised earlier that were never addressed, and surface anything the user should address before the wrap-up completes.
|
|
28
|
+
|
|
29
|
+
Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:`;
|
|
30
|
+
|
|
31
|
+
const COMPACT_TEMPLATE = `---
|
|
32
|
+
description: Flush thatch persistence, check for loose ends, then compact if clear
|
|
33
|
+
---
|
|
34
|
+
$ARGUMENTS
|
|
35
|
+
|
|
36
|
+
Pre-compaction wrap-up. Work through this checklist before responding:
|
|
37
|
+
|
|
38
|
+
${sharedChecklist}
|
|
39
|
+
|
|
40
|
+
${COMPACT_READY_TOKEN}
|
|
41
|
+
|
|
42
|
+
If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. The session will only be compacted when the token is present.`;
|
|
43
|
+
|
|
44
|
+
const EXIT_TEMPLATE = `---
|
|
45
|
+
description: Flush thatch persistence, check for loose ends, then exit opencode if clear
|
|
46
|
+
---
|
|
47
|
+
$ARGUMENTS
|
|
48
|
+
|
|
49
|
+
Pre-exit wrap-up. Work through this checklist before responding:
|
|
50
|
+
|
|
51
|
+
${sharedChecklist}
|
|
52
|
+
3. Leave the chat directory. Call thatch_chat_unregister for this session - the session is exiting, so other sessions must stop addressing mail to it.
|
|
53
|
+
|
|
54
|
+
Do not start new work beyond this checklist. If every item is handled and nothing needs the user's attention first, end your final response with exactly this token as the very last line, with no formatting around it:
|
|
55
|
+
|
|
56
|
+
${EXIT_READY_TOKEN}
|
|
57
|
+
|
|
58
|
+
If anything is outstanding, list the items concisely so the user can address them, and do NOT include the token. opencode will only exit when the token is present.`;
|
|
59
|
+
|
|
60
|
+
/** One wrap-up command: the command name (minus the thatch/ prefix) and its full markdown file content. */
|
|
61
|
+
export interface CommandDef {
|
|
62
|
+
name: string;
|
|
63
|
+
content: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The wrap-up command set, as full command-file contents ready to write. */
|
|
67
|
+
export function opencodeCommandDefs(): CommandDef[] {
|
|
68
|
+
return [
|
|
69
|
+
{ name: "compact", content: COMPACT_TEMPLATE },
|
|
70
|
+
{ name: "exit", content: EXIT_TEMPLATE },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sync the wrap-up commands into <configHome>/opencode/command/thatch/.
|
|
76
|
+
* Idempotent: each file is written only when its on-disk content differs.
|
|
77
|
+
* Returns the paths that were written (empty when everything was current).
|
|
78
|
+
*/
|
|
79
|
+
export function installOpencodeCommands(configHome: string): string[] {
|
|
80
|
+
const dir = join(configHome, "opencode", "command", "thatch");
|
|
81
|
+
const written: string[] = [];
|
|
82
|
+
for (const def of opencodeCommandDefs()) {
|
|
83
|
+
const path = join(dir, `${def.name}.md`);
|
|
84
|
+
let current: string | null = null;
|
|
85
|
+
try {
|
|
86
|
+
current = readFileSync(path, "utf8");
|
|
87
|
+
} catch {
|
|
88
|
+
current = null;
|
|
89
|
+
}
|
|
90
|
+
if (current !== def.content) {
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
writeFileSync(path, def.content);
|
|
93
|
+
written.push(path);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return written;
|
|
97
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -16,15 +16,22 @@ export const notificationPrefsSchema = z.strictObject({
|
|
|
16
16
|
sound: z.string().optional(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
export const chatPrefsSchema = z.strictObject({
|
|
20
|
+
enabled: z.boolean().optional(),
|
|
21
|
+
autoRegister: z.boolean().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
export const configSchema = z.strictObject({
|
|
20
25
|
notifications: notificationPrefsSchema.optional(),
|
|
26
|
+
chat: chatPrefsSchema.optional(),
|
|
21
27
|
});
|
|
22
28
|
|
|
23
29
|
export type NotificationPrefs = z.infer<typeof notificationPrefsSchema>;
|
|
30
|
+
export type ChatPrefs = z.infer<typeof chatPrefsSchema>;
|
|
24
31
|
export type Config = z.infer<typeof configSchema>;
|
|
25
32
|
|
|
26
33
|
/** Sections in presentation order. config_get / config_set iterate this. */
|
|
27
|
-
export const CONFIG_SECTIONS = ["notifications"] as const;
|
|
34
|
+
export const CONFIG_SECTIONS = ["notifications", "chat"] as const;
|
|
28
35
|
export type ConfigSection = (typeof CONFIG_SECTIONS)[number];
|
|
29
36
|
|
|
30
37
|
/**
|
|
@@ -90,6 +97,32 @@ export function mergeNotificationPrefs(
|
|
|
90
97
|
return { ...current, ...patch };
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/** Field-level merge for one section: omitted fields keep their values. */
|
|
101
|
+
export function mergeChatPrefs(current: ChatPrefs | undefined, patch: ChatPrefs): ChatPrefs {
|
|
102
|
+
return { ...current, ...patch };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether cross-session chat is on. Unset means on - chat is the default,
|
|
107
|
+
* and the toggle exists for users who want the feature entirely dark: every
|
|
108
|
+
* chat tool refuses, wake delivery never starts, and the system prompt
|
|
109
|
+
* omits the chat section.
|
|
110
|
+
*/
|
|
111
|
+
export function chatEnabled(config: Config): boolean {
|
|
112
|
+
return config.chat?.enabled !== false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether opencode sessions join the chat directory automatically (first
|
|
117
|
+
* idle event, name assigned by thatch). Unset means on - the directory is
|
|
118
|
+
* only useful when peers are in it, and a session that must be told to
|
|
119
|
+
* register never is. Turning it off restores opt-in behavior: sessions
|
|
120
|
+
* stay out of the directory until they call chat_register.
|
|
121
|
+
*/
|
|
122
|
+
export function chatAutoRegister(config: Config): boolean {
|
|
123
|
+
return config.chat?.autoRegister !== false;
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
/**
|
|
94
127
|
* Platform fallbacks shown by config_get and applied by notify_user when the
|
|
95
128
|
* user has not set a preference. Only darwin has opinionated defaults; other
|
package/src/db.ts
CHANGED
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
PredictionEngine,
|
|
5
5
|
} from "./prediction";
|
|
6
6
|
import { BehaviorEngine } from "./behavior";
|
|
7
|
-
import { ChatStore, type ChatHostKind } from "./chat";
|
|
7
|
+
import { ChatStore, type ChatHostKind, type ChatWorktreeKind } from "./chat";
|
|
8
8
|
import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
|
|
9
9
|
|
|
10
10
|
export { cosineSimilarity } from "./vector-math";
|
|
11
11
|
export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
|
|
12
12
|
export type { BehaviorNudgeItem, BehaviorRow, ScoredBehavior } from "./behavior";
|
|
13
|
-
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow,
|
|
13
|
+
export type { ChatSessionRow, ChatInboxItem, ChatNotificationRow, ChatWorktreeKind } from "./chat";
|
|
14
14
|
|
|
15
15
|
export interface MemoryRow {
|
|
16
16
|
slug: string;
|
|
@@ -238,7 +238,9 @@ export class ThatchDB {
|
|
|
238
238
|
project TEXT,
|
|
239
239
|
host_kind TEXT NOT NULL DEFAULT ('opencode'),
|
|
240
240
|
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
241
|
-
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
241
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
|
242
|
+
auto INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
worktree TEXT NOT NULL DEFAULT ('')
|
|
242
244
|
)
|
|
243
245
|
`);
|
|
244
246
|
|
|
@@ -262,11 +264,43 @@ export class ThatchDB {
|
|
|
262
264
|
|
|
263
265
|
this.#db.run("INSERT OR IGNORE INTO stores (name) VALUES ('global')");
|
|
264
266
|
|
|
267
|
+
// Per-base name counters for assigned chat names. A row only ever
|
|
268
|
+
// increments (never on prune), so a name is minted exactly once.
|
|
269
|
+
this.#db.run(`
|
|
270
|
+
CREATE TABLE IF NOT EXISTS chat_name_counters (
|
|
271
|
+
base TEXT PRIMARY KEY,
|
|
272
|
+
next INTEGER NOT NULL DEFAULT 1
|
|
273
|
+
)
|
|
274
|
+
`);
|
|
275
|
+
|
|
276
|
+
// Explicit leaves (chat_unregister). Suppresses auto-registration so
|
|
277
|
+
// the next idle event cannot silently re-register the session; cleared
|
|
278
|
+
// when the session explicitly rejoins via chat_register. Survives the
|
|
279
|
+
// directory-row delete, which is the point.
|
|
280
|
+
this.#db.run(`
|
|
281
|
+
CREATE TABLE IF NOT EXISTS chat_leave_tombstones (
|
|
282
|
+
session_id TEXT PRIMARY KEY,
|
|
283
|
+
left_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
284
|
+
)
|
|
285
|
+
`);
|
|
286
|
+
|
|
265
287
|
this.#migrateColumns();
|
|
266
288
|
this.#migrateChatNameCollation();
|
|
267
289
|
this.#migrateChatTopic();
|
|
268
290
|
this.#migrateChatHostKind();
|
|
269
291
|
this.#migrateChatBroadcastFlag();
|
|
292
|
+
this.#migrateChatAuto();
|
|
293
|
+
this.#migrateChatWorktree();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
297
|
+
// adds it, and existing rows read as manual (which they were - auto is
|
|
298
|
+
// only set by the assigned-name registration path).
|
|
299
|
+
#migrateChatAuto(): void {
|
|
300
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
301
|
+
if (cols.length > 0 && !cols.includes("auto")) {
|
|
302
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN auto INTEGER NOT NULL DEFAULT 0");
|
|
303
|
+
}
|
|
270
304
|
}
|
|
271
305
|
|
|
272
306
|
// chat_sessions tables created before the host_kind column lack it; the
|
|
@@ -278,6 +312,16 @@ export class ThatchDB {
|
|
|
278
312
|
}
|
|
279
313
|
}
|
|
280
314
|
|
|
315
|
+
// chat_sessions tables created before the worktree column lack it; the
|
|
316
|
+
// ALTER adds it, and existing rows read as undetected (rendered as no
|
|
317
|
+
// loc token in chat_list) - they registered before the capture existed.
|
|
318
|
+
#migrateChatWorktree(): void {
|
|
319
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
320
|
+
if (cols.length > 0 && !cols.includes("worktree")) {
|
|
321
|
+
this.#db.run("ALTER TABLE chat_sessions ADD COLUMN worktree TEXT NOT NULL DEFAULT ''");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
281
325
|
// chat_messages tables created before the via_broadcast column lack it;
|
|
282
326
|
// the ALTER adds it, and existing rows read as direct sends (which they
|
|
283
327
|
// were).
|
|
@@ -824,12 +868,28 @@ export class ThatchDB {
|
|
|
824
868
|
// Cross-session chat: delegates to ChatStore
|
|
825
869
|
// ---------------------------------------------------------------------------
|
|
826
870
|
|
|
827
|
-
registerChatSession(sessionID: string,
|
|
828
|
-
return this.#chat.register(sessionID,
|
|
871
|
+
registerChatSession(sessionID: string, project: string | null, topic: string | null, kind: ChatHostKind, nameBase: string | null = null, worktree: ChatWorktreeKind = null) {
|
|
872
|
+
return this.#chat.register(sessionID, project, topic, kind, nameBase, worktree);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
refreshChatTopic(sessionID: string, title: string) {
|
|
876
|
+
return this.#chat.refreshAutoTopic(sessionID, title);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
pruneStaleChatAuto(cutoff: string) {
|
|
880
|
+
return this.#chat.pruneStaleAuto(cutoff);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
hasChatLeaveTombstone(sessionID: string) {
|
|
884
|
+
return this.#chat.hasLeaveTombstone(sessionID);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
clearChatLeaveTombstone(sessionID: string) {
|
|
888
|
+
return this.#chat.clearLeaveTombstone(sessionID);
|
|
829
889
|
}
|
|
830
890
|
|
|
831
|
-
|
|
832
|
-
return this.#chat.
|
|
891
|
+
chatLeaveSuperseded(sessionID: string, project: string | null) {
|
|
892
|
+
return this.#chat.leaveSuperseded(sessionID, project);
|
|
833
893
|
}
|
|
834
894
|
|
|
835
895
|
broadcastChatMessage(fromSession: string, body: string) {
|
package/src/git.ts
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Classifies the checkout the given directory serves: "worktree" for a
|
|
7
|
+
* linked git worktree (its .git is a FILE pointing at the main repo's
|
|
8
|
+
* gitdir), "root" for the project's main checkout (.git is a DIRECTORY),
|
|
9
|
+
* and null when undetectable (no .git at all, or no directory given).
|
|
10
|
+
* Synchronous and cheap: callers capture this once at chat registration,
|
|
11
|
+
* so the roster can show which sessions share the main tree.
|
|
12
|
+
*/
|
|
13
|
+
export function detectWorktreeKind(dir?: string | null): "root" | "worktree" | null {
|
|
14
|
+
if (!dir) return null;
|
|
15
|
+
try {
|
|
16
|
+
const st = statSync(join(dir, ".git"));
|
|
17
|
+
if (st.isFile()) return "worktree";
|
|
18
|
+
if (st.isDirectory()) return "root";
|
|
19
|
+
return null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
2
24
|
|
|
3
25
|
/**
|
|
4
26
|
* Extracts an `owner/repo` slug from a git remote URL. Handles the common
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
3
3
|
import { ThatchDB } from "./db";
|
|
4
4
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
5
|
-
import { detectRepo } from "./git";
|
|
5
|
+
import { detectRepo, detectWorktreeKind } from "./git";
|
|
6
6
|
import { createTools } from "./tools";
|
|
7
7
|
import {
|
|
8
8
|
systemPrompt,
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
} from "./prompts";
|
|
19
19
|
import { ExtractionPipeline, type ToolInteraction } from "./extraction";
|
|
20
20
|
import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
|
|
21
|
+
import { installOpencodeCommands, COMPACT_READY_TOKEN, EXIT_READY_TOKEN } from "./commands";
|
|
21
22
|
import { hygieneReport } from "./hygiene";
|
|
22
23
|
import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
23
24
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
24
25
|
import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
|
|
25
26
|
import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
26
|
-
import { ChatPoller } from "./chat";
|
|
27
|
+
import { ChatPoller, isDefaultSessionTitle, slugifyTitle } from "./chat";
|
|
28
|
+
import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
|
|
27
29
|
import pkg from "../package.json";
|
|
28
30
|
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
@@ -51,6 +53,15 @@ const PREDICTION_THRESHOLD = parseFloat(process.env.THATCH_PREDICTION_THRESHOLD
|
|
|
51
53
|
// situation genuinely matches.
|
|
52
54
|
const BEHAVIOR_THRESHOLD = parseFloat(process.env.THATCH_BEHAVIOR_THRESHOLD ?? "0.60");
|
|
53
55
|
|
|
56
|
+
// Wrap-up command registry: command name as opencode derives it from the
|
|
57
|
+
// installed file path (~/.config/opencode/command/thatch/<name>.md), the
|
|
58
|
+
// greenlight token the command instructs the model to end its response with,
|
|
59
|
+
// and which TUI action the greenlight triggers.
|
|
60
|
+
const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit" }> = {
|
|
61
|
+
"thatch/compact": { token: COMPACT_READY_TOKEN, kind: "compact" },
|
|
62
|
+
"thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
|
|
63
|
+
};
|
|
64
|
+
|
|
54
65
|
export const server: Plugin = async ({ client, worktree }) => {
|
|
55
66
|
// The opencode server's cwd is wherever the server happened to start;
|
|
56
67
|
// `worktree` is the project this plugin instance actually serves.
|
|
@@ -60,6 +71,12 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
60
71
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
61
72
|
const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
|
|
62
73
|
|
|
74
|
+
// Whether cross-session chat is on (chat.enabled, default on). Read once
|
|
75
|
+
// at init: it gates the chat poller and the prompt's chat section. The
|
|
76
|
+
// chat tools re-read the file per call, so a toggle takes effect there
|
|
77
|
+
// without a restart.
|
|
78
|
+
const chatOn = chatEnabled(loadConfig(dbPath).config);
|
|
79
|
+
|
|
63
80
|
const db = new ThatchDB(dbPath);
|
|
64
81
|
const model = new BgeEmbeddingModel(modelName);
|
|
65
82
|
const extraction = new ExtractionPipeline();
|
|
@@ -95,23 +112,49 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
95
112
|
// process may deliver chat mail to - the map's keys).
|
|
96
113
|
const sessionStatus = new Map<string, string>();
|
|
97
114
|
|
|
115
|
+
// The proactive-prompt gate, shared by both delivery registries (watchers
|
|
116
|
+
// and chat). Two layers, in cost order: the compacting set and the
|
|
117
|
+
// event-fed sessionStatus map answer cheaply, and a final check verifies
|
|
118
|
+
// against the server's live status - the map is only as fresh as the
|
|
119
|
+
// event stream, and a stale "idle" turns a wake into mid-turn context
|
|
120
|
+
// injection (observed live: a session read mail mid-burst, contradicted
|
|
121
|
+
// its own in-flight plan, and looped).
|
|
122
|
+
const canPromptSession = async (sessionID: string): Promise<boolean> => {
|
|
123
|
+
if (compacting.has(sessionID)) return false;
|
|
124
|
+
if (sessionStatus.get(sessionID) !== "idle") return false;
|
|
125
|
+
try {
|
|
126
|
+
const { data } = await client.session.status();
|
|
127
|
+
// The server's status map only carries ACTIVE sessions - it deletes
|
|
128
|
+
// the entry when a session goes idle (session/status.ts), so absent
|
|
129
|
+
// means idle. Fail closed on busy/retry, pass on idle-or-absent.
|
|
130
|
+
const live = data?.[sessionID];
|
|
131
|
+
return !live || live.type === "idle";
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Unreachable server: do not deliver (the mail stays pending).
|
|
134
|
+
console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
98
139
|
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
99
|
-
//
|
|
100
|
-
// state. See src/watchers.ts
|
|
140
|
+
// and branch watching, plus local command watches). Deliberately
|
|
141
|
+
// process-scoped: no SQLite, no cross-restart state. See src/watchers.ts
|
|
142
|
+
// for the rationale.
|
|
101
143
|
//
|
|
102
144
|
// Delivery prompts the session with a synthetic part - the same mechanism
|
|
103
145
|
// opencode uses for background task completions - so a watched event
|
|
104
|
-
// triggers a model turn even when the user is away. Events
|
|
105
|
-
//
|
|
146
|
+
// triggers a model turn even when the user is away. Events carry pointer
|
|
147
|
+
// data plus machine status (check conclusions, exit codes); the model
|
|
148
|
+
// fetches logs and further details itself with gh.
|
|
106
149
|
const watchers = new WatcherRegistry({
|
|
107
150
|
deliver: async (sessionID, events) => {
|
|
108
151
|
// Events carry their watch's target label from the registry, so the
|
|
109
152
|
// notification header is correct for every source (PRs, branches,
|
|
110
|
-
// CI events whose URLs would not parse).
|
|
153
|
+
// commands, CI events whose URLs would not parse).
|
|
111
154
|
await client.session.promptAsync({
|
|
112
155
|
path: { id: sessionID },
|
|
113
156
|
body: {
|
|
114
|
-
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
|
|
157
|
+
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events, watchers.pollSeconds), synthetic: true }],
|
|
115
158
|
},
|
|
116
159
|
});
|
|
117
160
|
// Toast: the notification part is TUI-hidden, so without this the
|
|
@@ -133,25 +176,24 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
133
176
|
// TUI may not be connected. Best-effort.
|
|
134
177
|
}
|
|
135
178
|
},
|
|
136
|
-
canDeliver:
|
|
137
|
-
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
179
|
+
canDeliver: canPromptSession,
|
|
138
180
|
ghRunner: ghApiRun,
|
|
139
181
|
});
|
|
140
|
-
// gh presence decides whether watch_create
|
|
141
|
-
//
|
|
182
|
+
// gh presence decides whether watch_create and watch_branch_create work;
|
|
183
|
+
// checked lazily by the tools, but log once at startup so misconfiguration
|
|
184
|
+
// is visible in debug logs. watch_command_create needs only bash, not gh.
|
|
142
185
|
void ghAvailable().then((ok) => {
|
|
143
|
-
if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
|
|
186
|
+
if (!ok) console.error("[thatch] gh CLI not found - GitHub watch tools will report unavailable");
|
|
144
187
|
});
|
|
145
188
|
watchers.start();
|
|
146
189
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// it at call time, after full initialization, same as the watcher gate.
|
|
190
|
+
// The proactive-prompt gate, shared by both delivery registries
|
|
191
|
+
// (watchers and chat). Two layers, in cost order: the compacting set and
|
|
192
|
+
// the event-fed sessionStatus map answer cheaply, and a final check
|
|
193
|
+
// verifies against the server's live status - the map is only as fresh
|
|
194
|
+
// as the event stream, and a stale "idle" turns a wake into mid-turn
|
|
195
|
+
// context injection (observed live: a session read mail mid-burst,
|
|
196
|
+
// contradicted its own in-flight plan, and looped).
|
|
155
197
|
const chatPoller = new ChatPoller({
|
|
156
198
|
store: db,
|
|
157
199
|
hostedSessions: () => {
|
|
@@ -183,10 +225,11 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
183
225
|
// TUI may not be connected. Best-effort.
|
|
184
226
|
}
|
|
185
227
|
},
|
|
186
|
-
canDeliver:
|
|
187
|
-
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
|
228
|
+
canDeliver: canPromptSession,
|
|
188
229
|
});
|
|
189
|
-
|
|
230
|
+
// With chat disabled the tools refuse, so the poller would only heartbeat
|
|
231
|
+
// sessions and burn wake budget against a feature the user turned off.
|
|
232
|
+
if (chatOn) chatPoller.start();
|
|
190
233
|
|
|
191
234
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
192
235
|
// a session is in this set - the agent can't call tools during summary
|
|
@@ -247,6 +290,15 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
247
290
|
// Keyed by child session ID. Cleaned up on child idle, error, or deletion.
|
|
248
291
|
const childMetrics = new Map<string, { new: number; updated: number; deleted: number }>();
|
|
249
292
|
|
|
293
|
+
// Wrap-up slash commands (/thatch/compact, /thatch/exit) awaiting their
|
|
294
|
+
// greenlight check. command.execute.before marks the session when one of
|
|
295
|
+
// these commands runs; when the session next goes idle, the plugin reads
|
|
296
|
+
// the final assistant message for the ready token (the greenlight) and
|
|
297
|
+
// triggers the TUI action. Token absence means the model listed blockers
|
|
298
|
+
// instead, so nothing fires beyond a toast. Cleared on resolution or on
|
|
299
|
+
// session deletion.
|
|
300
|
+
const pendingWrapUp = new Map<string, { token: string; kind: "compact" | "exit" }>();
|
|
301
|
+
|
|
250
302
|
// Skills always install to the global opencode config - installing into the
|
|
251
303
|
// worktree would mutate the user's repo (untracked files in git status).
|
|
252
304
|
// A failed install degrades the nudge workflow but must not kill the plugin.
|
|
@@ -259,7 +311,16 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
259
311
|
console.error(`[thatch] skill install failed: ${err}`);
|
|
260
312
|
}
|
|
261
313
|
|
|
262
|
-
|
|
314
|
+
// Same for the wrap-up slash commands: synced into the global config dir on
|
|
315
|
+
// every load so template updates self-heal. Config is loaded before plugins
|
|
316
|
+
// during server startup, so a first-ever install lands on the next start.
|
|
317
|
+
try {
|
|
318
|
+
installOpencodeCommands(configHome);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error(`[thatch] command install failed: ${err}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const sys = systemPrompt(repo, chatOn);
|
|
263
324
|
const compact = compactionContext(repo);
|
|
264
325
|
|
|
265
326
|
// Direct extraction: create a child session linked to the parent and prompt
|
|
@@ -348,6 +409,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
348
409
|
return extraction.buildPayload(all, repo);
|
|
349
410
|
},
|
|
350
411
|
watcherRegistry: watchers,
|
|
412
|
+
projectDir: worktree,
|
|
351
413
|
}),
|
|
352
414
|
|
|
353
415
|
// 1. System prompt - always in context.
|
|
@@ -515,6 +577,14 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
515
577
|
// Skipped during compaction: the agent can't call tools while generating
|
|
516
578
|
// a summary, so a nudge that says "use thatch_memory_recall" triggers a
|
|
517
579
|
// blocked-tool error.
|
|
580
|
+
// Wrap-up commands mark their session here; the actual trigger happens
|
|
581
|
+
// on the session's next idle event, once the model's greenlight response
|
|
582
|
+
// is complete.
|
|
583
|
+
"command.execute.before": async (input) => {
|
|
584
|
+
const wrapUp = WRAPUP_COMMANDS[input.command];
|
|
585
|
+
if (wrapUp) pendingWrapUp.set(input.sessionID, wrapUp);
|
|
586
|
+
},
|
|
587
|
+
|
|
518
588
|
"chat.message": async (input, output) => {
|
|
519
589
|
// Chat transcript echoes are non-synthetic noReply parts, so they
|
|
520
590
|
// arrive here like any user message - but no model turn ever reads
|
|
@@ -820,6 +890,101 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
820
890
|
}
|
|
821
891
|
return;
|
|
822
892
|
}
|
|
893
|
+
// Wrap-up command resolution: the session went idle right after
|
|
894
|
+
// /thatch/compact or /thatch/exit. The final assistant message's
|
|
895
|
+
// trailing token is the greenlight - the command instructs the model
|
|
896
|
+
// to end with it only after flushing persistence and confirming no
|
|
897
|
+
// loose ends. Token absence means the model listed blockers, so the
|
|
898
|
+
// action never fires; a toast points the user at the blockers above.
|
|
899
|
+
const wrapUp = sessionID ? pendingWrapUp.get(sessionID) : undefined;
|
|
900
|
+
if (wrapUp) {
|
|
901
|
+
pendingWrapUp.delete(sessionID);
|
|
902
|
+
let ready = false;
|
|
903
|
+
try {
|
|
904
|
+
const { data } = await client.session.messages({ path: { id: sessionID } });
|
|
905
|
+
const last = [...(data ?? [])].reverse().find((m) => m.info.role === "assistant");
|
|
906
|
+
const text = (last?.parts ?? [])
|
|
907
|
+
.filter((p) => p.type === "text")
|
|
908
|
+
.map((p) => p.text ?? "")
|
|
909
|
+
.join("\n")
|
|
910
|
+
.trimEnd();
|
|
911
|
+
ready = text.endsWith(wrapUp.token);
|
|
912
|
+
} catch (err) {
|
|
913
|
+
console.error(`[thatch] wrap-up message fetch failed: ${err}`);
|
|
914
|
+
}
|
|
915
|
+
if (ready) {
|
|
916
|
+
// An exit-greenlit session is leaving the process: leave the
|
|
917
|
+
// chat directory too, so other sessions stop addressing mail to
|
|
918
|
+
// a roster entry whose host is about to vanish. The unregister
|
|
919
|
+
// tombstone also stops any straggler auto-register from
|
|
920
|
+
// resurrecting the row during shutdown. Compact keeps the
|
|
921
|
+
// session alive, so only the exit path does this.
|
|
922
|
+
if (wrapUp.kind === "exit") {
|
|
923
|
+
try {
|
|
924
|
+
db.unregisterChatSession(sessionID);
|
|
925
|
+
} catch (err) {
|
|
926
|
+
console.error(`[thatch] chat unregister on exit failed: ${err}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
if (wrapUp.kind === "compact") {
|
|
931
|
+
// executeCommand only accepts legacy alias names;
|
|
932
|
+
// "session_compact" maps to the TUI's session.compact action,
|
|
933
|
+
// the same thing the built-in /compact command runs.
|
|
934
|
+
await client.tui.executeCommand({ body: { command: "session_compact" } });
|
|
935
|
+
} else {
|
|
936
|
+
// No exit alias exists, so publish the TUI keymap command
|
|
937
|
+
// directly - the same dispatch as the /exit slash command.
|
|
938
|
+
await client.tui.publish({
|
|
939
|
+
body: { type: "tui.command.execute", properties: { command: "app.exit" } },
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
} catch (err) {
|
|
943
|
+
console.error(`[thatch] wrap-up action failed: ${err}`);
|
|
944
|
+
}
|
|
945
|
+
// Skip extraction and pending deliveries: compaction is starting
|
|
946
|
+
// (the checklist drained the buffer) or the process is exiting.
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
await client.tui.showToast({
|
|
951
|
+
body: {
|
|
952
|
+
message:
|
|
953
|
+
wrapUp.kind === "compact"
|
|
954
|
+
? "\u23F8 Not compacting - resolve the items listed above, then run /thatch/compact again"
|
|
955
|
+
: "\u23F8 Not exiting - resolve the items listed above, then run /thatch/exit again",
|
|
956
|
+
variant: "warning",
|
|
957
|
+
duration: 8000,
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
} catch {
|
|
961
|
+
// TUI may not be connected (e.g. headless mode).
|
|
962
|
+
}
|
|
963
|
+
// Blocked: fall through so extraction and pending deliveries still
|
|
964
|
+
// run - the model may have buffered tool interactions to flush.
|
|
965
|
+
}
|
|
966
|
+
// Auto-registration: every top-level opencode session joins the
|
|
967
|
+
// chat directory on its first idle event (unless the user turned
|
|
968
|
+
// auto-registration off). The name is assigned, never chosen - a
|
|
969
|
+
// slug of the session title plus a never-reused counter - and the
|
|
970
|
+
// live title rides the topic column, refreshed on every idle so the
|
|
971
|
+
// roster tracks what the session is actually working on. Titles are
|
|
972
|
+
// usually placeholders at first idle and real within a turn or two,
|
|
973
|
+
// so both name and topic converge fast. Fire-and-forget: a failed
|
|
974
|
+
// registration or title fetch must never block event delivery.
|
|
975
|
+
if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
|
|
976
|
+
void (async () => {
|
|
977
|
+
try {
|
|
978
|
+
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
979
|
+
const title = data?.title ?? "";
|
|
980
|
+
const base = title && !isDefaultSessionTitle(title) ? slugifyTitle(title) : null;
|
|
981
|
+
db.registerChatSession(sessionID, repo, base ? title : null, "opencode", base, detectWorktreeKind(worktree));
|
|
982
|
+
if (base) db.refreshChatTopic(sessionID, title);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
|
|
985
|
+
}
|
|
986
|
+
})();
|
|
987
|
+
}
|
|
823
988
|
// Parent went idle - trigger direct extraction if there are pending
|
|
824
989
|
// tool interactions and no extraction is already running. Falls
|
|
825
990
|
// back to the nudge path on the next chat.message if this throws.
|
|
@@ -861,6 +1026,13 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
861
1026
|
// error, and a leaked status key would keep heartbeat-ing a dead
|
|
862
1027
|
// session as permanently fresh.
|
|
863
1028
|
sessionStatus.delete(id);
|
|
1029
|
+
// Tombstone the auto-registerer: an idle IIFE already in flight
|
|
1030
|
+
// (title fetch pending) would otherwise re-insert a directory row
|
|
1031
|
+
// for a session the user just deleted - a week-long roster zombie
|
|
1032
|
+
// whose host is gone. The unregister below tombstones (see
|
|
1033
|
+
// ChatStore.unregister), and the tombstone is cleared only by an
|
|
1034
|
+
// explicit chat_register rejoin, so a genuinely new session is
|
|
1035
|
+
// unaffected.
|
|
864
1036
|
// A child deleted before completing never processed its payload.
|
|
865
1037
|
const parentID = childToParent.get(id);
|
|
866
1038
|
if (parentID) {
|
|
@@ -871,6 +1043,7 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
871
1043
|
parentSnapshots.delete(id);
|
|
872
1044
|
childMetrics.delete(id);
|
|
873
1045
|
extractionChildren.delete(id);
|
|
1046
|
+
pendingWrapUp.delete(id);
|
|
874
1047
|
// A deleted parent takes its accepted entries with it, and its
|
|
875
1048
|
// watchers die with it - the session that would receive their
|
|
876
1049
|
// notifications no longer exists.
|