@jeffober/thatch 0.1.43 → 0.1.44
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 +4 -2
- package/bin/thatch +270 -93
- package/docs/dev/README.md +8 -1
- package/docs/dev/features/cli.md +15 -17
- package/docs/dev/features/cross-session-chat.md +111 -42
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +12 -0
- package/docs/dev/mcp-parity.md +1 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +6 -4
- package/docs/user/cli.md +35 -43
- package/docs/user/cross-session-chat.md +89 -21
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +344 -185
- package/src/commands.ts +16 -6
- package/src/db.ts +37 -0
- package/src/debug.ts +44 -0
- package/src/index.ts +153 -39
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +2 -2
- package/src/setup.ts +203 -33
- package/src/tool-defs.ts +98 -24
- package/src/watchers.ts +51 -29
package/src/commands.ts
CHANGED
|
@@ -24,19 +24,27 @@ export const EXIT_READY_TOKEN = "THATCH_EXIT_READY";
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
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
|
|
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
28
|
|
|
29
|
-
|
|
29
|
+
const userMessageSection = `# User Message
|
|
30
|
+
|
|
31
|
+
$ARGUMENTS
|
|
32
|
+
|
|
33
|
+
(That section carries the text typed after the command. When it is empty, treat the user message as n/a - the command was run bare.)`;
|
|
30
34
|
|
|
31
35
|
const COMPACT_TEMPLATE = `---
|
|
32
36
|
description: Flush thatch persistence, check for loose ends, then compact if clear
|
|
33
37
|
---
|
|
34
|
-
$
|
|
38
|
+
${userMessageSection}
|
|
39
|
+
|
|
40
|
+
# Pre-compact wrap-up
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
Work through this checklist before responding:
|
|
37
43
|
|
|
38
44
|
${sharedChecklist}
|
|
39
45
|
|
|
46
|
+
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:
|
|
47
|
+
|
|
40
48
|
${COMPACT_READY_TOKEN}
|
|
41
49
|
|
|
42
50
|
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.`;
|
|
@@ -44,9 +52,11 @@ If anything is outstanding, list the items concisely so the user can address the
|
|
|
44
52
|
const EXIT_TEMPLATE = `---
|
|
45
53
|
description: Flush thatch persistence, check for loose ends, then exit opencode if clear
|
|
46
54
|
---
|
|
47
|
-
$
|
|
55
|
+
${userMessageSection}
|
|
56
|
+
|
|
57
|
+
# Pre-exit wrap-up
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
Work through this checklist before responding:
|
|
50
60
|
|
|
51
61
|
${sharedChecklist}
|
|
52
62
|
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.
|
package/src/db.ts
CHANGED
|
@@ -284,6 +284,20 @@ export class ThatchDB {
|
|
|
284
284
|
)
|
|
285
285
|
`);
|
|
286
286
|
|
|
287
|
+
// Hook-parent-process -> session mapping (Claude Code identity anchor):
|
|
288
|
+
// hooks learn the true session id from the host's hook payload and run
|
|
289
|
+
// as children of the same process that spawned the MCP server, so the
|
|
290
|
+
// server resolves its own parent pid against this table - an identity
|
|
291
|
+
// the model cannot claim. Rows are timestamped; stale rows are ignored
|
|
292
|
+
// (pid reuse) rather than deleted.
|
|
293
|
+
this.#db.run(`
|
|
294
|
+
CREATE TABLE IF NOT EXISTS chat_host_pids (
|
|
295
|
+
ppid INTEGER PRIMARY KEY,
|
|
296
|
+
session_id TEXT NOT NULL,
|
|
297
|
+
seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
298
|
+
)
|
|
299
|
+
`);
|
|
300
|
+
|
|
287
301
|
this.#migrateColumns();
|
|
288
302
|
this.#migrateChatNameCollation();
|
|
289
303
|
this.#migrateChatTopic();
|
|
@@ -291,6 +305,7 @@ export class ThatchDB {
|
|
|
291
305
|
this.#migrateChatBroadcastFlag();
|
|
292
306
|
this.#migrateChatAuto();
|
|
293
307
|
this.#migrateChatWorktree();
|
|
308
|
+
this.#migrateChatHostPid();
|
|
294
309
|
}
|
|
295
310
|
|
|
296
311
|
// chat_sessions tables created before the auto column lack it; the ALTER
|
|
@@ -322,6 +337,16 @@ export class ThatchDB {
|
|
|
322
337
|
}
|
|
323
338
|
}
|
|
324
339
|
|
|
340
|
+
// The released schema has no host_pid column (liveness is heartbeat age;
|
|
341
|
+
// see chatLiveness). A development database may carry one; drop it so
|
|
342
|
+
// every database matches a fresh install.
|
|
343
|
+
#migrateChatHostPid(): void {
|
|
344
|
+
const cols = (this.#db.query("PRAGMA table_info(chat_sessions)").all() as any[]).map((r) => r.name);
|
|
345
|
+
if (cols.includes("host_pid")) {
|
|
346
|
+
this.#db.run("ALTER TABLE chat_sessions DROP COLUMN host_pid");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
325
350
|
// chat_messages tables created before the via_broadcast column lack it;
|
|
326
351
|
// the ALTER adds it, and existing rows read as direct sends (which they
|
|
327
352
|
// were).
|
|
@@ -935,6 +960,18 @@ export class ThatchDB {
|
|
|
935
960
|
return this.#chat.unreadCount(sessionID);
|
|
936
961
|
}
|
|
937
962
|
|
|
963
|
+
recordChatHostPid(ppid: number, sessionID: string) {
|
|
964
|
+
return this.#chat.recordHostPid(ppid, sessionID);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
findChatSessionByHostPid(ppid: number, maxAgeSeconds: number): string | null {
|
|
968
|
+
return this.#chat.findSessionByHostPid(ppid, maxAgeSeconds);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
continueChatSession(oldSessionID: string, newSessionID: string) {
|
|
972
|
+
return this.#chat.continueSession(oldSessionID, newSessionID);
|
|
973
|
+
}
|
|
974
|
+
|
|
938
975
|
heartbeatChatSessions(sessionIDs: string[]) {
|
|
939
976
|
return this.#chat.heartbeat(sessionIDs);
|
|
940
977
|
}
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Opt-in diagnostic logging for questions a live harness cannot otherwise
|
|
5
|
+
// answer ("what did the plugin actually see at init?"). Lines go to
|
|
6
|
+
// debug.log beside the database, never to the host's stdout or stderr:
|
|
7
|
+
// the plugin runs inside opencode's TUI process, where console output is
|
|
8
|
+
// invisible or garbles the screen, and test sandboxes point the database at
|
|
9
|
+
// a tmp dir so their debug output never reaches the quality gate.
|
|
10
|
+
//
|
|
11
|
+
// Off unless THATCH_DEBUG is set. THATCH_DEBUG=1 (or "all" / "*") logs every
|
|
12
|
+
// tag. Otherwise it is a comma-separated list of tag filters: a filter
|
|
13
|
+
// matches a tag when it equals the whole tag ("chat:startup") or the
|
|
14
|
+
// feature part before the colon ("chat" matches every chat:* tag).
|
|
15
|
+
//
|
|
16
|
+
// Tags are `feature:aspect`, rendered in square brackets, so a grep on
|
|
17
|
+
// "[chat:" pulls one feature's story out of a shared file.
|
|
18
|
+
|
|
19
|
+
export type DebugLog = (tag: string, msg: string) => void;
|
|
20
|
+
|
|
21
|
+
// Decide, from a THATCH_DEBUG value, whether a tag is enabled. Exported for
|
|
22
|
+
// tests; callers use createDebugLog.
|
|
23
|
+
export function debugEnabled(spec: string | undefined, tag: string): boolean {
|
|
24
|
+
if (!spec) return false;
|
|
25
|
+
const s = spec.trim();
|
|
26
|
+
if (s === "1" || s === "all" || s === "*") return true;
|
|
27
|
+
const feature = tag.split(":")[0];
|
|
28
|
+
return s.split(",").map((f) => f.trim()).filter(Boolean).some((f) => f === tag || f === feature);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build the logger for a database location. Every line: ISO timestamp,
|
|
32
|
+
// bracketed tag, message. Writes never throw: diagnostics must not break
|
|
33
|
+
// the host, so a missing directory or a full disk simply drops the line.
|
|
34
|
+
export function createDebugLog(dbPath: string, spec: string | undefined = process.env.THATCH_DEBUG): DebugLog {
|
|
35
|
+
const file = join(dirname(dbPath), "debug.log");
|
|
36
|
+
return (tag, msg) => {
|
|
37
|
+
if (!debugEnabled(spec, tag)) return;
|
|
38
|
+
try {
|
|
39
|
+
appendFileSync(file, `${new Date().toISOString()} [${tag}] ${msg}\n`);
|
|
40
|
+
} catch {
|
|
41
|
+
// Intentionally silent; see above.
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createDebugLog } from "./debug";
|
|
2
4
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
3
5
|
import { ThatchDB } from "./db";
|
|
4
6
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
@@ -24,7 +26,7 @@ import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
|
24
26
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
25
27
|
import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
|
|
26
28
|
import { watcherNotificationNudge, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
27
|
-
import { ChatPoller,
|
|
29
|
+
import { ChatPoller, createWakeGate, isDefaultSessionTitle } from "./chat";
|
|
28
30
|
import { chatEnabled, chatAutoRegister, loadConfig } from "./config";
|
|
29
31
|
import pkg from "../package.json";
|
|
30
32
|
|
|
@@ -62,6 +64,73 @@ const WRAPUP_COMMANDS: Record<string, { token: string; kind: "compact" | "exit"
|
|
|
62
64
|
"thatch/exit": { token: EXIT_READY_TOKEN, kind: "exit" },
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Parses the session id a harness was started to continue from an argument
|
|
69
|
+
* list (`opencode -s <id>` / `--session <id>` / `--session=<id>`). Pure:
|
|
70
|
+
* the caller supplies the list (see startupSessionId for where it comes
|
|
71
|
+
* from). The framework passes no session id to plugins and resuming a
|
|
72
|
+
* session fires no events, so the command line is the only signal that a
|
|
73
|
+
* fresh harness is the new home of a continued session. `-c/--continue`
|
|
74
|
+
* (continue last) resolves no id here - that session registers on its
|
|
75
|
+
* first idle like any other.
|
|
76
|
+
*/
|
|
77
|
+
export function startupSessionIdFromArgv(argv: string[]): string | null {
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const arg = argv[i];
|
|
80
|
+
if (arg === "-s" || arg === "--session") return argv[i + 1] ?? null;
|
|
81
|
+
if (arg.startsWith("--session=")) return arg.slice("--session=".length) || null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The plugin does not run in the process's main thread: opencode's TUI
|
|
87
|
+
// spawns a worker thread (cli/tui/worker.js) to host the server, and worker
|
|
88
|
+
// threads get their own argv (just the worker script) with none of the CLI
|
|
89
|
+
// flags. The thread still shares the process's pid, so the OS-level command
|
|
90
|
+
// line for our own pid is the real `opencode -s ses_...` invocation. Read it
|
|
91
|
+
// from /proc on Linux or via ps on macOS. Returns [] when unavailable;
|
|
92
|
+
// callers treat that as "no session flag". The readers are injectable so
|
|
93
|
+
// tests can drive each branch without a real /proc or ps.
|
|
94
|
+
export interface OsArgsDeps {
|
|
95
|
+
/** Reads a file as UTF-8, throwing when it does not exist. */
|
|
96
|
+
readFile: (path: string) => string;
|
|
97
|
+
/** Runs `ps -p <pid> -o args=`; returns its exit code and stdout. */
|
|
98
|
+
ps: (pid: number) => { exitCode: number; stdout: string };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const defaultOsArgsDeps: OsArgsDeps = {
|
|
102
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
103
|
+
ps: (pid) => {
|
|
104
|
+
const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "args="]);
|
|
105
|
+
return { exitCode: res.exitCode, stdout: res.stdout.toString() };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function osProcessArgs(deps: OsArgsDeps = defaultOsArgsDeps, pid = process.pid): string[] {
|
|
110
|
+
try {
|
|
111
|
+
// Linux: cmdline is NUL-separated argv, exact and cheap.
|
|
112
|
+
return deps.readFile("/proc/self/cmdline").split("\0").filter(Boolean);
|
|
113
|
+
} catch {
|
|
114
|
+
// macOS has no /proc; ask ps for this pid's full command. Splitting on
|
|
115
|
+
// whitespace is safe for our purpose: session ids never contain spaces.
|
|
116
|
+
try {
|
|
117
|
+
const res = deps.ps(pid);
|
|
118
|
+
if (res.exitCode !== 0) return [];
|
|
119
|
+
return res.stdout.trim().split(/\s+/).filter(Boolean);
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Resolve the session id this harness was launched to resume, if any:
|
|
127
|
+
// prefer the thread-local argv (covers tests and any non-worker host), then
|
|
128
|
+
// fall back to the process's OS-level command line (the real harness path).
|
|
129
|
+
// The caller passes the OS command line in so it is read once per init.
|
|
130
|
+
export function startupSessionId(osArgs: string[]): string | null {
|
|
131
|
+
return startupSessionIdFromArgv(process.argv) ?? startupSessionIdFromArgv(osArgs);
|
|
132
|
+
}
|
|
133
|
+
|
|
65
134
|
export const server: Plugin = async ({ client, worktree }) => {
|
|
66
135
|
// The opencode server's cwd is wherever the server happened to start;
|
|
67
136
|
// `worktree` is the project this plugin instance actually serves.
|
|
@@ -78,6 +147,9 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
78
147
|
const chatOn = chatEnabled(loadConfig(dbPath).config);
|
|
79
148
|
|
|
80
149
|
const db = new ThatchDB(dbPath);
|
|
150
|
+
// Opt-in diagnostics (THATCH_DEBUG), written to debug.log beside the db.
|
|
151
|
+
// See debug.ts for the tag filter semantics.
|
|
152
|
+
const debug = createDebugLog(dbPath);
|
|
81
153
|
const model = new BgeEmbeddingModel(modelName);
|
|
82
154
|
const extraction = new ExtractionPipeline();
|
|
83
155
|
|
|
@@ -119,22 +191,17 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
119
191
|
// event stream, and a stale "idle" turns a wake into mid-turn context
|
|
120
192
|
// injection (observed live: a session read mail mid-burst, contradicted
|
|
121
193
|
// its own in-flight plan, and looped).
|
|
122
|
-
const canPromptSession =
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
194
|
+
const canPromptSession = createWakeGate({
|
|
195
|
+
isCompacting: (sessionID) => compacting.has(sessionID),
|
|
196
|
+
mappedStatus: (sessionID) => sessionStatus.get(sessionID),
|
|
197
|
+
fetchStatuses: async () => {
|
|
126
198
|
const { data } = await client.session.status();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const live = data?.[sessionID];
|
|
131
|
-
return !live || live.type === "idle";
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// Unreachable server: do not deliver (the mail stays pending).
|
|
199
|
+
return data ?? {};
|
|
200
|
+
},
|
|
201
|
+
onStatusError: (sessionID, err) => {
|
|
134
202
|
console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
};
|
|
203
|
+
},
|
|
204
|
+
});
|
|
138
205
|
|
|
139
206
|
// In-memory watcher registry for proactive event notifications (GitHub PR
|
|
140
207
|
// and branch watching, plus local command watches). Deliberately
|
|
@@ -187,24 +254,17 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
187
254
|
});
|
|
188
255
|
watchers.start();
|
|
189
256
|
|
|
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).
|
|
197
257
|
const chatPoller = new ChatPoller({
|
|
198
258
|
store: db,
|
|
199
259
|
hostedSessions: () => {
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// fresh-forever and burn
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return hosted;
|
|
260
|
+
// Hosting = the sessions this harness serves: those seen as events
|
|
261
|
+
// here (used in this TUI) plus the `-s` startup session. Sub-agent
|
|
262
|
+
// children are excluded even if someone registered one - a
|
|
263
|
+
// registered child would be heartbeat-ed fresh-forever and burn
|
|
264
|
+
// nudge budget on undeliverable wake prompts.
|
|
265
|
+
const hosted = [...sessionStatus.keys()];
|
|
266
|
+
if (startupSession) hosted.push(startupSession);
|
|
267
|
+
return hosted.filter((id) => !childToParent.has(id));
|
|
208
268
|
},
|
|
209
269
|
deliver: async (sessionID, senders, count) => {
|
|
210
270
|
await client.session.promptAsync({
|
|
@@ -231,6 +291,46 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
231
291
|
// sessions and burn wake budget against a feature the user turned off.
|
|
232
292
|
if (chatOn) chatPoller.start();
|
|
233
293
|
|
|
294
|
+
// Startup registration: `opencode -s <id>` continues a session whose
|
|
295
|
+
// directory row may predate this process (its harness died, or the
|
|
296
|
+
// machine restarted). The framework passes no session id to plugins and
|
|
297
|
+
// resuming fires no events, so the command line is the source (see
|
|
298
|
+
// startupSessionId). Registering here RECLAIMS the row - the name is
|
|
299
|
+
// owned by the session id and never changes - and refreshes its
|
|
300
|
+
// heartbeat synchronously: local SQLite, it cannot fail on a server that
|
|
301
|
+
// is still starting. The async tail (title fetch, asleep-mail delivery)
|
|
302
|
+
// is best-effort - the poller retries whatever it misses, and the first
|
|
303
|
+
// idle converges the topic.
|
|
304
|
+
const osArgs = osProcessArgs();
|
|
305
|
+
const startupSession = startupSessionId(osArgs);
|
|
306
|
+
const autoRegisterOn = chatAutoRegister(loadConfig(dbPath).config);
|
|
307
|
+
const tombstoned = startupSession ? db.hasChatLeaveTombstone(startupSession) : false;
|
|
308
|
+
debug("chat:startup", `init: argv=${JSON.stringify(process.argv.slice(0, 8))} osArgs=${JSON.stringify(osArgs.slice(0, 8))} parsed=${startupSession} chatOn=${chatOn} autoRegister=${autoRegisterOn} tombstone=${startupSession ? tombstoned : "n/a"}`);
|
|
309
|
+
if (chatOn && autoRegisterOn && startupSession && !tombstoned) {
|
|
310
|
+
const res = db.registerChatSession(startupSession, repo, null, "opencode", null, detectWorktreeKind(worktree));
|
|
311
|
+
debug("chat:startup", `registration for ${startupSession}: ok=${res.ok} name=${res.ok ? res.name : res.error}`);
|
|
312
|
+
if (res.ok) {
|
|
313
|
+
void (async () => {
|
|
314
|
+
try {
|
|
315
|
+
const { data } = await client.session.get({ path: { id: startupSession } });
|
|
316
|
+
const title = data?.title ?? "";
|
|
317
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
318
|
+
if (topic) db.refreshChatTopic(startupSession, topic);
|
|
319
|
+
} catch {
|
|
320
|
+
// Server may still be starting; the topic converges on the
|
|
321
|
+
// session's first idle either way.
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
// Asleep-mail: deliver what queued while this session was away,
|
|
325
|
+
// through the normal path (gate, nudge, toast, stamps).
|
|
326
|
+
await chatPoller.deliverPending();
|
|
327
|
+
} catch {
|
|
328
|
+
// The poller cycle retries delivery; nothing to do here.
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
234
334
|
// Sessions currently being compacted. chat.message nudges are skipped while
|
|
235
335
|
// a session is in this set - the agent can't call tools during summary
|
|
236
336
|
// generation, so a recall or extraction nudge would cause a blocked-tool
|
|
@@ -966,20 +1066,34 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
966
1066
|
// Auto-registration: every top-level opencode session joins the
|
|
967
1067
|
// chat directory on its first idle event (unless the user turned
|
|
968
1068
|
// auto-registration off). The name is assigned, never chosen - a
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
1069
|
+
// pool draw plus a never-reused counter, kept short because it
|
|
1070
|
+
// appears in wake prompts and rosters. The live session title rides
|
|
1071
|
+
// the topic column instead (refreshed on every idle), so the name
|
|
1072
|
+
// and the description stay separate identities. Fire-and-forget: a
|
|
1073
|
+
// failed registration or title fetch must never block event
|
|
1074
|
+
// delivery.
|
|
975
1075
|
if (chatOn && chatAutoRegister(loadConfig(dbPath).config) && sessionID && !db.hasChatLeaveTombstone(sessionID)) {
|
|
976
1076
|
void (async () => {
|
|
977
1077
|
try {
|
|
978
1078
|
const { data } = await client.session.get({ path: { id: sessionID } });
|
|
979
1079
|
const title = data?.title ?? "";
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1080
|
+
// Placeholder titles never become topics (the real one
|
|
1081
|
+
// converges on a later idle via refreshChatTopic).
|
|
1082
|
+
const topic = title && !isDefaultSessionTitle(title) ? title : null;
|
|
1083
|
+
const res = db.registerChatSession(sessionID, repo, topic, "opencode", null, detectWorktreeKind(worktree));
|
|
1084
|
+
// The topic converges as the auto-titler lands a real title:
|
|
1085
|
+
// registration is once-per-session, so later idles must
|
|
1086
|
+
// refresh it explicitly.
|
|
1087
|
+
if (res.ok && topic) db.refreshChatTopic(sessionID, topic);
|
|
1088
|
+
if (res.ok && res.created) {
|
|
1089
|
+
// First registration is the one moment the user should
|
|
1090
|
+
// notice: a quiet toast, not a conversation message.
|
|
1091
|
+
try {
|
|
1092
|
+
await client.tui.showToast({ body: { message: `registered in chat as ${res.name}`, variant: "info", duration: 4000 } });
|
|
1093
|
+
} catch {
|
|
1094
|
+
// Headless or disconnected TUI - registration stands.
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
983
1097
|
} catch (err) {
|
|
984
1098
|
console.error(`[thatch] chat auto-register failed for ${sessionID}: ${err}`);
|
|
985
1099
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ThatchDB } from "./db";
|
|
4
|
+
import { mcpSessionID } from "./chat";
|
|
4
5
|
import { BgeEmbeddingModel } from "./embeddings";
|
|
5
6
|
import { detectRepo } from "./git";
|
|
6
7
|
import { checkSetup, setupClaudeCode, setupCursor } from "./setup";
|
|
@@ -134,6 +135,21 @@ export async function runMcpServer(): Promise<void> {
|
|
|
134
135
|
defaultStore: repo,
|
|
135
136
|
extractionPayloadProvider,
|
|
136
137
|
drainExtractionQueue,
|
|
138
|
+
// Claude Code identity anchor, two model-proof layers:
|
|
139
|
+
// 1. The hooks record (parent pid -> session) from payloads the model
|
|
140
|
+
// cannot influence, and this server is a child of the same Claude
|
|
141
|
+
// Code process - current even after a session fork.
|
|
142
|
+
// 2. CLAUDE_CODE_SESSION_ID, set by the host in this server's env at
|
|
143
|
+
// spawn ("retains the ID it was spawned with") - covers the case
|
|
144
|
+
// where hook and server parent pids diverge, but goes stale after a
|
|
145
|
+
// fork/continue, so it is the fallback, not the primary.
|
|
146
|
+
// A miss on both falls back to the caller-claimed `as` (Cursor).
|
|
147
|
+
chatDerivedIdentity: () => {
|
|
148
|
+
const mapped = db.findChatSessionByHostPid(process.ppid, 600);
|
|
149
|
+
if (mapped) return mapped;
|
|
150
|
+
const envID = process.env.CLAUDE_CODE_SESSION_ID;
|
|
151
|
+
return envID ? db.findChatSession(mcpSessionID(envID))?.session_id ?? null : null;
|
|
152
|
+
},
|
|
137
153
|
};
|
|
138
154
|
const tools = compileTools();
|
|
139
155
|
|
package/src/prompts.ts
CHANGED
|
@@ -20,8 +20,8 @@ const chatPromptSection = `
|
|
|
20
20
|
|
|
21
21
|
Sessions on this machine - opencode, Claude Code, and Cursor alike - can
|
|
22
22
|
message each other through thatch. opencode sessions join the directory
|
|
23
|
-
automatically: your display name is assigned by thatch (a
|
|
24
|
-
|
|
23
|
+
automatically: your display name is assigned by thatch (a name drawn from
|
|
24
|
+
a built-in pool plus a counter, e.g. al-go-rithm-00001) and is never
|
|
25
25
|
reassigned or reused, so a given name is always the same session.
|
|
26
26
|
thatch_chat_register with no arguments reports your name or rejoins after
|
|
27
27
|
unregistering. On MCP hosts, pass the name your thatch hook printed as the
|