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.
- package/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +510 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +564 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|