@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/src/tool-defs.ts CHANGED
@@ -3,20 +3,25 @@ import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
3
3
  import type { EmbeddingModel } from "./embeddings";
4
4
  import {
5
5
  CONFIG_SECTIONS,
6
+ chatEnabled,
7
+ chatPrefsSchema,
6
8
  loadConfig,
9
+ mergeChatPrefs,
7
10
  mergeNotificationPrefs,
8
11
  notificationDefaults,
9
12
  notificationPrefsSchema,
10
13
  saveConfig,
14
+ type ChatPrefs,
11
15
  type Config,
12
16
  type ConfigSection,
13
17
  type NotificationPrefs,
14
18
  } from "./config";
15
19
  import { sendNotification, defaultSpawner, type NotifyChannel, type Spawner } from "./notify";
16
- import { predictionVerb } from "./prompts";
20
+ import { predictionVerb, chatInboxFrame } from "./prompts";
17
21
  import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
18
- import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
19
- import { CHAT_STALE_MINUTES, isStale, renderChatParticipant, mcpSessionID, type ChatHostKind } from "./chat";
22
+ import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, commandTargetLabel, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
23
+ import { CHAT_STALE_MINUTES, isStale, renderChatParticipant, humanAge, type ChatHostKind } from "./chat";
24
+ import { detectWorktreeKind } from "./git";
20
25
 
21
26
  // Near-duplicate thresholds for matcher/prediction/behavior dedup at
22
27
  // creation time. Matches the thatch_find_duplicates threshold (0.85).
@@ -75,6 +80,9 @@ export interface CoreContext {
75
80
  * path - MCP hosts have no poller, no event bus, and no way to deliver a
76
81
  * proactive prompt, so watch tools are opencode-only. */
77
82
  watchers?: WatcherRegistry;
83
+ /** The plugin host's project directory. Command watchers run their
84
+ * commands here. Only wired on the opencode path, alongside watchers. */
85
+ projectDir?: string;
78
86
  /** Injectable command runner for notify_user. Defaults to Bun.spawn.
79
87
  * Tests inject a mock so notifications never actually fire or speak. */
80
88
  spawner?: Spawner;
@@ -891,6 +899,15 @@ function renderNotificationSection(prefs: NotificationPrefs | undefined): string
891
899
  return lines.join("\n");
892
900
  }
893
901
 
902
+ /** Renders the chat section. Both fields default to on. */
903
+ function renderChatSection(prefs: ChatPrefs | undefined): string {
904
+ return [
905
+ "chat:",
906
+ ` enabled: ${prefs?.enabled ?? "<unset>"} (default: on)`,
907
+ ` autoRegister: ${prefs?.autoRegister ?? "<unset>"} (default: on; opencode sessions join the chat directory automatically)`,
908
+ ].join("\n");
909
+ }
910
+
894
911
  /** Drops keys explicitly set to undefined so a merge never overwrites. */
895
912
  function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
896
913
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
@@ -904,7 +921,7 @@ const configGetDef: ToolDef = {
904
921
  "The config file (~/.config/thatch/config.json, beside thatch.db) is " +
905
922
  "also hand-editable. Call this before config_set.",
906
923
  args: {
907
- section: z.enum(["notifications"]).optional().describe(
924
+ section: z.enum(["notifications", "chat"]).optional().describe(
908
925
  "Restrict output to one section. Omit to read all sections.",
909
926
  ),
910
927
  },
@@ -919,6 +936,9 @@ const configGetDef: ToolDef = {
919
936
  if (name === "notifications") {
920
937
  lines.push(renderNotificationSection(loaded.config.notifications));
921
938
  }
939
+ if (name === "chat") {
940
+ lines.push(renderChatSection(loaded.config.chat));
941
+ }
922
942
  }
923
943
  lines.push("", `file: ${loaded.path}`);
924
944
  return lines.join("\n");
@@ -950,24 +970,50 @@ const configSetDef: ToolDef = {
950
970
  "Notification preferences to update. Fields you omit keep their " +
951
971
  "current values.",
952
972
  ),
973
+ chat: z.strictObject({
974
+ enabled: z.boolean().optional().describe(
975
+ "Cross-session chat on/off. Unset or true is the default; false " +
976
+ "makes every chat tool refuse and stops wake delivery.",
977
+ ),
978
+ autoRegister: z.boolean().optional().describe(
979
+ "Whether opencode sessions join the chat directory automatically " +
980
+ "(assigned names). Unset or true is the default; false restores " +
981
+ "opt-in joining via chat_register only.",
982
+ ),
983
+ }).optional().describe(
984
+ "Chat preferences to update. Fields you omit keep their current " +
985
+ "values.",
986
+ ),
953
987
  },
954
988
  async execute(args) {
955
989
  const loaded = loadConfig();
956
990
  const config: Config = { ...loaded.config };
957
- const patch = args.notifications as Record<string, unknown> | undefined;
958
- if (!patch || Object.keys(stripUndefined(patch)).length === 0) {
991
+ const notifPatch = args.notifications as Record<string, unknown> | undefined;
992
+ const chatPatch = args.chat as Record<string, unknown> | undefined;
993
+ const notifClean = notifPatch ? stripUndefined(notifPatch) : {};
994
+ const chatClean = chatPatch ? stripUndefined(chatPatch) : {};
995
+ if (Object.keys(notifClean).length === 0 && Object.keys(chatClean).length === 0) {
959
996
  return (
960
997
  "Nothing to update: pass a section with fields to change.\n" +
961
- renderNotificationSection(config.notifications)
998
+ renderNotificationSection(config.notifications) + "\n" +
999
+ renderChatSection(config.chat)
1000
+ );
1001
+ }
1002
+ if (Object.keys(notifClean).length > 0) {
1003
+ config.notifications = notificationPrefsSchema.parse(
1004
+ mergeNotificationPrefs(config.notifications, notifClean),
1005
+ );
1006
+ }
1007
+ if (Object.keys(chatClean).length > 0) {
1008
+ config.chat = chatPrefsSchema.parse(
1009
+ mergeChatPrefs(config.chat, chatClean),
962
1010
  );
963
1011
  }
964
- config.notifications = notificationPrefsSchema.parse(
965
- mergeNotificationPrefs(config.notifications, stripUndefined(patch)),
966
- );
967
1012
  const path = saveConfig(config);
968
1013
  return (
969
1014
  `[saved] ${path}\n` +
970
- renderNotificationSection(config.notifications)
1015
+ (Object.keys(notifClean).length > 0 ? renderNotificationSection(config.notifications) + "\n" : "") +
1016
+ (Object.keys(chatClean).length > 0 ? renderChatSection(config.chat) : "")
971
1017
  );
972
1018
  },
973
1019
  };
@@ -1166,14 +1212,17 @@ const watchCreateDef: ToolDef = {
1166
1212
  name: "watch_create",
1167
1213
  description:
1168
1214
  "Watch a GitHub PR for events and get notified in this session when they " +
1169
- "happen. Events: new top-level comments, new inline review comments, " +
1170
- "replies to review comments, review thread resolutions, new commits " +
1171
- "(head SHA change), PR status changes, title/description edits, and " +
1172
- "completed CI check runs. The watcher polls in the background and " +
1173
- "injects a notification prompt; notifications carry pointers only " +
1174
- "(author, URL) - fetch details yourself with the gh CLI when you decide " +
1175
- "to act. Watches live until cancelled, the session ends, or opencode " +
1176
- "restarts. Requires the gh CLI.",
1215
+ "happen: new top-level comments, inline review comments and replies, " +
1216
+ "review thread resolutions, new commits (head SHA change), PR status " +
1217
+ "changes, title/description edits, and completed CI check runs. For CI " +
1218
+ "waits: a short bounded wait (about 2-3 minutes) is fine as a single " +
1219
+ "in-turn gh poll; for longer or unknown waits, use this watcher instead " +
1220
+ "of sleep-polling. The watcher polls about every 60s by default - the " +
1221
+ "first check lands within ~60s of creation. Notifications carry a short " +
1222
+ "machine summary (event type, actor, and for CI the check name and " +
1223
+ "conclusion); use the gh CLI only for logs and further details. Watches " +
1224
+ "live until cancelled, the session ends, or opencode restarts. Requires " +
1225
+ "the gh CLI.",
1177
1226
  args: {
1178
1227
  pr: z.number().int().positive().describe(
1179
1228
  "The PR number to watch.",
@@ -1184,6 +1233,11 @@ const watchCreateDef: ToolDef = {
1184
1233
  events: z.array(z.enum(PR_EVENT_TYPES as [PrWatcherEventType, ...PrWatcherEventType[]])).optional().describe(
1185
1234
  "Which event types to watch. Omit for all PR event types.",
1186
1235
  ),
1236
+ once: z.boolean().optional().describe(
1237
+ "Fire only once: after the first matching event happens, the watcher " +
1238
+ "cancels itself. Use for one-shot waits like 'tell me when this run " +
1239
+ "finishes'. Default false - a standing watch.",
1240
+ ),
1187
1241
  },
1188
1242
  opencodeOnly: true,
1189
1243
  async execute(args, ctx, host) {
@@ -1195,18 +1249,21 @@ const watchCreateDef: ToolDef = {
1195
1249
  }
1196
1250
  const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
1197
1251
  const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
1198
- const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events);
1252
+ const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events, { once: args.once === true });
1199
1253
  if (!result.ok) return `Watcher not created: ${result.error}`;
1200
1254
  const w = result.watcher;
1201
1255
  return (
1202
1256
  `[watching] ${w.repo}#${w.pr}\n` +
1203
1257
  `id: ${w.id}\n` +
1204
1258
  `events: ${w.events.join(", ")}\n` +
1259
+ (w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
1205
1260
  `head: ${w.state.headSha.slice(0, 7)} (${w.state.state}${w.state.merged ? ", merged" : ""})\n\n` +
1206
- `The baseline is captured now - only changes from this point notify. ` +
1261
+ `The baseline is captured now - only changes from this point notify, ` +
1262
+ `and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
1207
1263
  `You will receive a system notification in this session when a watched event happens. ` +
1208
- `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1209
- `since the notification itself carries only pointers.`
1264
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report) - ` +
1265
+ `the notification carries a short machine summary (for CI: check name and conclusion), ` +
1266
+ `and you fetch details with the gh CLI only when you decide to act.`
1210
1267
  );
1211
1268
  },
1212
1269
  };
@@ -1234,8 +1291,11 @@ const watchListDef: ToolDef = {
1234
1291
  const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
1235
1292
  return watchers
1236
1293
  .map((w) => {
1294
+ if (w.source === "command") {
1295
+ return `${w.id}: cmd: ${commandTargetLabel(w.command)} [${w.source}]${w.once ? " [once]" : ""} events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m lastExit=${w.state.lastExit}`;
1296
+ }
1237
1297
  const target = w.source === "pr" ? `${w.repo}#${w.pr}` : `${w.repo}@${w.branch}`;
1238
- return `${w.id}: ${target} [${w.source}] events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`;
1298
+ return `${w.id}: ${target} [${w.source}]${w.once ? " [once]" : ""} events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`;
1239
1299
  })
1240
1300
  .join("\n");
1241
1301
  },
@@ -1254,10 +1314,15 @@ const watchBranchCreateDef: ToolDef = {
1254
1314
  "when things happen on it: branch_commit when new commits land (e.g. a " +
1255
1315
  "PR merged), branch_ci when a check run on the head completes, and " +
1256
1316
  "branch_workflow when a GitHub Actions workflow run starts or finishes " +
1257
- "on the branch. Optionally filter to workflow names (substring match). " +
1258
- "The watcher polls in the background and injects a notification prompt; " +
1259
- "notifications carry pointers only - fetch details with the gh CLI when " +
1260
- "you decide to act. Watches live until cancelled, the session ends, or " +
1317
+ "on the branch. This is how you watch CI against main or wait for a " +
1318
+ "post-merge build. For CI waits: a short bounded wait (about 2-3 " +
1319
+ "minutes) is fine as a single in-turn gh poll; for longer or unknown " +
1320
+ "waits, use this watcher instead of sleep-polling. Optionally filter to " +
1321
+ "workflow names (substring match). The watcher polls about every 60s by " +
1322
+ "default - the first check lands within ~60s of creation. Notifications " +
1323
+ "carry a short machine summary (event type, and for CI and workflow " +
1324
+ "runs the name and conclusion); use the gh CLI only for logs and " +
1325
+ "further details. Watches live until cancelled, the session ends, or " +
1261
1326
  "opencode restarts. Requires the gh CLI.",
1262
1327
  args: {
1263
1328
  branch: z.string().describe(
@@ -1272,6 +1337,11 @@ const watchBranchCreateDef: ToolDef = {
1272
1337
  workflows: z.array(z.string()).optional().describe(
1273
1338
  "Only notify for workflow runs whose name contains one of these substrings (case-insensitive). Omit for all workflows.",
1274
1339
  ),
1340
+ once: z.boolean().optional().describe(
1341
+ "Fire only once: after the first matching event happens, the watcher " +
1342
+ "cancels itself. Use for one-shot waits like 'tell me when this run " +
1343
+ "finishes'. Default false - a standing watch.",
1344
+ ),
1275
1345
  },
1276
1346
  opencodeOnly: true,
1277
1347
  async execute(args, ctx, host) {
@@ -1289,6 +1359,7 @@ const watchBranchCreateDef: ToolDef = {
1289
1359
  args.branch as string,
1290
1360
  events,
1291
1361
  (args.workflows as string[] | undefined) ?? [],
1362
+ { once: args.once === true },
1292
1363
  );
1293
1364
  if (!result.ok) return `Watcher not created: ${result.error}`;
1294
1365
  const w = result.watcher;
@@ -1297,11 +1368,84 @@ const watchBranchCreateDef: ToolDef = {
1297
1368
  `id: ${w.id}\n` +
1298
1369
  `events: ${w.events.join(", ")}\n` +
1299
1370
  (w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
1371
+ (w.once ? `mode: one-shot - auto-cancels after the first event\n` : "") +
1300
1372
  `head: ${w.state.headSha.slice(0, 7)}\n\n` +
1301
- `The baseline is captured now - only changes from this point notify. ` +
1373
+ `The baseline is captured now - only changes from this point notify, ` +
1374
+ `and the first poll lands within ~${ctx.watchers.pollSeconds}s. ` +
1302
1375
  `You will receive a system notification in this session when a watched event happens. ` +
1303
- `State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
1304
- `since the notification itself carries only pointers.`
1376
+ `State any handling policy for those notifications now (e.g. what to act on, what just to report) - ` +
1377
+ `the notification carries a short machine summary (for CI: check name and conclusion), ` +
1378
+ `and you fetch details with the gh CLI only when you decide to act.`
1379
+ );
1380
+ },
1381
+ };
1382
+
1383
+ /**
1384
+ * Registers a background watcher on a local shell command. The command is a
1385
+ * CONDITION VARIABLE, not a data pipe: the poller re-runs it every cycle and
1386
+ * reads only its exit code - stdout and stderr are discarded, never delivered.
1387
+ * The session gets one synthetic notification the first time the command
1388
+ * exits 0, and the watcher cancels itself. This is the generic "wait for an
1389
+ * arbitrary condition" escape hatch: wait for a marker file, an endpoint to
1390
+ * come up, a lock to clear. opencode-only, like the other watch tools.
1391
+ * Requires bash, not the gh CLI.
1392
+ */
1393
+ const watchCommandCreateDef: ToolDef = {
1394
+ name: "watch_command_create",
1395
+ description:
1396
+ "Register a local shell command as a background condition and get " +
1397
+ "notified in this session when it first exits 0. THE COMMAND IS A " +
1398
+ "CONDITION VARIABLE, NOT A DATA PIPE: the watcher only reads its exit " +
1399
+ "code - stdout and stderr are discarded, never returned to you. Write " +
1400
+ "the command as a fast, idempotent status check (exit 0 = the wait is " +
1401
+ "over) such as 'test -f marker', 'curl -sf endpoint >/dev/null', or a " +
1402
+ "'gh run list --json' query - NOT a command whose output you want, and " +
1403
+ "NOT a blocking wait (no 'gh run watch', 'tail -f', or 'sleep': the " +
1404
+ "per-run timeout kills a blocking command each cycle, wasting up to " +
1405
+ "30s of the shared poll cycle on a check that can never succeed). " +
1406
+ "Use this for long or unknown-duration waits on any local condition " +
1407
+ "instead of sleep-polling. The command runs in this project directory " +
1408
+ "via bash -c about every 60s, killed at a 30s per-run timeout " +
1409
+ "(THATCH_WATCH_COMMAND_TIMEOUT_SECONDS overrides; a timed-out run " +
1410
+ "means not-done-yet). One-shot by design: it fires once on the first " +
1411
+ "exit 0, auto-cancels, and the notification carries only the exit code " +
1412
+ "and duration - re-run the command or read logs yourself if you need " +
1413
+ "details. The command runs once at registration as validation: if it " +
1414
+ "already exits 0 the watcher is refused (the condition is already met - " +
1415
+ "proceed instead), and a command-not-found (exit 127) is refused too. " +
1416
+ "Counts against the per-session watcher limit; expires with the " +
1417
+ "session's other watchers.",
1418
+ args: {
1419
+ command: z.string().min(1).describe(
1420
+ "The shell command to run as a condition: a fast, idempotent status " +
1421
+ "check that exits 0 when the wait is over. Its output is discarded.",
1422
+ ),
1423
+ },
1424
+ opencodeOnly: true,
1425
+ async execute(args, ctx, host) {
1426
+ if (!host) {
1427
+ return "Watching is unavailable: this host did not provide a session context.";
1428
+ }
1429
+ if (!ctx.watchers) {
1430
+ return "Watching is unavailable: no watcher registry was wired by this host.";
1431
+ }
1432
+ if (!ctx.projectDir) {
1433
+ return "Watching is unavailable: no project directory was wired by this host.";
1434
+ }
1435
+ const command = args.command as string;
1436
+ const result = await ctx.watchers.createCommand(host.sessionID, command, ctx.projectDir);
1437
+ if (!result.ok) return `Watcher not created: ${result.error}`;
1438
+ const w = result.watcher;
1439
+ return (
1440
+ `[watching] cmd: ${commandTargetLabel(w.command)}\n` +
1441
+ `id: ${w.id}\n` +
1442
+ `mode: one-shot - fires on the first exit 0, then auto-cancels\n` +
1443
+ `lastExit: ${w.state.lastExit} (baseline exit code; 124 = the baseline run was killed at the timeout)\n\n` +
1444
+ `The command re-runs about every ${ctx.watchers.pollSeconds}s and the first check already happened at registration - ` +
1445
+ `the next run lands within ~${ctx.watchers.pollSeconds}s. ` +
1446
+ `You will receive a system notification when the command exits 0; it carries the exit code and duration only. ` +
1447
+ `State any handling policy for that notification now (e.g. what to do once the condition is met) - ` +
1448
+ `the notification is the signal, not the data: re-run the command or read logs yourself when it arrives.`
1305
1449
  );
1306
1450
  },
1307
1451
  };
@@ -1314,7 +1458,7 @@ const watchCancelDef: ToolDef = {
1314
1458
  name: "watch_cancel",
1315
1459
  description: "Cancel one of this session's watchers by id. opencode-only.",
1316
1460
  args: {
1317
- id: z.string().describe("The watcher id from watch_create, watch_branch_create, or watch_list."),
1461
+ id: z.string().describe("The watcher id from watch_create, watch_branch_create, watch_command_create, or watch_list."),
1318
1462
  },
1319
1463
  opencodeOnly: true,
1320
1464
  async execute(args, ctx, host) {
@@ -1351,6 +1495,18 @@ const watchCancelDef: ToolDef = {
1351
1495
  * the same change as chatEchoText and its tests.
1352
1496
  */
1353
1497
 
1498
+ /**
1499
+ * Refusal text when the user disabled cross-session chat by config. Every
1500
+ * chat tool returns this so the model can report the toggle to the user
1501
+ * instead of concluding the feature is broken.
1502
+ */
1503
+ function chatDisabledReply(): string {
1504
+ return (
1505
+ "[disabled] Cross-session chat is off (chat.enabled: false in the thatch config). " +
1506
+ "Use thatch config_set { chat: { enabled: true } } to re-enable if the user asks."
1507
+ );
1508
+ }
1509
+
1354
1510
  /**
1355
1511
  * Resolves the chat identity for a tool call. opencode supplies the host
1356
1512
  * session; MCP hosts (host === undefined) declare theirs with the `as`
@@ -1362,6 +1518,9 @@ async function resolveChatIdentity(
1362
1518
  host: HostToolContext | undefined,
1363
1519
  as: unknown,
1364
1520
  ): Promise<{ ok: true; sessionID: string; kind: ChatHostKind } | { ok: false; error: string }> {
1521
+ if (!chatEnabled(loadConfig().config)) {
1522
+ return { ok: false, error: chatDisabledReply() };
1523
+ }
1365
1524
  if (host) return { ok: true, sessionID: host.sessionID, kind: "opencode" };
1366
1525
  if (typeof as !== "string" || !as.trim()) {
1367
1526
  return {
@@ -1381,98 +1540,119 @@ async function resolveChatIdentity(
1381
1540
  }
1382
1541
 
1383
1542
  /** The zod arg shared by chat tools whose MCP mode needs the caller's
1384
- * registered name: MCP hosts have no session context, so identity is
1385
- * self-declared (the documented trust posture). opencode ignores it. */
1543
+ * assigned name: MCP hosts have no session context, so identity is passed
1544
+ * as the name the host's thatch hook printed (names are assigned by
1545
+ * thatch, never claimed). opencode ignores it. */
1386
1546
  function mcpIdentityArg() {
1387
1547
  return z.string().optional().describe(
1388
- "Your registered display name. Only needed on hosts without session " +
1548
+ "Your assigned display name - the one your host's thatch hook printed, " +
1549
+ "or that chat_register returned. Only needed on hosts without session " +
1389
1550
  "context (Claude Code, Cursor); opencode supplies your identity itself.",
1390
1551
  );
1391
1552
  }
1392
1553
 
1393
- /** The degraded-delivery notice appended to MCP-path registration output:
1394
- * wake prompts are an opencode capability, so other hosts poll. */
1554
+ /**
1555
+ * The delivery-model notes appended to registration output, one per host
1556
+ * kind. Without these a session has no way to learn how mail reaches it,
1557
+ * and defaults to sleep-polling its inbox. The wake prompt is an opencode
1558
+ * capability; other hosts learn about pending mail at their next prompt.
1559
+ */
1560
+ function wakeDeliveryNote(): string {
1561
+ return "\nDelivery: you are woken automatically in this session when a message arrives - no need to poll (a busy session gets the wake when its turn ends).\n";
1562
+ }
1563
+
1395
1564
  function degradedDeliveryNote(): string {
1396
- return "\nNote: wake-up delivery is opencode-only; on this host your inbox is checked at prompt time (chat_status, or the flush-tools hook line).\n";
1565
+ return "\nDelivery: wake-up is opencode-only; on this host pending mail is reported at your next prompt (chat_read, chat_status, or the flush-tools hook line).\n";
1397
1566
  }
1398
1567
 
1399
1568
  /**
1400
- * Joins the chat directory. Without a name, draws one at random from the
1401
- * built-in name pool (whimsical, geek-flavored, guaranteed unused) - the
1402
- * recommended path, since it cannot collide. With a name, claims it
1403
- * case-insensitively; registration is the gate for both directions.
1569
+ * Joins the chat directory (or reports/renews an existing registration).
1570
+ * Names are assigned by thatch, never claimed: there is no name or topic
1571
+ * argument, and registration is the gate for both directions. MCP hosts
1572
+ * without a hook-printed name get a fresh per-conversation identity.
1404
1573
  */
1405
1574
  const chatRegisterDef: ToolDef = {
1406
1575
  name: "chat_register",
1407
1576
  description:
1408
1577
  "Join the cross-session chat directory so other sessions on this " +
1409
1578
  "machine can message you and you can message them (works from opencode, " +
1410
- "Claude Code, and Cursor). Omit the name to be assigned one from the " +
1411
- "built-in pool (recommended - cannot collide); pass a name to claim it " +
1412
- "instead, case-insensitively unique. Include a topic: it is what other " +
1413
- "sessions see in chat_list when deciding who to talk to. Pass an empty " +
1414
- "topic to clear it; omit it to keep the current one. Safe to call " +
1415
- "again - the same name is a no-op, a new name renames you, and a new " +
1416
- "topic updates it. Only top-level sessions should register; never " +
1417
- "register a sub-agent session.",
1579
+ "Claude Code, and Cursor). Display names are assigned by thatch, never " +
1580
+ "chosen: a slug plus a never-reused counter (the slug comes from your " +
1581
+ "session title when one exists, otherwise from a built-in pool), so a " +
1582
+ "name always refers to the same session and cannot be claimed by anyone " +
1583
+ "else. Call with no arguments to join, to rejoin after chat_unregister, " +
1584
+ "or to check your assigned name (opencode sessions are usually " +
1585
+ "registered automatically already). On hosts without session context, " +
1586
+ "pass `as` with the name your thatch hook printed for you, or omit it " +
1587
+ "to be assigned a fresh identity for this conversation. Delivery: " +
1588
+ "opencode sessions are woken automatically when mail arrives; on other " +
1589
+ "hosts pending mail is reported at your next prompt. Only top-level " +
1590
+ "sessions should register; never register a sub-agent session.",
1418
1591
  args: {
1419
- name: z.string().optional().describe(
1420
- "Custom display name. Omit to draw one from the built-in name pool.",
1421
- ),
1422
- topic: z.string().optional().describe(
1423
- "One line about what this session is working on (shown to other " +
1424
- "sessions in chat_list). Omit on re-register to keep the existing " +
1425
- "topic; pass an empty string to clear it.",
1426
- ),
1427
1592
  as: mcpIdentityArg(),
1428
1593
  },
1429
1594
  async execute(args, ctx, host) {
1430
- const custom = typeof args.name === "string" ? args.name : null;
1431
- const topic = typeof args.topic === "string" ? args.topic : null;
1595
+ if (!chatEnabled(loadConfig().config)) return chatDisabledReply();
1432
1596
  const kind: ChatHostKind = host ? "opencode" : "mcp";
1433
- // MCP mode (host absent): the claimed name is the identity, stored
1434
- // under a deterministic synthetic session ID so a later conversation
1435
- // claiming the same name finds the same row.
1436
- const sessionID = host ? host.sessionID : mcpSessionID(custom!.trim());
1437
- if (custom !== null) {
1438
- const result = ctx.db.registerChatSession(sessionID, custom, ctx.defaultStore, topic, kind);
1439
- if (!result.ok) return `Registration failed: ${result.error}`;
1597
+ // MCP reclaim: `as` must name an EXISTING registration - the identity
1598
+ // printed by the host's thatch hook for this conversation. Names are
1599
+ // assigned, so there is no claim path; a stale name from an old
1600
+ // conversation still resolves to the same row it always did.
1601
+ const asName = host ? null : typeof args.as === "string" ? args.as.trim() : null;
1602
+ if (!host && !asName) {
1603
+ // Fresh identity for this conversation: without session context or a
1604
+ // hook-printed name there is nothing stable to anchor to, and a new
1605
+ // conversation is a new peer anyway. Persistent identity on MCP hosts
1606
+ // comes from the hook, which re-registers the same session ID and
1607
+ // prints the assigned name each prompt.
1608
+ const sessionID = `mcp_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`;
1609
+ const res = ctx.db.registerChatSession(sessionID, ctx.defaultStore, null, kind, null, detectWorktreeKind(ctx.projectDir));
1610
+ if (!res.ok) return `Registration failed: ${res.error}`;
1440
1611
  return (
1441
- `[registered] ${custom.trim()}\n` +
1612
+ `[registered] ${res.name}\n` +
1442
1613
  `session_id: ${sessionID}\n` +
1443
1614
  `project: ${ctx.defaultStore}\n` +
1444
- // The store returns the sanitized topic it kept, so the
1445
- // confirmation shows the roster value, not the raw input.
1446
- (result.topic ? `topic: ${result.topic}\n` : "") +
1447
- (host ? "" : degradedDeliveryNote()) +
1615
+ degradedDeliveryNote() +
1448
1616
  `\n` +
1449
- `Other sessions can now message you by name with chat_send; use ` +
1450
- `chat_list to see who else is available.`
1617
+ `This identity is fresh for this conversation. If your host runs the ` +
1618
+ `thatch hook, its output names your persistent identity - use that ` +
1619
+ `as the 'as' argument on other chat tools. Use chat_list to see who ` +
1620
+ `is available.`
1451
1621
  );
1452
1622
  }
1453
- // Pool draw: MCP mode requires an explicit name anyway (the identity
1454
- // must be known to the caller), so a host-less draw is routed through
1455
- // the same assign path with the name the caller declared.
1456
- const mcpAs = host ? null : typeof args.as === "string" ? args.as.trim() : null;
1457
- if (!host && !mcpAs) {
1458
- return "Chat needs an identity: pass `name` (or `as`) with your display name, since this host has no session context to derive one from.";
1623
+ if (!host) {
1624
+ const row = ctx.db.findChatSession(asName!);
1625
+ if (!row) {
1626
+ return `No registered session named "${asName}" - names are assigned by thatch (your host hook prints yours); you cannot claim a new one.`;
1627
+ }
1628
+ return (
1629
+ `[registered] ${row.name}\n` +
1630
+ `session_id: ${row.session_id}\n` +
1631
+ `project: ${row.project ?? ctx.defaultStore}\n` +
1632
+ degradedDeliveryNote() +
1633
+ `\n` +
1634
+ `You were already registered. Other sessions can message you by name ` +
1635
+ `with chat_send, or reach everyone at once with chat_broadcast; use ` +
1636
+ `chat_list to see who else is available.`
1637
+ );
1459
1638
  }
1460
- const drawSessionID = host ? host.sessionID : mcpSessionID(mcpAs!.trim());
1461
- const assigned = ctx.db.assignChatName(drawSessionID, ctx.defaultStore, topic, kind);
1462
- if (!assigned.ok) return `Registration failed: ${assigned.error}`;
1463
- const origin = assigned.drawn
1464
- ? "Your name was drawn from the built-in pool."
1465
- : "You were already registered - keeping your current name.";
1639
+ // An explicit join clears the leave tombstone BEFORE registering:
1640
+ // "I want back in" is the opposite of "I left", and the tombstone must
1641
+ // not block the rejoin it is supposed to yield to. (register() treats
1642
+ // a tombstoned opencode session as blocked; the tombstone is for the
1643
+ // idle path, not for the model's explicit intent.)
1644
+ ctx.db.clearChatLeaveTombstone?.(host.sessionID);
1645
+ const res = ctx.db.registerChatSession(host.sessionID, ctx.defaultStore, null, kind, null, detectWorktreeKind(ctx.projectDir));
1646
+ if (!res.ok) return `Registration failed: ${res.error}`;
1466
1647
  return (
1467
- `[registered] ${assigned.name}\n` +
1468
- `session_id: ${drawSessionID}\n` +
1648
+ `[registered] ${res.name}\n` +
1649
+ `session_id: ${host.sessionID}\n` +
1469
1650
  `project: ${ctx.defaultStore}\n` +
1470
- (assigned.topic ? `topic: ${assigned.topic}\n` : "") +
1471
- (host ? "" : degradedDeliveryNote()) +
1651
+ wakeDeliveryNote() +
1472
1652
  `\n` +
1473
- `${origin} Other sessions can now message you by name with chat_send, ` +
1474
- `or reach everyone at once with chat_broadcast; use chat_list to see ` +
1475
- `who else is available.`
1653
+ `Other sessions can now message you by name with chat_send, or reach ` +
1654
+ `everyone at once with chat_broadcast; use chat_list to see who else ` +
1655
+ `is available.`
1476
1656
  );
1477
1657
  },
1478
1658
  };
@@ -1493,7 +1673,7 @@ const chatListDef: ToolDef = {
1493
1673
  if (!identity.ok) return identity.error;
1494
1674
  const sessions = ctx.db.listChatSessions();
1495
1675
  if (sessions.length === 0) {
1496
- return "No sessions registered. chat_register opts in; you would be the first.";
1676
+ return "No sessions registered. Sessions join the directory automatically on their first idle (opencode), or via chat_register - you would be the first.";
1497
1677
  }
1498
1678
  const unread = ctx.db.unreadChatCount(identity.sessionID);
1499
1679
  const lines = sessions.map((s) => {
@@ -1502,11 +1682,26 @@ const chatListDef: ToolDef = {
1502
1682
  ? isStale(s, CHAT_STALE_MINUTES) ? "idle" : "active"
1503
1683
  : isStale(s, CHAT_STALE_MINUTES) ? "stale" : "fresh";
1504
1684
  const project = s.project ? ` project:${s.project}` : "";
1685
+ // The age turns the liveness bucket into a concrete last-check-in;
1686
+ // the checkout kind tells sessions on a shared tree who sits in a
1687
+ // worktree versus the project root.
1688
+ const age = ` last seen ${humanAge(s.last_seen)}`;
1689
+ const loc = s.worktree ? ` loc:${s.worktree}` : "";
1505
1690
  const topic = s.topic ? ` topic:${s.topic}` : "";
1506
1691
  const mailbox = self ? (unread > 0 ? ` ${unread} unread` : "") : "";
1507
- return `- ${s.name} (${s.session_id.slice(0, 12)})${project} ${liveness}${topic}${self ? " [you]" : ""}${mailbox}`;
1692
+ return `- ${s.name} (${s.session_id.slice(0, 12)})${project}${age}${loc} ${liveness}${topic}${self ? " [you]" : ""}${mailbox}`;
1508
1693
  });
1509
- return `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n${lines.join("\n")}`;
1694
+ // The name-stability note closes the roster because it answers the
1695
+ // question a reader asking "is this the same X as before?" needs:
1696
+ // assigned names are unique forever; only hook-anchored MCP identities
1697
+ // can be revisited by an old name.
1698
+ return (
1699
+ `[chat] ${sessions.length} session${sessions.length === 1 ? "" : "s"} registered\n` +
1700
+ `${lines.join("\n")}\n` +
1701
+ `Names are assigned by thatch and never reused or reassigned, so the ` +
1702
+ `same name is always the same session. A name whose row shows stale or ` +
1703
+ `idle has not been seen recently.`
1704
+ );
1510
1705
  },
1511
1706
  };
1512
1707
 
@@ -1571,7 +1766,11 @@ const chatReadDef: ToolDef = {
1571
1766
  const sender = renderChatParticipant(m.from_name, m.from_session);
1572
1767
  return `[from ${sender}, ${formatChatTimestamp(m.created_at)}] ${m.body}`;
1573
1768
  });
1574
- return `${lines.join("\n")}\n(${messages.length} message${messages.length === 1 ? "" : "s"}, marked read)`;
1769
+ // The frame is the injection boundary: bodies are other agents' text,
1770
+ // so the tool output marks everything between the fences as untrusted
1771
+ // data (chatInboxFrame in prompts.ts). The persisted rows are
1772
+ // untouched - the frame exists only in this output.
1773
+ return `${chatInboxFrame(lines, messages.length)}\n(${messages.length} message${messages.length === 1 ? "" : "s"}, marked read)`;
1575
1774
  },
1576
1775
  };
1577
1776
 
@@ -1663,7 +1862,10 @@ const chatStatusDef: ToolDef = {
1663
1862
  }
1664
1863
  return (
1665
1864
  `[chat] registered as ${status.name}: ${status.pending} unread of ${status.total} total` +
1666
- (status.pending > 0 ? " - call chat_read to read them." : ".")
1865
+ (status.pending > 0 ? " - call chat_read to read them." : ".") +
1866
+ (host
1867
+ ? " You are woken automatically when a message arrives - no need to poll."
1868
+ : " Pending mail is reported at your next prompt; chat_read drains it.")
1667
1869
  );
1668
1870
  },
1669
1871
  };
@@ -1701,6 +1903,7 @@ export const TOOL_DEFS: ToolDef[] = [
1701
1903
  sessionGetDef,
1702
1904
  watchCreateDef,
1703
1905
  watchBranchCreateDef,
1906
+ watchCommandCreateDef,
1704
1907
  watchListDef,
1705
1908
  watchCancelDef,
1706
1909
  chatRegisterDef,
package/src/tools.ts CHANGED
@@ -18,6 +18,7 @@ export function createTools(
18
18
  extractionPayloadProvider?: CoreContext["extractionPayloadProvider"];
19
19
  drainExtractionQueue?: CoreContext["drainExtractionQueue"];
20
20
  watcherRegistry?: WatcherRegistry;
21
+ projectDir?: string;
21
22
  },
22
23
  ): Record<string, ReturnType<typeof tool>> {
23
24
  const ctx: CoreContext = {
@@ -27,6 +28,7 @@ export function createTools(
27
28
  extractionPayloadProvider: extensions?.extractionPayloadProvider,
28
29
  drainExtractionQueue: extensions?.drainExtractionQueue,
29
30
  watchers: extensions?.watcherRegistry,
31
+ projectDir: extensions?.projectDir,
30
32
  };
31
33
 
32
34
  const tools: Record<string, ReturnType<typeof tool>> = {};