@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/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
- 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:`;
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
- $ARGUMENTS
38
+ ${userMessageSection}
39
+
40
+ # Pre-compact wrap-up
35
41
 
36
- Pre-compaction wrap-up. Work through this checklist before responding:
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
- $ARGUMENTS
55
+ ${userMessageSection}
56
+
57
+ # Pre-exit wrap-up
48
58
 
49
- Pre-exit wrap-up. Work through this checklist before responding:
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, isDefaultSessionTitle, slugifyTitle } from "./chat";
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 = async (sessionID: string): Promise<boolean> => {
123
- if (compacting.has(sessionID)) return false;
124
- if (sessionStatus.get(sessionID) !== "idle") return false;
125
- try {
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
- // 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).
199
+ return data ?? {};
200
+ },
201
+ onStatusError: (sessionID, err) => {
134
202
  console.error(`[thatch] status check failed for ${sessionID}: ${err}`);
135
- return false;
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
- // Sub-agent children share this process's sessionStatus (their
201
- // status events land here), but they are never legitimate chat
202
- // participants - a registered child would be heartbeat-ed
203
- // fresh-forever and burn nudge budget on undeliverable wake
204
- // prompts. Keep them out of the hosted set even when guidance
205
- // against registering them is ignored.
206
- const hosted = [...sessionStatus.keys()].filter((id) => !childToParent.has(id));
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
- // 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.
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
- 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);
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 slug of your
24
- session title plus a counter, e.g. fix-auth-bug-00001) and is never
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