switchroom 0.5.0 → 0.7.9

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.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +510 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +564 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -139,6 +139,38 @@ export interface UpdatePlaceholderMessage {
139
139
  text: string;
140
140
  }
141
141
 
142
+ /**
143
+ * Phase 2 cron-fold-in: a privileged client (the in-agent scheduler
144
+ * sibling, supervised by start.sh under SWITCHROOM_INLINE_SCHEDULER=1)
145
+ * sends this to the gateway to inject a synthesized turn into the
146
+ * agent's bridge. The gateway forwards the embedded `inbound` envelope
147
+ * verbatim via `ipcServer.sendToAgent(agentName, inbound)`.
148
+ *
149
+ * Why a separate envelope rather than a direct inbound on the wire:
150
+ * 1. ClientToGateway and GatewayToClient are distinct directions.
151
+ * A client cannot send a `type: "inbound"` message — that's a
152
+ * gateway→client envelope. The bridge's validateGatewayMessage
153
+ * is its security boundary, and the gateway's validateClientMessage
154
+ * is the parallel boundary on this side. Wrapping in
155
+ * `inject_inbound` keeps both validators sharp on their own
156
+ * direction.
157
+ * 2. The gateway is *deciding* to forward — a future scope check
158
+ * (e.g., reject inbounds whose `meta.source` is not in a known
159
+ * set, rate-limit per sender) lives naturally at the gateway.
160
+ *
161
+ * Trust model: the gateway socket lives at a per-agent path inside
162
+ * the agent container; only processes inside that container can
163
+ * connect. `inject_inbound` is therefore as trusted as any other
164
+ * process running under that agent's UID.
165
+ */
166
+ export interface InjectInboundMessage {
167
+ type: "inject_inbound";
168
+ /** Target agent name — the gateway routes via sendToAgent. */
169
+ agentName: string;
170
+ /** Forwarded verbatim to the bridge as a `type: "inbound"` envelope. */
171
+ inbound: InboundMessage;
172
+ }
173
+
142
174
  export type ClientToGateway =
143
175
  | RegisterMessage
144
176
  | ToolCallMessage
@@ -148,4 +180,5 @@ export type ClientToGateway =
148
180
  | ScheduleRestartMessage
149
181
  | OperatorEventForward
150
182
  | PtyPartialForward
151
- | UpdatePlaceholderMessage;
183
+ | UpdatePlaceholderMessage
184
+ | InjectInboundMessage;
@@ -3,6 +3,7 @@ import type {
3
3
  ClientToGateway,
4
4
  GatewayToClient,
5
5
  HeartbeatMessage,
6
+ InjectInboundMessage,
6
7
  OperatorEventForward,
7
8
  PermissionRequestForward,
8
9
  PtyPartialForward,
@@ -30,6 +31,15 @@ export interface IpcServerOptions {
30
31
  * messages will be silently dropped at dispatch.
31
32
  */
32
33
  onPtyPartial?: (client: IpcClient, msg: PtyPartialForward) => void;
34
+ /**
35
+ * Phase 2 cron-fold-in: invoked when a privileged in-container client
36
+ * (the agent-scheduler sibling) asks the gateway to forward a
37
+ * synthesized InboundMessage to a registered bridge. The handler is
38
+ * expected to call `ipcServer.sendToAgent(msg.agentName, msg.inbound)`
39
+ * (or its own equivalent). Optional: gateways that don't run the
40
+ * inline scheduler simply ignore inject_inbound messages.
41
+ */
42
+ onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
33
43
  log?: (msg: string) => void;
34
44
  /**
35
45
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -161,6 +171,27 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
161
171
  // ipc-protocol.ts for context.
162
172
  return typeof m.chatId === "string" && (m.chatId as string).length > 0
163
173
  && typeof m.text === "string" && (m.text as string).length <= 8192;
174
+ case "inject_inbound": {
175
+ // Phase 2 cron-fold-in. The wrapped `inbound` is forwarded
176
+ // verbatim to the bridge as a `type: "inbound"` envelope, so
177
+ // we validate the same fields the bridge's
178
+ // validateGatewayMessage cares about (`chatId`, `text`) plus
179
+ // the basic structural shape every InboundMessage carries.
180
+ if (typeof m.agentName !== "string"
181
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
182
+ if (typeof m.inbound !== "object" || m.inbound === null) return false;
183
+ const inb = m.inbound as Record<string, unknown>;
184
+ return inb.type === "inbound"
185
+ && typeof inb.chatId === "string"
186
+ && (inb.chatId as string).length > 0
187
+ && typeof inb.text === "string"
188
+ && typeof inb.messageId === "number"
189
+ && typeof inb.user === "string"
190
+ && typeof inb.userId === "number"
191
+ && typeof inb.ts === "number"
192
+ && typeof inb.meta === "object"
193
+ && inb.meta !== null;
194
+ }
164
195
  default:
165
196
  return false;
166
197
  }
@@ -178,6 +209,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
178
209
  onScheduleRestart,
179
210
  onOperatorEvent,
180
211
  onPtyPartial,
212
+ onInjectInbound,
181
213
  log = () => {},
182
214
  heartbeatTimeoutMs = 30_000,
183
215
  } = options;
@@ -263,6 +295,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
263
295
  case "pty_partial":
264
296
  if (onPtyPartial) onPtyPartial(client, msg as PtyPartialForward);
265
297
  break;
298
+ case "inject_inbound":
299
+ if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
300
+ break;
266
301
  case "update_placeholder":
267
302
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
268
303
  // Soft-accepted so recall.py keeps working without modifying
@@ -27,6 +27,29 @@
27
27
  * Releases happen on shutdown (SIGTERM/SIGINT/uncaught error) by
28
28
  * unlinking the canonical path. We log every state transition; do NOT
29
29
  * silently swallow filesystem errors.
30
+ *
31
+ * Container/PID-namespace correctness (#884):
32
+ * -------------------------------------------
33
+ * Under v0.7 docker each agent runs in its own PID namespace. The
34
+ * gateway PID written to disk inside the previous container instance
35
+ * is meaningless in the new container — PID 10 in container A and
36
+ * PID 10 in container B are unrelated processes. `process.kill(pid, 0)`
37
+ * happily reports "alive" because the PID number is reused by an
38
+ * unrelated current-container process (tini's child, autoaccept-poll,
39
+ * etc.), and the new gateway aborts with `another_gateway_is_live`.
40
+ *
41
+ * Fix: stamp every record with a `bootId` derived from PID 1's
42
+ * `starttime` (clock ticks since system boot, field 22 in /proc/1/stat).
43
+ * Inside a container, PID 1 is tini and its starttime is the container's
44
+ * start instant — survives PID recycling within the namespace, but
45
+ * differs from any other container's PID 1 starttime. On bare metal
46
+ * PID 1 is systemd/init; the field still uniquely identifies the host
47
+ * boot. The PID-liveness check is now gated on bootId match: same boot
48
+ * → trust kill(pid,0); different boot → record is stale regardless.
49
+ *
50
+ * Records written by older versions have no `bootId`. We treat those as
51
+ * "unknown boot" and fall back to the legacy kill-based check — same
52
+ * behavior as before this fix, so the upgrade path is one-way safe.
30
53
  */
31
54
  import {
32
55
  link as linkAsync,
@@ -34,10 +57,48 @@ import {
34
57
  writeFile as writeFileAsync,
35
58
  readFile as readFileAsync,
36
59
  } from "node:fs/promises";
60
+ import { readFileSync } from "node:fs";
37
61
 
38
62
  export interface MutexRecord {
39
63
  pid: number;
40
64
  startedAtMs: number;
65
+ /**
66
+ * Identifier of the OS/container boot during which this record was
67
+ * written. See "Container/PID-namespace correctness" in the file
68
+ * header. Optional for backwards compatibility with records written
69
+ * by pre-#884 gateway versions.
70
+ */
71
+ bootId?: string;
72
+ }
73
+
74
+ /**
75
+ * Read PID 1's start-time-in-clock-ticks from /proc/1/stat (field 22).
76
+ *
77
+ * Inside a docker container the PID-1 starttime is tied to the
78
+ * container instance and survives PID recycling but differs across
79
+ * container recreations. On bare metal it identifies the host boot.
80
+ * Returns `null` outside Linux or when /proc/1/stat is unreadable —
81
+ * callers fall back to legacy PID-only checks in that case.
82
+ *
83
+ * The 22nd field (`starttime`) appears AFTER the `comm` field which
84
+ * is wrapped in parentheses and may contain spaces/parens itself, so
85
+ * we slice past the LAST `)` before splitting on whitespace.
86
+ */
87
+ export function readCurrentBootId(): string | null {
88
+ try {
89
+ const stat = readFileSync("/proc/1/stat", "utf-8");
90
+ const lastParen = stat.lastIndexOf(")");
91
+ if (lastParen < 0) return null;
92
+ const tail = stat.slice(lastParen + 1).trim();
93
+ const fields = tail.split(/\s+/);
94
+ // Field index in the post-comm tail: original fields 3..N → tail[0..]
95
+ // starttime is original field 22, so tail index 22 - 3 = 19.
96
+ const starttime = fields[19];
97
+ if (!starttime || !/^\d+$/.test(starttime)) return null;
98
+ return `pid1:${starttime}`;
99
+ } catch {
100
+ return null;
101
+ }
41
102
  }
42
103
 
43
104
  export type AcquireOutcome =
@@ -63,6 +124,14 @@ export interface AcquireOptions {
63
124
  * Injectable so tests can simulate dead/alive PIDs without forking.
64
125
  */
65
126
  isPidAlive?: (pid: number) => boolean;
127
+ /**
128
+ * Override for "what boot are we in right now". Defaults to
129
+ * `readCurrentBootId()`. Injectable so tests can simulate
130
+ * container-restart scenarios without recreating containers.
131
+ * `null` disables the bootId gate (treats all records as
132
+ * same-boot — the legacy pre-#884 behavior).
133
+ */
134
+ currentBootId?: string | null;
66
135
  /**
67
136
  * Logger. Defaults to process.stderr.write. Lines are pre-formatted
68
137
  * with the `telegram gateway:` prefix to match journalctl style.
@@ -114,7 +183,11 @@ async function tryReadRecord(path: string): Promise<MutexRecord | null> {
114
183
  Number.isFinite(parsed.pid) &&
115
184
  Number.isFinite(parsed.startedAtMs)
116
185
  ) {
117
- return { pid: parsed.pid, startedAtMs: parsed.startedAtMs };
186
+ const out: MutexRecord = { pid: parsed.pid, startedAtMs: parsed.startedAtMs };
187
+ if (typeof parsed.bootId === "string" && parsed.bootId.length > 0) {
188
+ out.bootId = parsed.bootId;
189
+ }
190
+ return out;
118
191
  }
119
192
  return null;
120
193
  } catch {
@@ -139,8 +212,18 @@ export async function acquireStartupLock(
139
212
  const { path, record, agentName } = opts;
140
213
  const agentTag = fmtAgent(agentName);
141
214
 
215
+ // Resolve the current bootId. `undefined` in opts means "use the
216
+ // process default"; an explicit `null` opts out (legacy behavior).
217
+ const currentBootId =
218
+ opts.currentBootId === undefined ? readCurrentBootId() : opts.currentBootId;
219
+
220
+ // Stamp our own record with the bootId so future boots know whether
221
+ // we belong to the same container/host as them. Don't mutate the
222
+ // caller's record object.
223
+ const recordToWrite: MutexRecord =
224
+ currentBootId != null ? { ...record, bootId: currentBootId } : { ...record };
142
225
  const tmp = tmpPath(path, record.pid);
143
- const payload = JSON.stringify(record);
226
+ const payload = JSON.stringify(recordToWrite);
144
227
 
145
228
  // Write the tmp file first. If this throws, the canonical isn't
146
229
  // touched — caller can retry on a fresh boot.
@@ -187,6 +270,31 @@ export async function acquireStartupLock(
187
270
  continue;
188
271
  }
189
272
 
273
+ // Boot/PID-namespace gate (#884). If the holder record carries a
274
+ // bootId AND it doesn't match ours, the holder PID is from a
275
+ // different container/host boot and `kill(pid, 0)` against it is
276
+ // meaningless — same PID number could be a live unrelated process
277
+ // in our namespace. Skip the kill check, treat as stale, recover.
278
+ // If either side has no bootId we fall back to the legacy PID
279
+ // check (preserves pre-#884 behavior for non-Linux dev/test runs
280
+ // and for upgrades from records that pre-date the bootId field).
281
+ const bootMismatch =
282
+ currentBootId != null && holder.bootId != null && holder.bootId !== currentBootId;
283
+
284
+ if (bootMismatch) {
285
+ log(
286
+ `telegram gateway: boot.lock_stale_recovered_boot_mismatch prior_pid=${holder.pid} prior_started_at=${new Date(
287
+ holder.startedAtMs,
288
+ ).toISOString()} prior_boot=${holder.bootId} current_boot=${currentBootId}${agentTag}`,
289
+ );
290
+ await unlinkAsync(path).catch((unlinkErr: unknown) => {
291
+ const code = (unlinkErr as NodeJS.ErrnoException).code;
292
+ if (code !== "ENOENT") throw unlinkErr;
293
+ });
294
+ recoveredFrom = holder;
295
+ continue;
296
+ }
297
+
190
298
  if (isAlive(holder.pid)) {
191
299
  // Live holder. Drop tmp and report blocked.
192
300
  await unlinkAsync(tmp).catch(() => {});
@@ -19,6 +19,15 @@
19
19
  "timeout": 10
20
20
  }
21
21
  ]
22
+ },
23
+ {
24
+ "hooks": [
25
+ {
26
+ "type": "command",
27
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
28
+ "timeout": 5
29
+ }
30
+ ]
22
31
  }
23
32
  ],
24
33
  "PostToolUse": [
@@ -52,6 +61,16 @@
52
61
  "timeout": 5
53
62
  }
54
63
  ]
64
+ },
65
+ {
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
70
+ "timeout": 5,
71
+ "async": true
72
+ }
73
+ ]
55
74
  }
56
75
  ]
57
76
  }
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — emits a deterministic human label per tool call.
4
+ *
5
+ * Claude Code PreToolUse protocol (v1):
6
+ * Input: JSON on stdin — { session_id, tool_name, tool_input, tool_use_id, cwd, ... }
7
+ * Output: exit 0 + empty stdout → allow. We NEVER emit JSON to stdout
8
+ * (would risk hookSpecificOutput.updatedInput collisions). We
9
+ * NEVER exit non-zero (exit 2 BLOCKS the tool call).
10
+ *
11
+ * Side effect: appends one JSON line to
12
+ * $TELEGRAM_STATE_DIR/tool-labels-${session_id}.jsonl
13
+ * with shape { ts, tool_use_id, agent_id, label, tool_name }.
14
+ *
15
+ * If $TELEGRAM_STATE_DIR is unset → silent skip (renderer just falls back
16
+ * to its existing precedence ladder). If session_id or tool_use_id is
17
+ * missing → skip (the row could never be joined anyway). If the rule
18
+ * table doesn't produce a label for the tool → skip.
19
+ *
20
+ * Tools intentionally NOT labeled here (handled by existing description
21
+ * / TodoWrite / sub-agent panels in the renderer):
22
+ * Bash, Task, Agent, TodoWrite
23
+ *
24
+ * Issue #783.
25
+ */
26
+
27
+ import { readFileSync, mkdirSync, appendFileSync, existsSync } from 'node:fs'
28
+ import { join, basename } from 'node:path'
29
+
30
+ function readStdin() {
31
+ try {
32
+ return readFileSync(0, 'utf8')
33
+ } catch {
34
+ return ''
35
+ }
36
+ }
37
+
38
+ /**
39
+ * One-line, length-bounded escape of a value for inclusion in a label.
40
+ * Newlines collapsed, very long strings truncated with an ellipsis.
41
+ */
42
+ function clip(s, max = 80) {
43
+ if (s == null) return ''
44
+ let v = String(s).replace(/\s+/g, ' ').trim()
45
+ if (v.length > max) v = v.slice(0, max - 1) + '…'
46
+ return v
47
+ }
48
+
49
+ function safeBasename(p) {
50
+ if (!p || typeof p !== 'string') return ''
51
+ try {
52
+ const b = basename(p)
53
+ return b || p
54
+ } catch {
55
+ return p
56
+ }
57
+ }
58
+
59
+ function urlHostPath(u) {
60
+ if (!u || typeof u !== 'string') return ''
61
+ try {
62
+ const x = new URL(u)
63
+ return x.host + (x.pathname && x.pathname !== '/' ? x.pathname : '')
64
+ } catch {
65
+ return u
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Compute a label for a (toolName, input) pair. Returns null when the
71
+ * tool should NOT be labeled (suppress / fall through to existing
72
+ * renderer precedence).
73
+ */
74
+ export function computeLabel(toolName, input) {
75
+ const i = input ?? {}
76
+
77
+ // Tools whose labels are already handled elsewhere — emit nothing so
78
+ // the existing description / TodoWrite / sub-agent paths win.
79
+ switch (toolName) {
80
+ case 'Bash':
81
+ case 'Task':
82
+ case 'Agent':
83
+ case 'TodoWrite':
84
+ case 'ToolSearch':
85
+ return null
86
+ }
87
+
88
+ // Built-in rule table.
89
+ switch (toolName) {
90
+ case 'Read':
91
+ return `Reading ${clip(safeBasename(i.file_path))}`.trim()
92
+ case 'Edit':
93
+ return `Editing ${clip(safeBasename(i.file_path))}`.trim()
94
+ case 'Write':
95
+ return `Writing ${clip(safeBasename(i.file_path))}`.trim()
96
+ case 'Grep': {
97
+ const path = i.path ? clip(String(i.path), 40) : '.'
98
+ const pat = clip(String(i.pattern ?? ''), 40)
99
+ return `Searching ${path} for ${pat}`
100
+ }
101
+ case 'Glob':
102
+ return `Finding files matching ${clip(String(i.pattern ?? ''), 60)}`
103
+ case 'WebFetch':
104
+ return `Fetching ${clip(urlHostPath(i.url), 60)}`
105
+ case 'WebSearch':
106
+ return `Searching the web for ${clip(String(i.query ?? ''), 60)}`
107
+ case 'NotebookEdit':
108
+ return `Editing notebook ${clip(safeBasename(i.notebook_path))}`
109
+ case 'BashOutput':
110
+ return 'Reading background output'
111
+ case 'KillBash':
112
+ case 'KillShell':
113
+ return 'Stopping background process'
114
+ }
115
+
116
+ // MCP allowlist.
117
+ if (typeof toolName === 'string' && toolName.startsWith('mcp__')) {
118
+ switch (toolName) {
119
+ case 'mcp__switchroom-telegram__reply':
120
+ case 'mcp__switchroom-telegram__stream_reply':
121
+ return 'Replying'
122
+ case 'mcp__switchroom-telegram__react': {
123
+ const emoji = clip(String(i.emoji ?? ''), 8)
124
+ return emoji ? `Reacting ${emoji}` : 'Reacting'
125
+ }
126
+ case 'mcp__switchroom-telegram__get_recent_messages':
127
+ return 'Reading chat history'
128
+ case 'mcp__hindsight__recall':
129
+ case 'mcp__hindsight__reflect':
130
+ return 'Searching memory'
131
+ case 'mcp__hindsight__retain':
132
+ return 'Saving memory'
133
+ // Explicit suppressions — return null so we don't emit a sidecar
134
+ // line at all. (Falling through to the default below produces the
135
+ // same effect, but listing these makes the intent obvious.)
136
+ case 'mcp__switchroom-telegram__send_typing':
137
+ case 'mcp__hindsight__sync_retain':
138
+ return null
139
+ }
140
+ // Any other mcp__* tool: not on the allowlist, no label.
141
+ return null
142
+ }
143
+
144
+ return null
145
+ }
146
+
147
+ function main() {
148
+ const raw = readStdin().trim()
149
+ if (!raw) process.exit(0)
150
+
151
+ let event
152
+ try {
153
+ event = JSON.parse(raw)
154
+ } catch {
155
+ process.exit(0)
156
+ }
157
+
158
+ const stateDir = process.env.TELEGRAM_STATE_DIR
159
+ if (!stateDir || stateDir.length === 0) process.exit(0)
160
+
161
+ const sessionId = event.session_id
162
+ const toolUseId = event.tool_use_id
163
+ const toolName = event.tool_name
164
+ if (!sessionId || !toolUseId || !toolName) process.exit(0)
165
+
166
+ let label
167
+ try {
168
+ label = computeLabel(toolName, event.tool_input)
169
+ } catch {
170
+ process.exit(0)
171
+ }
172
+ if (!label) process.exit(0)
173
+
174
+ // agent_id: Claude Code does not pass sub-agent agent_id directly to
175
+ // the hook; fall back to SWITCHROOM_AGENT_NAME or the cwd basename.
176
+ const agentId =
177
+ process.env.SWITCHROOM_AGENT_NAME ??
178
+ (event.cwd ? safeBasename(event.cwd) : null) ??
179
+ null
180
+
181
+ const line = JSON.stringify({
182
+ ts: Date.now(),
183
+ tool_use_id: toolUseId,
184
+ agent_id: agentId,
185
+ label,
186
+ tool_name: toolName,
187
+ }) + '\n'
188
+
189
+ try {
190
+ if (!existsSync(stateDir)) {
191
+ mkdirSync(stateDir, { recursive: true })
192
+ }
193
+ const target = join(stateDir, `tool-labels-${sessionId}.jsonl`)
194
+ appendFileSync(target, line)
195
+ } catch (err) {
196
+ // Never block. Surface to stderr (captured by plugin-logger).
197
+ try {
198
+ process.stderr.write(
199
+ `[tool-label-pretool] write failed: ${err?.message ?? err}\n`,
200
+ )
201
+ } catch { /* ignore */ }
202
+ }
203
+
204
+ process.exit(0)
205
+ }
206
+
207
+ // Skip main() when imported (for unit tests of computeLabel).
208
+ const isMain = (() => {
209
+ try {
210
+ const argv1 = process.argv[1] ?? ''
211
+ return argv1.endsWith('tool-label-pretool.mjs')
212
+ } catch {
213
+ return false
214
+ }
215
+ })()
216
+ if (isMain) main()
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook — reaps stale tool-label sidecar files.
4
+ *
5
+ * Removes $TELEGRAM_STATE_DIR/tool-labels-*.jsonl files older than 24h.
6
+ * If more than 50 sidecar files exist, removes the oldest down to 50.
7
+ * Always exits 0.
8
+ *
9
+ * Issue #783.
10
+ */
11
+
12
+ import { readdirSync, statSync, unlinkSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000
16
+ const MAX_SIDECARS = 50
17
+
18
+ function main() {
19
+ const stateDir = process.env.TELEGRAM_STATE_DIR
20
+ if (!stateDir || stateDir.length === 0) process.exit(0)
21
+
22
+ let entries
23
+ try {
24
+ entries = readdirSync(stateDir)
25
+ } catch {
26
+ process.exit(0)
27
+ }
28
+
29
+ const now = Date.now()
30
+ const sidecars = []
31
+ for (const name of entries) {
32
+ if (!name.startsWith('tool-labels-') || !name.endsWith('.jsonl')) continue
33
+ const full = join(stateDir, name)
34
+ try {
35
+ const st = statSync(full)
36
+ sidecars.push({ path: full, mtime: st.mtimeMs })
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+
42
+ // 1) Age-based reap
43
+ for (const s of sidecars) {
44
+ if (now - s.mtime > TWENTY_FOUR_HOURS_MS) {
45
+ try { unlinkSync(s.path) } catch { /* ignore */ }
46
+ s._removed = true
47
+ }
48
+ }
49
+
50
+ // 2) Cap by count — drop oldest beyond MAX_SIDECARS
51
+ const remaining = sidecars.filter((s) => !s._removed)
52
+ if (remaining.length > MAX_SIDECARS) {
53
+ remaining.sort((a, b) => a.mtime - b.mtime)
54
+ const toDrop = remaining.length - MAX_SIDECARS
55
+ for (let i = 0; i < toDrop; i++) {
56
+ try { unlinkSync(remaining[i].path) } catch { /* ignore */ }
57
+ }
58
+ }
59
+
60
+ process.exit(0)
61
+ }
62
+
63
+ main()
@@ -19,11 +19,14 @@
19
19
  "start:source": "bun server.ts",
20
20
  "start:dist": "bun dist/server.js",
21
21
  "build": "node scripts/build.mjs",
22
- "prepublishOnly": "npm run build"
22
+ "prepublishOnly": "npm run build",
23
+ "test:uat": "vitest run --config ../vitest.uat.config.ts",
24
+ "uat:login": "bun uat/login.ts"
23
25
  },
24
26
  "dependencies": {
25
27
  "@grammyjs/runner": "^2.0.3",
26
28
  "@modelcontextprotocol/sdk": "^1.0.0",
29
+ "@mtcute/node": "^0.27.0",
27
30
  "@secretlint/core": "^12.2.0",
28
31
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
29
32
  "@secretlint/types": "^12.2.0",
@@ -24,7 +24,13 @@ import { homedir } from 'os'
24
24
  import { dirname, join } from 'path'
25
25
 
26
26
  const DEFAULT_LOG_PATH = join(homedir(), '.switchroom', 'logs', 'telegram-plugin.log')
27
- const ROTATE_AT_BYTES = 5 * 1024 * 1024 // 5 MB
27
+ // Retention bump (#card-audit-log): the new structured `card-events.jsonl`
28
+ // is the durable audit trail; this file is the freeform freestream. Bump
29
+ // from 5 MB × 1 backup to 50 MB × 5 backups so a multi-day card-render
30
+ // regression is still grep-able from the raw log when the operator goes
31
+ // looking days later.
32
+ const ROTATE_AT_BYTES = 50 * 1024 * 1024 // 50 MB
33
+ const ROTATION_BACKUPS = 5
28
34
 
29
35
  export interface PluginLoggerHandle {
30
36
  /** Stop intercepting and restore the original stderr.write. */
@@ -59,6 +65,18 @@ function rotateIfNeeded(path: string): void {
59
65
  try {
60
66
  const st = statSync(path)
61
67
  if (st.size < ROTATE_AT_BYTES) return
68
+ // Shift backups: .N-1 → .N, .N-2 → .N-1, ..., .1 → .2, current → .1.
69
+ // Best-effort: any rename that fails (missing intermediate, permission)
70
+ // is swallowed so logging never throws.
71
+ for (let i = ROTATION_BACKUPS - 1; i >= 1; i--) {
72
+ const src = `${path}.${i}`
73
+ const dst = `${path}.${i + 1}`
74
+ try {
75
+ if (existsSync(src)) renameSync(src, dst)
76
+ } catch {
77
+ // ignore
78
+ }
79
+ }
62
80
  const backup = `${path}.1`
63
81
  renameSync(path, backup)
64
82
  } catch {
@@ -133,4 +151,5 @@ export function _resetForTests(): void {
133
151
  export const _internals = {
134
152
  DEFAULT_LOG_PATH,
135
153
  ROTATE_AT_BYTES,
154
+ ROTATION_BACKUPS,
136
155
  }