switchroom 0.7.15 → 0.10.0
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 +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded exponential-backoff retry for gateway startup network errors
|
|
2
|
+
* Bounded exponential-backoff retry for gateway startup network errors,
|
|
3
|
+
* with classification of the failure mode so the caller can act.
|
|
3
4
|
*
|
|
4
5
|
* On 2026-04-29 all five switchroom gateways silently broke at boot because
|
|
5
6
|
* `api.telegram.org` was unreachable for ~27 minutes after system boot (the
|
|
@@ -9,22 +10,29 @@
|
|
|
9
10
|
* process alive but not polling. No crash, so systemd's `Restart=always` never
|
|
10
11
|
* fired. Telegram → agent delivery was dead until manual restarts.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Then issue #1076: a *revoked or wrong-typed* bot token returns Telegram API
|
|
14
|
+
* 401 `Unauthorized`. Pre-fix `gatewayStartupRetry` rethrew non-network errors
|
|
15
|
+
* immediately, the surrounding gateway catch block exited 1, the in-container
|
|
16
|
+
* `_switchroom_supervise` respawned, the new gateway re-hit 401, repeat. Ten
|
|
17
|
+
* restarts in <60 s tripped the supervisor cap and the gateway went silently
|
|
18
|
+
* dead with no operator-visible signal. This module now distinguishes 401 as
|
|
19
|
+
* a permanent config error, which the gateway handles by writing an issue +
|
|
20
|
+
* quarantine marker + exit-78 (the supervisor's "config error, don't
|
|
21
|
+
* restart" sentinel — see profiles/_base/start.sh.hbs).
|
|
16
22
|
*
|
|
17
|
-
*
|
|
23
|
+
* This module provides:
|
|
18
24
|
*
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* `classifyStartupError(err)` — returns `'network' | 'unauthorized' | 'other'`.
|
|
26
|
+
* `isBootNetworkError(err)` — back-compat alias for the network arm.
|
|
27
|
+
* `STARTUP_RETRY_DELAYS_MS` — the chosen backoff schedule.
|
|
28
|
+
* `gatewayStartupRetry(fn, opts)` — drives the retry loop.
|
|
23
29
|
*
|
|
24
30
|
* The function is extracted from `gateway.ts`'s top-level IIFE so it can be
|
|
25
31
|
* unit-tested without spinning up the full bot runtime.
|
|
26
32
|
*/
|
|
27
33
|
|
|
34
|
+
export type StartupErrorKind = 'network' | 'unauthorized' | 'other'
|
|
35
|
+
|
|
28
36
|
export interface StartupRetryOpts {
|
|
29
37
|
/**
|
|
30
38
|
* Delay schedule in milliseconds. Each attempt waits the corresponding
|
|
@@ -39,11 +47,23 @@ export interface StartupRetryOpts {
|
|
|
39
47
|
sleep?: (ms: number) => Promise<void>
|
|
40
48
|
|
|
41
49
|
/**
|
|
42
|
-
* Called when all
|
|
43
|
-
* Defaults to `process.exit(1)
|
|
50
|
+
* Called when all NETWORK retries are exhausted. Should NOT return
|
|
51
|
+
* (exit/throw). Defaults to `process.exit(1)` so systemd /
|
|
52
|
+
* `_switchroom_supervise` restart-on-failure can recycle the unit.
|
|
44
53
|
*/
|
|
45
54
|
onExhausted?: (lastError: unknown) => never
|
|
46
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Called when a startup API call returns 401 Unauthorized. The bot token
|
|
58
|
+
* is permanently wrong (revoked, wrong type, typo) — retrying just burns
|
|
59
|
+
* the supervisor restart budget. Caller should write an issue + quarantine
|
|
60
|
+
* marker and `process.exit(78)` (EX_CONFIG). Should NOT return.
|
|
61
|
+
*
|
|
62
|
+
* Default: same exit-1 path as `onExhausted` so callers that haven't been
|
|
63
|
+
* updated keep the pre-fix behaviour (rather than silently swallowing 401).
|
|
64
|
+
*/
|
|
65
|
+
onUnauthorized?: (err: unknown) => never
|
|
66
|
+
|
|
47
67
|
/** Log sink for retry progress messages. Defaults to process.stderr.write. */
|
|
48
68
|
log?: (line: string) => void
|
|
49
69
|
}
|
|
@@ -67,38 +87,83 @@ const DEFAULT_SLEEP = (ms: number): Promise<void> =>
|
|
|
67
87
|
new Promise((resolve) => setTimeout(resolve, ms))
|
|
68
88
|
|
|
69
89
|
/**
|
|
70
|
-
*
|
|
71
|
-
* retry loop should absorb. Covers:
|
|
90
|
+
* Classify a startup-time error into one of:
|
|
72
91
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
92
|
+
* - `network`: transient connectivity / DNS / TCP / fetch failure — the
|
|
93
|
+
* retry loop should absorb these with backoff.
|
|
94
|
+
* - `unauthorized`: Telegram API 401 (revoked or wrong-typed bot token).
|
|
95
|
+
* Permanent until the operator rotates the token. Retrying compounds
|
|
96
|
+
* the supervisor restart budget for no gain — see #1076.
|
|
97
|
+
* - `other`: everything else (bad request shape, 5xx, server bug, etc.).
|
|
98
|
+
* Rethrown to the surrounding gateway catch block, which exits non-zero
|
|
99
|
+
* so the supervisor can recycle.
|
|
100
|
+
*
|
|
101
|
+
* Grammy surfaces 401 via `GrammyError` (name === 'GrammyError') with
|
|
102
|
+
* `error_code === 401`. Some test fixtures and node-fetch wrappers surface
|
|
103
|
+
* 401 only in the message string, so we fall through to a substring match
|
|
104
|
+
* for `Unauthorized` as defence in depth.
|
|
77
105
|
*/
|
|
78
|
-
export function
|
|
79
|
-
if (!(err instanceof Error)) return
|
|
80
|
-
|
|
81
|
-
|
|
106
|
+
export function classifyStartupError(err: unknown): StartupErrorKind {
|
|
107
|
+
if (!(err instanceof Error)) return 'other'
|
|
108
|
+
|
|
109
|
+
// Unauthorized (#1076). Check BEFORE the network arm so a Grammy-wrapped
|
|
110
|
+
// 401 doesn't accidentally match the "Network request" substring branch
|
|
111
|
+
// through some future change to grammy's error stringification.
|
|
112
|
+
const errAny = err as Error & {
|
|
113
|
+
error_code?: number
|
|
114
|
+
name?: string
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
errAny.name === 'GrammyError' &&
|
|
118
|
+
errAny.error_code === 401
|
|
119
|
+
) {
|
|
120
|
+
return 'unauthorized'
|
|
121
|
+
}
|
|
122
|
+
// Fall-back string match. Telegram's API returns the literal token
|
|
123
|
+
// 'Unauthorized' for 401 in the description field. We avoid a substring
|
|
124
|
+
// of just '401' here because that can match unrelated error codes /
|
|
125
|
+
// ports / numeric content.
|
|
126
|
+
if (err.message.includes('Unauthorized')) return 'unauthorized'
|
|
127
|
+
|
|
128
|
+
// Network arm — grammy wraps fetch/ECONN errors in HttpError.
|
|
129
|
+
if (err.name === 'HttpError') return 'network'
|
|
82
130
|
const msg = err.message
|
|
83
|
-
|
|
131
|
+
if (
|
|
84
132
|
msg.includes('ECONNRESET') ||
|
|
85
133
|
msg.includes('ETIMEDOUT') ||
|
|
86
134
|
msg.includes('ENOTFOUND') ||
|
|
87
135
|
msg.includes('ECONNREFUSED') ||
|
|
88
136
|
msg.includes('fetch failed') ||
|
|
89
137
|
msg.includes('Network request')
|
|
90
|
-
)
|
|
138
|
+
) {
|
|
139
|
+
return 'network'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'other'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns true if `err` is a transient network-level failure that the startup
|
|
147
|
+
* retry loop should absorb. Retained as a named export for the existing
|
|
148
|
+
* regression tests and downstream callers that only care about the network
|
|
149
|
+
* arm. Prefer `classifyStartupError` for new code.
|
|
150
|
+
*/
|
|
151
|
+
export function isBootNetworkError(err: unknown): boolean {
|
|
152
|
+
return classifyStartupError(err) === 'network'
|
|
91
153
|
}
|
|
92
154
|
|
|
93
155
|
/**
|
|
94
|
-
* Attempt `fn()` and retry on
|
|
95
|
-
*
|
|
156
|
+
* Attempt `fn()` and retry on network failures using the provided delay
|
|
157
|
+
* schedule.
|
|
96
158
|
*
|
|
97
159
|
* - On success: returns whatever `fn()` resolved to.
|
|
98
|
-
* - On
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
160
|
+
* - On unauthorized (401): calls `opts.onUnauthorized(err)` which must not
|
|
161
|
+
* return. The gateway uses this to write an issue + quarantine marker
|
|
162
|
+
* + `process.exit(78)`. Default is `process.exit(1)` for back-compat.
|
|
163
|
+
* - On other non-network error: re-throws immediately (not a transient
|
|
164
|
+
* boot issue, not a known config error).
|
|
165
|
+
* - On exhausted network retries: calls `opts.onExhausted(lastError)` which
|
|
166
|
+
* must not return. Default is `process.exit(1)`.
|
|
102
167
|
*/
|
|
103
168
|
export async function gatewayStartupRetry<T>(
|
|
104
169
|
fn: () => Promise<T>,
|
|
@@ -114,6 +179,16 @@ export async function gatewayStartupRetry<T>(
|
|
|
114
179
|
)
|
|
115
180
|
process.exit(1)
|
|
116
181
|
})
|
|
182
|
+
const onUnauthorized: (err: unknown) => never =
|
|
183
|
+
opts.onUnauthorized ??
|
|
184
|
+
((err: unknown) => {
|
|
185
|
+
// Back-compat default. Real callers (gateway.ts) override this with
|
|
186
|
+
// an issue-sink writer + quarantine-marker writer + exit-78.
|
|
187
|
+
process.stderr.write(
|
|
188
|
+
`telegram gateway: startup unauthorized (bot token rejected) — exiting: ${(err as Error).message}\n`,
|
|
189
|
+
)
|
|
190
|
+
process.exit(1)
|
|
191
|
+
})
|
|
117
192
|
const log =
|
|
118
193
|
opts.log ??
|
|
119
194
|
((line: string) => {
|
|
@@ -127,7 +202,10 @@ export async function gatewayStartupRetry<T>(
|
|
|
127
202
|
try {
|
|
128
203
|
return await fn()
|
|
129
204
|
} catch (err) {
|
|
130
|
-
|
|
205
|
+
const kind = classifyStartupError(err)
|
|
206
|
+
if (kind === 'unauthorized') return onUnauthorized(err)
|
|
207
|
+
if (kind === 'other') throw err
|
|
208
|
+
// network
|
|
131
209
|
lastError = err
|
|
132
210
|
if (attempt >= maxAttempts) break
|
|
133
211
|
const delayMs = delays[attempt - 1]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the synthetic `vault_grant_approved` and
|
|
3
|
+
* `vault_grant_denied` inbounds the gateway injects after the
|
|
4
|
+
* operator taps Approve / Deny on a `vault_request_access` card
|
|
5
|
+
* (#1052 / #1150).
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `gateway.ts` so the InboundMessage shape is pinned
|
|
8
|
+
* by tests separate from the broker/IPC plumbing. The shape is
|
|
9
|
+
* load-bearing — it carries the `meta.source` field the bridge keys
|
|
10
|
+
* on when rendering `<channel source="vault_grant_approved">` /
|
|
11
|
+
* `<channel source="vault_grant_denied">` blocks for the model, and
|
|
12
|
+
* the `meta.{agent,key,scope,stage_id,operator_id}` fields that
|
|
13
|
+
* downstream filters / dashboards may anchor on.
|
|
14
|
+
*
|
|
15
|
+
* A regression that drops a meta field or changes the source string
|
|
16
|
+
* would silently break the agent's wake-up flow — the bridge wouldn't
|
|
17
|
+
* recognize the source and route as a generic channel event, the
|
|
18
|
+
* model wouldn't know it was an approval response, and the
|
|
19
|
+
* conversation would drift. Pinning the builders against fixture
|
|
20
|
+
* tests is cheaper than catching that downstream.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
24
|
+
|
|
25
|
+
/** Subset of the pending-request state the builders need. Kept narrow
|
|
26
|
+
* so callers don't have to pass the full PendingVaultRequestAccess. */
|
|
27
|
+
export interface VaultGrantInboundContext {
|
|
28
|
+
agent: string
|
|
29
|
+
key: string
|
|
30
|
+
scope: 'read' | 'write'
|
|
31
|
+
/** Telegram chat id where the approval card lived. Used as the
|
|
32
|
+
* inbound's chatId — keeps the synthesized turn associated with
|
|
33
|
+
* the conversation that triggered the request. */
|
|
34
|
+
chat_id: string
|
|
35
|
+
/** Seconds. For approved grants; ignored for deny. */
|
|
36
|
+
ttl_seconds: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the synthetic InboundMessage for a successful operator
|
|
41
|
+
* approval. Meta fields are pinned by tests.
|
|
42
|
+
*
|
|
43
|
+
* @param ctx Per-request context (agent, key, scope, chat).
|
|
44
|
+
* @param grantId Broker-returned grant id (e.g. "vg_a1b2c3").
|
|
45
|
+
* @param stageId The card's stage id from the approval flow.
|
|
46
|
+
* @param operatorId Telegram user id of the approving operator
|
|
47
|
+
* (string for portability — Telegram ids are
|
|
48
|
+
* numeric but routinely round-trip as strings).
|
|
49
|
+
* @param nowMs Wall-clock ms. Used for both `ts` and
|
|
50
|
+
* `messageId` so the helper is deterministic
|
|
51
|
+
* under fake clock. Defaults to `Date.now()`.
|
|
52
|
+
*/
|
|
53
|
+
export function buildVaultGrantApprovedInbound(opts: {
|
|
54
|
+
ctx: VaultGrantInboundContext
|
|
55
|
+
grantId: string
|
|
56
|
+
stageId: string
|
|
57
|
+
operatorId: string
|
|
58
|
+
nowMs?: number
|
|
59
|
+
}): InboundMessage {
|
|
60
|
+
const ts = opts.nowMs ?? Date.now()
|
|
61
|
+
const days = Math.round(opts.ctx.ttl_seconds / 86400)
|
|
62
|
+
return {
|
|
63
|
+
type: 'inbound',
|
|
64
|
+
chatId: opts.ctx.chat_id,
|
|
65
|
+
messageId: ts, // synthetic — no Telegram message id exists
|
|
66
|
+
user: 'vault-broker',
|
|
67
|
+
userId: 0,
|
|
68
|
+
ts,
|
|
69
|
+
text:
|
|
70
|
+
`✅ Operator approved your vault access request for ` +
|
|
71
|
+
`\`${opts.ctx.key}\` (scope=${opts.ctx.scope}, ` +
|
|
72
|
+
`${days}d, grant=${opts.grantId}). ` +
|
|
73
|
+
`The token has been written. Please resume the task that was ` +
|
|
74
|
+
`waiting on this credential — fetch via the usual switchroom vault ` +
|
|
75
|
+
`get path.`,
|
|
76
|
+
meta: {
|
|
77
|
+
source: 'vault_grant_approved',
|
|
78
|
+
agent: opts.ctx.agent,
|
|
79
|
+
key: opts.ctx.key,
|
|
80
|
+
scope: opts.ctx.scope,
|
|
81
|
+
grant_id: opts.grantId,
|
|
82
|
+
stage_id: opts.stageId,
|
|
83
|
+
operator_id: opts.operatorId,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the synthetic InboundMessage for an operator denial.
|
|
90
|
+
*
|
|
91
|
+
* The text steers the model toward a fallback path (apologise, try a
|
|
92
|
+
* different approach, skip the feature) — added in #1156 alongside
|
|
93
|
+
* the buffer-on-disconnect fix because the deny side had the same
|
|
94
|
+
* agent-stays-idle bug as the approve side.
|
|
95
|
+
*/
|
|
96
|
+
export function buildVaultGrantDeniedInbound(opts: {
|
|
97
|
+
ctx: VaultGrantInboundContext
|
|
98
|
+
stageId: string
|
|
99
|
+
operatorId: string
|
|
100
|
+
nowMs?: number
|
|
101
|
+
}): InboundMessage {
|
|
102
|
+
const ts = opts.nowMs ?? Date.now()
|
|
103
|
+
return {
|
|
104
|
+
type: 'inbound',
|
|
105
|
+
chatId: opts.ctx.chat_id,
|
|
106
|
+
messageId: ts,
|
|
107
|
+
user: 'vault-broker',
|
|
108
|
+
userId: 0,
|
|
109
|
+
ts,
|
|
110
|
+
text:
|
|
111
|
+
`🚫 Operator denied your vault access request for ` +
|
|
112
|
+
`\`${opts.ctx.key}\` (scope=${opts.ctx.scope}). ` +
|
|
113
|
+
`The credential is unavailable — pick a fallback for the original task ` +
|
|
114
|
+
`(apologise to the user, try a different approach, or skip the feature). ` +
|
|
115
|
+
`Do NOT re-request this key without first asking the user.`,
|
|
116
|
+
meta: {
|
|
117
|
+
source: 'vault_grant_denied',
|
|
118
|
+
agent: opts.ctx.agent,
|
|
119
|
+
key: opts.ctx.key,
|
|
120
|
+
scope: opts.ctx.scope,
|
|
121
|
+
stage_id: opts.stageId,
|
|
122
|
+
operator_id: opts.operatorId,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -195,6 +195,69 @@ export function _resetForTests(): void {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Issue a WAL checkpoint on the history DB, releasing `*.db-wal` pages
|
|
200
|
+
* back to the main DB and truncating the WAL file. Called by the
|
|
201
|
+
* gateway's periodic reaper so the WAL doesn't grow unbounded in
|
|
202
|
+
* long-running agent sessions (issue #1073).
|
|
203
|
+
*
|
|
204
|
+
* Wrapped in try/catch — `PRAGMA wal_checkpoint(TRUNCATE)` can return
|
|
205
|
+
* SQLITE_BUSY under reader pressure, which bun:sqlite raises as a thrown
|
|
206
|
+
* error. That's non-fatal; the next reaper tick retries. Returns true
|
|
207
|
+
* on success, false on a swallowed error.
|
|
208
|
+
*
|
|
209
|
+
* No-op (returns false) if `initHistory` was never called.
|
|
210
|
+
*/
|
|
211
|
+
export function checkpointWal(): boolean {
|
|
212
|
+
if (db == null) return false
|
|
213
|
+
try {
|
|
214
|
+
db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').run()
|
|
215
|
+
return true
|
|
216
|
+
} catch {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Prune `messages` rows older than `retentionDays`. Used by the periodic
|
|
223
|
+
* reaper (#1073) to catch the case where the gateway runs for weeks or
|
|
224
|
+
* months — the init-time prune only fires once at boot.
|
|
225
|
+
*
|
|
226
|
+
* Returns the number of rows deleted (sum across all batches). No-op
|
|
227
|
+
* if `retentionDays <= 0` or if `initHistory` was never called.
|
|
228
|
+
*
|
|
229
|
+
* Batched to keep transactions short; otherwise a years-old DB on first
|
|
230
|
+
* boot after an upgrade would lock the inbound write path for the duration
|
|
231
|
+
* of a single multi-million-row DELETE. Uses the rowid-subselect form
|
|
232
|
+
* because bun:sqlite is built without SQLITE_ENABLE_UPDATE_DELETE_LIMIT
|
|
233
|
+
* (same constraint as reaper.ts).
|
|
234
|
+
*/
|
|
235
|
+
export function pruneMessagesOlderThanDays(
|
|
236
|
+
retentionDays: number,
|
|
237
|
+
nowSec?: number,
|
|
238
|
+
batchLimit = 5000,
|
|
239
|
+
): number {
|
|
240
|
+
if (db == null) return 0
|
|
241
|
+
if (retentionDays <= 0) return 0
|
|
242
|
+
const cutoffSec = (nowSec ?? Math.floor(Date.now() / 1000)) - retentionDays * 86400
|
|
243
|
+
const stmt = db.prepare(`
|
|
244
|
+
DELETE FROM messages
|
|
245
|
+
WHERE rowid IN (
|
|
246
|
+
SELECT rowid FROM messages WHERE ts < ? LIMIT ?
|
|
247
|
+
)
|
|
248
|
+
`)
|
|
249
|
+
let total = 0
|
|
250
|
+
// Same defence-in-depth ceiling as reaper.ts — caps a single call at
|
|
251
|
+
// 5M rows at the default batch size, more than any healthy fleet.
|
|
252
|
+
for (let i = 0; i < 1000; i++) {
|
|
253
|
+
const result = stmt.run(cutoffSec, batchLimit) as { changes: number }
|
|
254
|
+
const n = result.changes ?? 0
|
|
255
|
+
total += n
|
|
256
|
+
if (n === 0) break
|
|
257
|
+
}
|
|
258
|
+
return total
|
|
259
|
+
}
|
|
260
|
+
|
|
198
261
|
function requireDb(): SqliteDatabase {
|
|
199
262
|
if (db == null) {
|
|
200
263
|
throw new Error('history: initHistory() must be called before any record/query operation')
|
|
@@ -430,6 +493,34 @@ export function getLatestInboundMessageId(
|
|
|
430
493
|
return row?.message_id ?? null
|
|
431
494
|
}
|
|
432
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Look up the role + text of a single message by (chat_id, message_id).
|
|
498
|
+
* Returns `null` if no row exists (the message predates history, the
|
|
499
|
+
* row was reaped, or history is disabled). Used by the reaction-trigger
|
|
500
|
+
* handler (#1074) to decide whether a reacted-to message is bot-authored
|
|
501
|
+
* AND to pull the preview text for the synthesized inbound — both in
|
|
502
|
+
* one DB hit, so the trigger predicate doesn't need a Telegram API call
|
|
503
|
+
* per reaction.
|
|
504
|
+
*
|
|
505
|
+
* Telegram message_ids are unique within a chat regardless of thread,
|
|
506
|
+
* so we match on (chat_id, message_id) and ignore thread_id — same as
|
|
507
|
+
* recordEdit / recordReaction.
|
|
508
|
+
*/
|
|
509
|
+
export function lookupMessageRoleAndText(
|
|
510
|
+
chatId: string,
|
|
511
|
+
messageId: number,
|
|
512
|
+
): { role: 'user' | 'assistant'; text: string } | null {
|
|
513
|
+
const row = requireDb()
|
|
514
|
+
.prepare(
|
|
515
|
+
`SELECT role, text FROM messages WHERE chat_id = ? AND message_id = ? LIMIT 1`,
|
|
516
|
+
)
|
|
517
|
+
.get(chatId, messageId) as
|
|
518
|
+
| { role: 'user' | 'assistant'; text: string | null }
|
|
519
|
+
| undefined
|
|
520
|
+
if (!row) return null
|
|
521
|
+
return { role: row.role, text: row.text ?? '' }
|
|
522
|
+
}
|
|
523
|
+
|
|
433
524
|
export function getRecentOutboundCount(
|
|
434
525
|
chatId: string,
|
|
435
526
|
withinSeconds: number,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse hook — detects sandbox-related errors in tool_response and
|
|
4
|
+
* injects a one-line hint via Claude Code's `hookSpecificOutput.
|
|
5
|
+
* additionalContext` channel. The hint reminds the agent that the
|
|
6
|
+
* read-only file system / EROFS error is the switchroom sandbox working
|
|
7
|
+
* as intended, and that it should respond to the user with a concrete
|
|
8
|
+
* "Operator action: ..." line rather than retrying or echoing the raw
|
|
9
|
+
* kernel error.
|
|
10
|
+
*
|
|
11
|
+
* Pairs with the SANDBOX_GUIDANCE primer in --append-system-prompt
|
|
12
|
+
* (src/agents/scaffold.ts). The primer is the always-on context; this
|
|
13
|
+
* hook is the just-in-time nudge that fires only when the agent
|
|
14
|
+
* actually hits the boundary.
|
|
15
|
+
*
|
|
16
|
+
* Claude Code PostToolUse protocol:
|
|
17
|
+
* stdin: JSON { tool_name, tool_use_id, tool_input, tool_response, ... }
|
|
18
|
+
* stdout: optional JSON
|
|
19
|
+
* {"hookSpecificOutput":{"hookEventName":"PostToolUse",
|
|
20
|
+
* "additionalContext":"<text>"}}
|
|
21
|
+
* prepended to the model's next-turn context after the tool
|
|
22
|
+
* result is shown.
|
|
23
|
+
* exit: 0 always. Hook failures must never block the tool flow.
|
|
24
|
+
*
|
|
25
|
+
* Design notes:
|
|
26
|
+
* - Detection is a substring/regex match against the stringified
|
|
27
|
+
* tool_response (covers stdout, stderr, error fields).
|
|
28
|
+
* - No DB writes, no IPC. Pure stdin → stdout, fail-silent.
|
|
29
|
+
* - Idempotent: re-reading the same tool_response yields the same
|
|
30
|
+
* hint. Claude Code dedupes additionalContext naturally because the
|
|
31
|
+
* hook fires once per PostToolUse event.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync } from 'node:fs'
|
|
35
|
+
|
|
36
|
+
function readStdin() {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(0, 'utf8')
|
|
39
|
+
} catch {
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Patterns that indicate a sandbox-boundary hit, in order of specificity.
|
|
46
|
+
* Each entry: [regex, hint-key]. Hint text is composed below from the
|
|
47
|
+
* matched key — keeps the patterns easy to scan.
|
|
48
|
+
*/
|
|
49
|
+
const PATTERNS = [
|
|
50
|
+
// The canonical kernel error code + message. Covers most write/mkdir/
|
|
51
|
+
// rename/unlink failures against the read-only rootfs.
|
|
52
|
+
[/\bEROFS\b/, 'erofs'],
|
|
53
|
+
[/read[- ]only file ?system/i, 'erofs'],
|
|
54
|
+
// npm/pip install attempts that hit a read-only prefix. These usually
|
|
55
|
+
// surface as ENOENT or permission errors against /usr/lib/node_modules
|
|
56
|
+
// or /usr/local/lib — listing the explicit paths keeps us from
|
|
57
|
+
// false-matching on user code that legitimately mentions /usr.
|
|
58
|
+
[/EACCES.+\/(usr|opt|etc|bin|lib)\//, 'eacces-rootfs'],
|
|
59
|
+
// apt / dpkg refusing to write to /var/lib/dpkg etc.
|
|
60
|
+
[/dpkg.*permission denied|apt.*permission denied|Unable to acquire the dpkg/i, 'apt'],
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
function buildHint(key) {
|
|
64
|
+
const common =
|
|
65
|
+
'Sandbox boundary hit. The agent container has `read_only: true` rootfs ' +
|
|
66
|
+
'(see the SANDBOX primer in the system prompt). Do NOT retry the same ' +
|
|
67
|
+
'write. Tell the user what you tried, why the sandbox blocked it, and ' +
|
|
68
|
+
'name an operator action (e.g. "edit on host then `switchroom apply`", ' +
|
|
69
|
+
'or "add to docker/Dockerfile.agent and rebuild"). Writable paths: ' +
|
|
70
|
+
'$HOME (/state/agent/home), /tmp, /state/agent/**, /var/log/switchroom.'
|
|
71
|
+
|
|
72
|
+
if (key === 'apt') {
|
|
73
|
+
return (
|
|
74
|
+
common +
|
|
75
|
+
' For package installs specifically: ask the operator to add the ' +
|
|
76
|
+
'package to docker/Dockerfile.agent and rebuild the agent image — ' +
|
|
77
|
+
'in-container apt is not the right path.'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return common
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emitContext(text) {
|
|
84
|
+
const payload = {
|
|
85
|
+
hookSpecificOutput: {
|
|
86
|
+
hookEventName: 'PostToolUse',
|
|
87
|
+
additionalContext: text,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(JSON.stringify(payload) + '\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function main() {
|
|
94
|
+
const raw = readStdin()
|
|
95
|
+
if (!raw) return
|
|
96
|
+
|
|
97
|
+
let evt
|
|
98
|
+
try {
|
|
99
|
+
evt = JSON.parse(raw)
|
|
100
|
+
} catch {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// tool_response shape varies by tool — string for Bash, object with
|
|
105
|
+
// file/oldString/newString for Edit/Write, etc. Stringify the whole
|
|
106
|
+
// thing so we match against every nested error field at once. Cap the
|
|
107
|
+
// scan window to keep memory bounded if the model just dumped a 10MB
|
|
108
|
+
// log into the tool_response.
|
|
109
|
+
let body
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.stringify(evt.tool_response ?? '')
|
|
112
|
+
} catch {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (!body) return
|
|
116
|
+
if (body.length > 64 * 1024) body = body.slice(0, 64 * 1024)
|
|
117
|
+
|
|
118
|
+
for (const [pattern, key] of PATTERNS) {
|
|
119
|
+
if (pattern.test(body)) {
|
|
120
|
+
emitContext(buildHint(key))
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
main()
|
|
128
|
+
} catch {
|
|
129
|
+
// Fail-silent. The PostToolUse must never block the tool flow.
|
|
130
|
+
}
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
* block the tool response.
|
|
12
12
|
*
|
|
13
13
|
* DB location: <agentDir>/telegram/registry.db
|
|
14
|
-
* agentDir
|
|
14
|
+
* agentDir lookup (first hit wins):
|
|
15
|
+
* 1. SWITCHROOM_AGENT_DIR env var (explicit override, mainly used in tests)
|
|
16
|
+
* 2. TELEGRAM_STATE_DIR with `/telegram` suffix stripped — the canonical
|
|
17
|
+
* env var start.sh exports on every switchroom agent. See the
|
|
18
|
+
* sibling pretool hook docblock for why this lookup matters (without
|
|
19
|
+
* it the hook used to write to a registry.db nobody read).
|
|
20
|
+
* 3. process.cwd() (legacy fallback for ad-hoc invocations).
|
|
15
21
|
*
|
|
16
22
|
* Performance: the actual DB write is deferred via setImmediate (Node 22+
|
|
17
23
|
* node:sqlite path) or non-blocking spawn (CLI fallback) so the hook returns
|
|
@@ -268,7 +274,18 @@ function main() {
|
|
|
268
274
|
const id = event.tool_use_id ?? null
|
|
269
275
|
if (!id) process.exit(0)
|
|
270
276
|
|
|
271
|
-
|
|
277
|
+
// Same agent-dir resolution as the pretool hook (Bug 2 fix). Without
|
|
278
|
+
// the TELEGRAM_STATE_DIR derivation the posttool would write the
|
|
279
|
+
// `ended_at` row to a registry.db nobody reads, even though the row
|
|
280
|
+
// was originally inserted by the pretool hook that DID write to the
|
|
281
|
+
// correct DB (after this PR). Keep the two hooks in lock-step.
|
|
282
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
283
|
+
const derivedFromStateDir = stateDir && stateDir.endsWith('/telegram')
|
|
284
|
+
? stateDir.slice(0, -'/telegram'.length)
|
|
285
|
+
: null
|
|
286
|
+
const agentDir = process.env.SWITCHROOM_AGENT_DIR
|
|
287
|
+
?? derivedFromStateDir
|
|
288
|
+
?? process.cwd()
|
|
272
289
|
const dbPath = join(agentDir, 'telegram', 'registry.db')
|
|
273
290
|
|
|
274
291
|
// If DB doesn't exist yet, nothing to update
|
|
@@ -11,7 +11,17 @@
|
|
|
11
11
|
* block the tool call.
|
|
12
12
|
*
|
|
13
13
|
* DB location: <agentDir>/telegram/registry.db
|
|
14
|
-
* agentDir
|
|
14
|
+
* agentDir lookup (first hit wins):
|
|
15
|
+
* 1. SWITCHROOM_AGENT_DIR env var (explicit override, mainly used in tests)
|
|
16
|
+
* 2. TELEGRAM_STATE_DIR with `/telegram` suffix stripped — the canonical
|
|
17
|
+
* env var start.sh exports for every switchroom agent (and the same
|
|
18
|
+
* path the gateway + watcher resolve their DB through). Without this
|
|
19
|
+
* the hook used to fall through to process.cwd() in production,
|
|
20
|
+
* writing to a registry.db nobody read, leaving every bg sub-agent
|
|
21
|
+
* invisible to the watcher. Surfaced by
|
|
22
|
+
* bg-sub-agent-dispatch-dm.test.ts; see RFC Phase 2 §Bug 2 in
|
|
23
|
+
* reference/sub-agent-visibility-rfc.md.
|
|
24
|
+
* 3. process.cwd() (legacy fallback for ad-hoc invocations).
|
|
15
25
|
*
|
|
16
26
|
* Performance: the actual DB write is deferred via setImmediate (Node 22+
|
|
17
27
|
* node:sqlite path) or a non-blocking spawn (CLI fallback) so the hook
|
|
@@ -223,7 +233,17 @@ function main() {
|
|
|
223
233
|
// misroute).
|
|
224
234
|
if (event.tool_name !== 'Agent' && event.tool_name !== 'Task') process.exit(0)
|
|
225
235
|
|
|
226
|
-
|
|
236
|
+
// Resolve agent dir: explicit env override → derive from TELEGRAM_STATE_DIR
|
|
237
|
+
// (start.sh exports this on every agent) → cwd fallback. The middle case
|
|
238
|
+
// is the production path; without it the hook silently wrote to a
|
|
239
|
+
// registry.db nobody read (#709 / #776 / #782 / #788 Bug 2).
|
|
240
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
241
|
+
const derivedFromStateDir = stateDir && stateDir.endsWith('/telegram')
|
|
242
|
+
? stateDir.slice(0, -'/telegram'.length)
|
|
243
|
+
: null
|
|
244
|
+
const agentDir = process.env.SWITCHROOM_AGENT_DIR
|
|
245
|
+
?? derivedFromStateDir
|
|
246
|
+
?? process.cwd()
|
|
227
247
|
const telegramDir = join(agentDir, 'telegram')
|
|
228
248
|
const dbPath = join(telegramDir, 'registry.db')
|
|
229
249
|
|
|
@@ -111,6 +111,17 @@ export function computeLabel(toolName, input) {
|
|
|
111
111
|
case 'KillBash':
|
|
112
112
|
case 'KillShell':
|
|
113
113
|
return 'Stopping background process'
|
|
114
|
+
case 'Skill': {
|
|
115
|
+
// The Skill tool's input is `{ skill: "<slug>", args?: "..." }`.
|
|
116
|
+
// We emit `Running skill <slug>` so downstream observers
|
|
117
|
+
// (notably the skill-coverage UAT runner at
|
|
118
|
+
// telegram-plugin/uat/runners/skill-coverage.ts) can tail the
|
|
119
|
+
// sidecar JSONL and recover which skill fired per turn —
|
|
120
|
+
// the progress card path that used to surface this was retired
|
|
121
|
+
// when `progressDriver` was nulled out in #1122 PR3.
|
|
122
|
+
const slug = clip(String(i.skill ?? ''), 64)
|
|
123
|
+
return slug ? `Running skill ${slug}` : null
|
|
124
|
+
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
// MCP allowlist.
|