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
|
@@ -34,7 +34,12 @@ import {
|
|
|
34
34
|
} from 'fs'
|
|
35
35
|
import { homedir } from 'os'
|
|
36
36
|
import { basename, join } from 'path'
|
|
37
|
-
|
|
37
|
+
// #1122 PR3: inlined from the deleted progress-card.ts. Kill switch
|
|
38
|
+
// for the sub-agent transcript watcher (PROGRESS_CARD_MULTI_AGENT=0
|
|
39
|
+
// disables it). Name retained for back-compat with operator configs.
|
|
40
|
+
function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
return env.PROGRESS_CARD_MULTI_AGENT !== '0'
|
|
42
|
+
}
|
|
38
43
|
import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
|
|
39
44
|
import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
|
|
40
45
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared bot runtime helpers — extracted from gateway.ts
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Shared bot runtime helpers — extracted from gateway.ts as a reusable
|
|
3
|
+
* core that callers can build on without duplicating the boilerplate.
|
|
4
|
+
* Used today by the per-agent gateway; historically also by the
|
|
5
|
+
* standalone foreman bot before its retirement.
|
|
5
6
|
*
|
|
6
7
|
* What lives here:
|
|
7
8
|
* - `createRobustApiCall` — thin re-export of createRetryApiCall pre-wired
|
|
@@ -361,7 +362,7 @@ export async function runPollingLoop(
|
|
|
361
362
|
|
|
362
363
|
/**
|
|
363
364
|
* Returns true if the sender's user ID is in the allowFrom list.
|
|
364
|
-
* Used by
|
|
365
|
+
* Used by the gateway for sender-allowlist auth gating.
|
|
365
366
|
*/
|
|
366
367
|
export function isAllowedSender(ctx: Context, allowFrom: string[]): boolean {
|
|
367
368
|
const from = ctx.from
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silence-poke.ts — framework safety net for "model is silent to the user."
|
|
3
|
+
*
|
|
4
|
+
* Background (issue #1122): we're moving away from a pinned progress card
|
|
5
|
+
* to a conversational shape where the chat itself is the artifact. The
|
|
6
|
+
* progress card was implicitly doing one useful job — covering for a
|
|
7
|
+
* model that doesn't know how to say "still working." This module is the
|
|
8
|
+
* explicit replacement: when the model has been silent past a threshold,
|
|
9
|
+
* we nudge it (or, as a last resort, send a framework message ourselves).
|
|
10
|
+
*
|
|
11
|
+
* Two clocks (this module owns ONE of them; the other is the legacy
|
|
12
|
+
* idle stall in status-reactions.ts and is unrelated):
|
|
13
|
+
*
|
|
14
|
+
* silence clock = now - lastOutboundAt (or turnStartedAt if no outbound yet)
|
|
15
|
+
*
|
|
16
|
+
* Outbound = a fresh `reply` or `stream_reply` first-emit. Reactions,
|
|
17
|
+
* edits, and tool churn DO NOT reset the silence clock — that's the
|
|
18
|
+
* whole point. The model could be ripping through 20 tool calls and
|
|
19
|
+
* still be "silent" to the user.
|
|
20
|
+
*
|
|
21
|
+
* Escalation ladder per turn:
|
|
22
|
+
*
|
|
23
|
+
* t=0 startTurn() — silence clock starts at turnStartedAt
|
|
24
|
+
* t=75s soft poke armed — appended to next tool result as a
|
|
25
|
+
* <system-reminder> nudging the model to send an update
|
|
26
|
+
* t=180s firm poke armed (stronger wording) if no outbound landed
|
|
27
|
+
* t=300s framework fallback: gateway itself sends a user-visible
|
|
28
|
+
* "still working… / still thinking…" message. Fires at most
|
|
29
|
+
* once per turn. Pings the device (user needs to know).
|
|
30
|
+
*
|
|
31
|
+
* Subagent-dispatch override: when the model dispatches a sub-agent
|
|
32
|
+
* (`Task(...)`, `@worker` etc), the soft threshold extends to 300s for
|
|
33
|
+
* that turn — the model is legitimately waiting on a child, no point
|
|
34
|
+
* poking it to narrate the wait. Firm/fallback thresholds unchanged.
|
|
35
|
+
*
|
|
36
|
+
* Wired into the gateway at the central tool-result chokepoint
|
|
37
|
+
* (`gateway.ts:onToolCall`) so the poke text piggybacks the next tool
|
|
38
|
+
* result back to claude. MCP doesn't allow mid-generation injection;
|
|
39
|
+
* tool results are the only synchronous moment we own the wire.
|
|
40
|
+
*
|
|
41
|
+
* Kill switch: SWITCHROOM_DISABLE_SILENCE_POKE=1 disables the whole
|
|
42
|
+
* subsystem (no timers, no injection, no fallback). The conversational
|
|
43
|
+
* pacing prompt still applies; only the framework safety net is off.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type PokeLevel = 'soft' | 'firm'
|
|
47
|
+
|
|
48
|
+
export interface SilencePokeState {
|
|
49
|
+
/** Wall-clock ms of turn start. Silence clock zero-point when no outbound yet. */
|
|
50
|
+
turnStartedAt: number
|
|
51
|
+
/** Wall-clock ms of last outbound message, or null. */
|
|
52
|
+
lastOutboundAt: number | null
|
|
53
|
+
/** 0 = none, 1 = soft fired, 2 = firm fired, 3 = fallback fired. */
|
|
54
|
+
pokesFired: 0 | 1 | 2 | 3
|
|
55
|
+
/** Armed pending drain on the next tool result, or null. */
|
|
56
|
+
pokeArmed: { level: PokeLevel } | null
|
|
57
|
+
/** When true, soft threshold extends to subagentSoft (default 300s). */
|
|
58
|
+
subagentDispatchActive: boolean
|
|
59
|
+
/** Wall-clock ms of last `thinking` session event, or null. */
|
|
60
|
+
lastThinkingAt: number | null
|
|
61
|
+
/** True once the 300s framework fallback has fired this turn. */
|
|
62
|
+
fallbackFired: boolean
|
|
63
|
+
/** Wall-clock ms of last poke fire — used for poke-success latency. */
|
|
64
|
+
lastPokeFiredAt: number | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ThresholdsMs {
|
|
68
|
+
soft: number
|
|
69
|
+
firm: number
|
|
70
|
+
fallback: number
|
|
71
|
+
/** Soft threshold when subagentDispatchActive=true. */
|
|
72
|
+
subagentSoft: number
|
|
73
|
+
/** How long after a poke we still count an outbound as a "success." */
|
|
74
|
+
pokeSuccessWindowMs: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_THRESHOLDS: ThresholdsMs = {
|
|
78
|
+
soft: 75_000,
|
|
79
|
+
firm: 180_000,
|
|
80
|
+
fallback: 300_000,
|
|
81
|
+
subagentSoft: 300_000,
|
|
82
|
+
pokeSuccessWindowMs: 15_000,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_POLL_INTERVAL_MS = 5_000
|
|
86
|
+
|
|
87
|
+
export interface FrameworkFallbackContext {
|
|
88
|
+
key: string
|
|
89
|
+
chatId: string
|
|
90
|
+
threadId: number | null
|
|
91
|
+
/** Picked from lastThinkingAt: 'thinking' if a thinking event landed in
|
|
92
|
+
* the last 30s of silence, else 'working'. */
|
|
93
|
+
fallbackKind: 'working' | 'thinking'
|
|
94
|
+
silenceMs: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type SilencePokeMetric =
|
|
98
|
+
| { kind: 'silence_poke_fired'; key: string; level: PokeLevel; silence_ms: number; subagent_wait: boolean }
|
|
99
|
+
| { kind: 'silence_poke_succeeded'; key: string; level: PokeLevel; latency_ms: number }
|
|
100
|
+
| { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
101
|
+
|
|
102
|
+
export interface SilencePokeDeps {
|
|
103
|
+
/** Called when the 300s fallback fires. Caller sends the user-visible
|
|
104
|
+
* message + ensures it pings the device. Caller must NOT call back
|
|
105
|
+
* into noteOutbound for this — it's a framework-sourced message,
|
|
106
|
+
* not a model-sourced one, and we want pokes to continue (well, no,
|
|
107
|
+
* fallbackFired ensures only one per turn anyway). */
|
|
108
|
+
onFrameworkFallback: (ctx: FrameworkFallbackContext) => Promise<void> | void
|
|
109
|
+
/** Telemetry sink for poke events. */
|
|
110
|
+
emitMetric: (event: SilencePokeMetric) => void
|
|
111
|
+
/** Threshold overrides (tests). */
|
|
112
|
+
thresholdsMs?: ThresholdsMs
|
|
113
|
+
/** Poll interval (tests). */
|
|
114
|
+
pollIntervalMs?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const state = new Map<string, SilencePokeState>()
|
|
118
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
119
|
+
let activeDeps: SilencePokeDeps | null = null
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* True iff the kill switch is OFF. Re-read every call so tests can
|
|
123
|
+
* toggle process.env without reloading the module.
|
|
124
|
+
*/
|
|
125
|
+
export function silencePokeEnabled(): boolean {
|
|
126
|
+
const v = process.env.SWITCHROOM_DISABLE_SILENCE_POKE
|
|
127
|
+
return !(v === '1' || v === 'true')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Initialise a fresh turn's silence state. No-op when kill switch is on.
|
|
132
|
+
*/
|
|
133
|
+
export function startTurn(key: string, now: number): void {
|
|
134
|
+
if (!silencePokeEnabled()) return
|
|
135
|
+
state.set(key, {
|
|
136
|
+
turnStartedAt: now,
|
|
137
|
+
lastOutboundAt: null,
|
|
138
|
+
pokesFired: 0,
|
|
139
|
+
pokeArmed: null,
|
|
140
|
+
subagentDispatchActive: false,
|
|
141
|
+
lastThinkingAt: null,
|
|
142
|
+
fallbackFired: false,
|
|
143
|
+
lastPokeFiredAt: null,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Record a fresh user-visible outbound message (reply or stream_reply
|
|
149
|
+
* first-emit). Resets the silence clock + the escalation counter. If a
|
|
150
|
+
* poke fired recently, emit a `silence_poke_succeeded` metric.
|
|
151
|
+
*/
|
|
152
|
+
export function noteOutbound(key: string, now: number): void {
|
|
153
|
+
const s = state.get(key)
|
|
154
|
+
if (s == null) return
|
|
155
|
+
// Success measurement: if a poke fired within the success window and
|
|
156
|
+
// an outbound just landed, count it as a successful poke.
|
|
157
|
+
const thresholds = activeDeps?.thresholdsMs ?? DEFAULT_THRESHOLDS
|
|
158
|
+
if (
|
|
159
|
+
s.lastPokeFiredAt != null
|
|
160
|
+
&& (now - s.lastPokeFiredAt) <= thresholds.pokeSuccessWindowMs
|
|
161
|
+
&& activeDeps != null
|
|
162
|
+
&& s.pokesFired >= 1
|
|
163
|
+
&& s.pokesFired <= 2
|
|
164
|
+
) {
|
|
165
|
+
activeDeps.emitMetric({
|
|
166
|
+
kind: 'silence_poke_succeeded',
|
|
167
|
+
key,
|
|
168
|
+
level: s.pokesFired === 1 ? 'soft' : 'firm',
|
|
169
|
+
latency_ms: now - s.lastPokeFiredAt,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
s.lastOutboundAt = now
|
|
173
|
+
s.pokesFired = 0
|
|
174
|
+
s.pokeArmed = null
|
|
175
|
+
// Intentionally DO NOT clear `subagentDispatchActive` here. The
|
|
176
|
+
// model's `reply` narrating the dispatch ("spinning up @reviewer")
|
|
177
|
+
// is itself the outbound that resets the silence clock — clearing
|
|
178
|
+
// the flag would defeat the extended-threshold guarantee for the
|
|
179
|
+
// wait that follows. The flag persists until endTurn(). Fixes the
|
|
180
|
+
// non-blocking note from PR2 review (#1125).
|
|
181
|
+
s.lastPokeFiredAt = null
|
|
182
|
+
s.fallbackFired = false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Note that the model dispatched a sub-agent (Task tool, @worker, etc).
|
|
187
|
+
* Extends the soft threshold for THIS turn. The flag persists until
|
|
188
|
+
* endTurn() — subsequent outbound messages within the turn keep the
|
|
189
|
+
* extended threshold, which is the correct shape for the dispatch
|
|
190
|
+
* narrate → wait → child-result → summarise sequence.
|
|
191
|
+
*/
|
|
192
|
+
export function noteSubagentDispatch(key: string): void {
|
|
193
|
+
const s = state.get(key)
|
|
194
|
+
if (s == null) return
|
|
195
|
+
s.subagentDispatchActive = true
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Record a `thinking` session event. Used to pick "still thinking…" vs
|
|
200
|
+
* "still working…" wording for the 300s framework fallback.
|
|
201
|
+
*/
|
|
202
|
+
export function noteThinking(key: string, now: number): void {
|
|
203
|
+
const s = state.get(key)
|
|
204
|
+
if (s == null) return
|
|
205
|
+
s.lastThinkingAt = now
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Drain any armed poke for ANY active turn and return the system-reminder
|
|
210
|
+
* text to append. Returns null if nothing is armed.
|
|
211
|
+
*
|
|
212
|
+
* Called at the gateway's tool-result chokepoint; the appended reminder
|
|
213
|
+
* piggybacks the result back to claude. Drains the flag immediately so
|
|
214
|
+
* the next tool result doesn't double-inject.
|
|
215
|
+
*
|
|
216
|
+
* Iterates all keys because the tool result doesn't carry which turn it
|
|
217
|
+
* belongs to. In practice the gateway has ≤1 active turn at a time, but
|
|
218
|
+
* the code handles multi-turn correctly: each turn's poke text is
|
|
219
|
+
* appended once (and never appears in another turn's tool result, since
|
|
220
|
+
* we drain by mutating the matched state).
|
|
221
|
+
*/
|
|
222
|
+
export function consumeArmedPoke(): string | null {
|
|
223
|
+
for (const s of state.values()) {
|
|
224
|
+
if (s.pokeArmed != null) {
|
|
225
|
+
const level = s.pokeArmed.level
|
|
226
|
+
s.pokeArmed = null
|
|
227
|
+
return formatPokeText(level)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** End a turn — drop state. Idempotent. */
|
|
234
|
+
export function endTurn(key: string): void {
|
|
235
|
+
state.delete(key)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Verbatim poke text. Wording is load-bearing — see issue #1122 design. */
|
|
239
|
+
export function formatPokeText(level: PokeLevel): string {
|
|
240
|
+
if (level === 'soft') {
|
|
241
|
+
return (
|
|
242
|
+
"[silence-poke] You've been silent to the user for 75s. If you're "
|
|
243
|
+
+ "still working on this, send one short conversational reply — e.g. "
|
|
244
|
+
+ "\"still going, working through X\" — so they know you're alive. "
|
|
245
|
+
+ "Keep it brief; don't restate the task. If you're about to finish "
|
|
246
|
+
+ 'within the next few seconds, skip the update.'
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
return (
|
|
250
|
+
"[silence-poke] 3 minutes silent. Please send an update now — what "
|
|
251
|
+
+ "you're working on, or whether you're stuck. If something is taking "
|
|
252
|
+
+ 'unusually long (slow tool, network, waiting on a sub-agent), say so '
|
|
253
|
+
+ 'explicitly.'
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Verbatim framework-fallback text — the user-visible "still working / still
|
|
259
|
+
* thinking" message the gateway sends at the 300s threshold when the model
|
|
260
|
+
* hasn't broken its own silence. Wording is load-bearing (see
|
|
261
|
+
* `reference/conversational-pacing.md` § Silence-poke ladder). Two principles:
|
|
262
|
+
*
|
|
263
|
+
* 1. The parenthetical `(no update from agent in N min)` is honest —
|
|
264
|
+
* distinguishes from "the agent said something" so users learn to trust
|
|
265
|
+
* real agent messages. `N` is derived from `silenceMs`, never hard-coded.
|
|
266
|
+
* 2. The verb is `working` by default, `thinking` only when the session
|
|
267
|
+
* stream has emitted a `kind: 'thinking'` event in the last 30s. Picked
|
|
268
|
+
* by the caller via `fallbackKind`; this helper just formats.
|
|
269
|
+
*
|
|
270
|
+
* Extracted from the gateway's `onFrameworkFallback` callback so the wording
|
|
271
|
+
* can be snapshot-tested in isolation. CC-4 in `docs/status-ask-cause-classes.md`.
|
|
272
|
+
*/
|
|
273
|
+
export function formatFrameworkFallbackText(
|
|
274
|
+
fallbackKind: 'working' | 'thinking',
|
|
275
|
+
silenceMs: number,
|
|
276
|
+
): string {
|
|
277
|
+
const minutes = Math.max(1, Math.round(silenceMs / 60_000))
|
|
278
|
+
const suffix = `(no update from agent in ${minutes} min)`
|
|
279
|
+
return fallbackKind === 'thinking'
|
|
280
|
+
? `still thinking… ${suffix}`
|
|
281
|
+
: `still working… ${suffix}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Internal tick — iterates active states, arms pokes or fires fallback.
|
|
286
|
+
* Exported as __tickForTests so suite can step the clock deterministically.
|
|
287
|
+
*/
|
|
288
|
+
function tick(now: number): void {
|
|
289
|
+
if (activeDeps == null) return
|
|
290
|
+
const thresholds = activeDeps.thresholdsMs ?? DEFAULT_THRESHOLDS
|
|
291
|
+
for (const [key, s] of state.entries()) {
|
|
292
|
+
const zeroAt = s.lastOutboundAt ?? s.turnStartedAt
|
|
293
|
+
const silence = now - zeroAt
|
|
294
|
+
if (silence < 0) continue
|
|
295
|
+
const softThreshold = s.subagentDispatchActive
|
|
296
|
+
? thresholds.subagentSoft
|
|
297
|
+
: thresholds.soft
|
|
298
|
+
|
|
299
|
+
if (s.pokesFired === 0 && silence >= softThreshold) {
|
|
300
|
+
s.pokeArmed = { level: 'soft' }
|
|
301
|
+
s.pokesFired = 1
|
|
302
|
+
s.lastPokeFiredAt = now
|
|
303
|
+
activeDeps.emitMetric({
|
|
304
|
+
kind: 'silence_poke_fired',
|
|
305
|
+
key,
|
|
306
|
+
level: 'soft',
|
|
307
|
+
silence_ms: silence,
|
|
308
|
+
subagent_wait: s.subagentDispatchActive,
|
|
309
|
+
})
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (s.pokesFired === 1 && silence >= thresholds.firm) {
|
|
314
|
+
s.pokeArmed = { level: 'firm' }
|
|
315
|
+
s.pokesFired = 2
|
|
316
|
+
s.lastPokeFiredAt = now
|
|
317
|
+
activeDeps.emitMetric({
|
|
318
|
+
kind: 'silence_poke_fired',
|
|
319
|
+
key,
|
|
320
|
+
level: 'firm',
|
|
321
|
+
silence_ms: silence,
|
|
322
|
+
subagent_wait: s.subagentDispatchActive,
|
|
323
|
+
})
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (s.pokesFired === 2 && !s.fallbackFired && silence >= thresholds.fallback) {
|
|
328
|
+
s.fallbackFired = true
|
|
329
|
+
s.pokesFired = 3
|
|
330
|
+
const { chatId, threadId } = parseKey(key)
|
|
331
|
+
const recentThinking = s.lastThinkingAt != null
|
|
332
|
+
&& (now - s.lastThinkingAt) < 30_000
|
|
333
|
+
const fallbackKind: 'working' | 'thinking' = recentThinking ? 'thinking' : 'working'
|
|
334
|
+
activeDeps.emitMetric({
|
|
335
|
+
kind: 'silence_fallback_sent',
|
|
336
|
+
key,
|
|
337
|
+
fallback_kind: fallbackKind,
|
|
338
|
+
silence_ms: silence,
|
|
339
|
+
})
|
|
340
|
+
// Caller may throw or fail — guard so a busted fallback doesn't kill the timer.
|
|
341
|
+
try {
|
|
342
|
+
const r = activeDeps.onFrameworkFallback({
|
|
343
|
+
key,
|
|
344
|
+
chatId,
|
|
345
|
+
threadId,
|
|
346
|
+
fallbackKind,
|
|
347
|
+
silenceMs: silence,
|
|
348
|
+
})
|
|
349
|
+
if (r != null && typeof (r as Promise<void>).catch === 'function') {
|
|
350
|
+
;(r as Promise<void>).catch((err) => {
|
|
351
|
+
process.stderr.write(
|
|
352
|
+
`silence-poke: framework fallback handler rejected: ${err}\n`,
|
|
353
|
+
)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
`silence-poke: framework fallback handler threw: ${err}\n`,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Parse `<chatId>:<threadIdOrEmpty>` back into structured fields. Matches
|
|
367
|
+
* the `statusKey` shape used throughout the gateway.
|
|
368
|
+
*/
|
|
369
|
+
function parseKey(key: string): { chatId: string; threadId: number | null } {
|
|
370
|
+
const idx = key.indexOf(':')
|
|
371
|
+
if (idx < 0) return { chatId: key, threadId: null }
|
|
372
|
+
const chatId = key.slice(0, idx)
|
|
373
|
+
const tail = key.slice(idx + 1)
|
|
374
|
+
if (tail === '' || tail === 'undefined') return { chatId, threadId: null }
|
|
375
|
+
const n = Number(tail)
|
|
376
|
+
return { chatId, threadId: Number.isFinite(n) ? n : null }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Start the timer. Idempotent — second call is a no-op. Stash deps so
|
|
381
|
+
* tick() can find them. Honours the kill switch.
|
|
382
|
+
*/
|
|
383
|
+
export function startTimer(deps: SilencePokeDeps): void {
|
|
384
|
+
if (!silencePokeEnabled()) return
|
|
385
|
+
if (timer != null) return
|
|
386
|
+
activeDeps = deps
|
|
387
|
+
const poll = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
388
|
+
timer = setInterval(() => tick(Date.now()), poll)
|
|
389
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Stop the timer. Idempotent. */
|
|
393
|
+
export function stopTimer(): void {
|
|
394
|
+
if (timer != null) {
|
|
395
|
+
clearInterval(timer)
|
|
396
|
+
timer = null
|
|
397
|
+
}
|
|
398
|
+
activeDeps = null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Test-only: drive a single tick at a deterministic clock value. */
|
|
402
|
+
export function __tickForTests(now: number): void {
|
|
403
|
+
tick(now)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Test-only: install deps without starting the real timer. */
|
|
407
|
+
export function __setDepsForTests(deps: SilencePokeDeps | null): void {
|
|
408
|
+
activeDeps = deps
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Test-only: peek at state. */
|
|
412
|
+
export function __getStateForTests(key: string): SilencePokeState | undefined {
|
|
413
|
+
return state.get(key)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Test-only: full reset. */
|
|
417
|
+
export function __resetAllForTests(): void {
|
|
418
|
+
state.clear()
|
|
419
|
+
stopTimer()
|
|
420
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silent-end.ts — gateway-side state-file writer for the Stop hook.
|
|
3
|
+
*
|
|
4
|
+
* The Stop hook (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`)
|
|
5
|
+
* reads `$TELEGRAM_STATE_DIR/silent-end-pending.json` to decide whether
|
|
6
|
+
* to block-and-re-prompt or allow the session to end. Pre-#1122 PR3 the
|
|
7
|
+
* file was written from inside the progress-card driver's `onSilentEnd`
|
|
8
|
+
* callback. PR3 deleted the driver and accidentally removed the writer.
|
|
9
|
+
* The hook still ran on every Stop, but the file never appeared, so the
|
|
10
|
+
* hook always allowed the stop → users could ask a question, see 👀
|
|
11
|
+
* fire, and then get nothing back if the model failed to call `reply`.
|
|
12
|
+
*
|
|
13
|
+
* This module is the deterministic replacement. The gateway calls
|
|
14
|
+
* `writeSilentEndState(...)` when a fresh user-message turn ends with
|
|
15
|
+
* zero outbound messages, and `clearSilentEndState(...)` the moment a
|
|
16
|
+
* reply lands. The Stop hook reads the same file and makes its
|
|
17
|
+
* decision — no prompt dependency, no model behaviour required.
|
|
18
|
+
*
|
|
19
|
+
* Retry semantics: on first silent-end the hook blocks the stop with
|
|
20
|
+
* a re-prompt; on the second silent-end (retryCount >= MAX_RETRIES in
|
|
21
|
+
* the hook) the hook lets the session end. We inherit retryCount from
|
|
22
|
+
* any prior state file IFF the prior file's `turnKey` matches — a new
|
|
23
|
+
* turn always starts at retryCount=0.
|
|
24
|
+
*
|
|
25
|
+
* The state file is per-agent (each agent has its own
|
|
26
|
+
* TELEGRAM_STATE_DIR), so two agents going silent at the same time
|
|
27
|
+
* don't collide.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'
|
|
31
|
+
import { dirname, join } from 'node:path'
|
|
32
|
+
import { homedir } from 'node:os'
|
|
33
|
+
|
|
34
|
+
export interface SilentEndState {
|
|
35
|
+
/** The chat the silent turn was for — used by operator-facing diagnostics. */
|
|
36
|
+
chatId: string
|
|
37
|
+
/** Optional forum thread id, stringified or null. */
|
|
38
|
+
threadId: number | null
|
|
39
|
+
/** Stable identifier for the in-flight turn (statusKey shape). */
|
|
40
|
+
turnKey: string
|
|
41
|
+
/** Incremented each time the Stop hook blocks for this turn. */
|
|
42
|
+
retryCount: number
|
|
43
|
+
/** Wall-clock ms of last write. */
|
|
44
|
+
timestamp: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SilentEndDeps {
|
|
48
|
+
/** State dir root (defaults to `TELEGRAM_STATE_DIR` env). */
|
|
49
|
+
stateDir?: string
|
|
50
|
+
/** stderr writer (defaults to `process.stderr.write`). */
|
|
51
|
+
log?: (line: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveStateDir(deps?: SilentEndDeps): string {
|
|
55
|
+
if (deps?.stateDir != null) return deps.stateDir
|
|
56
|
+
const env = process.env.TELEGRAM_STATE_DIR
|
|
57
|
+
if (env != null && env !== '') return env
|
|
58
|
+
// Same fallback the gateway (`gateway.ts STATE_DIR`) and the Stop
|
|
59
|
+
// hook (`silent-end-interrupt-stop.mjs getStateDir`) already use.
|
|
60
|
+
// Discovered during UAT overnight 2026-05-13: test-harness ran
|
|
61
|
+
// without `TELEGRAM_STATE_DIR` set, so the writer returned null
|
|
62
|
+
// path → no state file ever appeared → hook always read "no
|
|
63
|
+
// silent-end pending" → silent-end recovery never engaged. The
|
|
64
|
+
// hook + writer have to agree on the path.
|
|
65
|
+
//
|
|
66
|
+
// Prefer `process.env.HOME` over `node:os` `homedir()` so the
|
|
67
|
+
// fallback is overridable in tests. Bun's `os.homedir()` reads
|
|
68
|
+
// the system home once at startup and ignores subsequent
|
|
69
|
+
// `process.env.HOME` mutations, which breaks the bun-test pass
|
|
70
|
+
// of `silent-end.test.ts` even though the vitest pass is fine
|
|
71
|
+
// (Node's `os.homedir()` documents `HOME` as the first source).
|
|
72
|
+
// In production both branches yield the same path — `HOME` is
|
|
73
|
+
// always set under the agent's tini-supervised process tree.
|
|
74
|
+
const home = process.env.HOME ?? homedir()
|
|
75
|
+
return join(home, '.claude', 'channels', 'telegram')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveStatePath(deps?: SilentEndDeps): string {
|
|
79
|
+
return join(resolveStateDir(deps), 'silent-end-pending.json')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emitLog(deps: SilentEndDeps | undefined, line: string): void {
|
|
83
|
+
if (deps?.log != null) deps.log(line)
|
|
84
|
+
else process.stderr.write(line)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write the silent-end state file for the given turn. Inherits
|
|
89
|
+
* retryCount from a prior write IFF the prior write's turnKey matches.
|
|
90
|
+
* Otherwise resets to 0.
|
|
91
|
+
*
|
|
92
|
+
* State path: `${TELEGRAM_STATE_DIR ?? ~/.claude/channels/telegram}/
|
|
93
|
+
* silent-end-pending.json` — exactly matching the path the Stop hook
|
|
94
|
+
* (silent-end-interrupt-stop.mjs) reads. The parent dir is created
|
|
95
|
+
* with `mkdir -p` if it doesn't exist (fresh-install case).
|
|
96
|
+
*/
|
|
97
|
+
export function writeSilentEndState(
|
|
98
|
+
args: { chatId: string; threadId: number | null; turnKey: string },
|
|
99
|
+
deps?: SilentEndDeps,
|
|
100
|
+
): void {
|
|
101
|
+
const statePath = resolveStatePath(deps)
|
|
102
|
+
let retryCount = 0
|
|
103
|
+
try {
|
|
104
|
+
if (existsSync(statePath)) {
|
|
105
|
+
const prev = JSON.parse(readFileSync(statePath, 'utf8')) as Partial<SilentEndState>
|
|
106
|
+
if (prev.turnKey === args.turnKey && typeof prev.retryCount === 'number') {
|
|
107
|
+
retryCount = prev.retryCount
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
retryCount = 0
|
|
112
|
+
}
|
|
113
|
+
const state: SilentEndState = {
|
|
114
|
+
chatId: args.chatId,
|
|
115
|
+
threadId: args.threadId,
|
|
116
|
+
turnKey: args.turnKey,
|
|
117
|
+
retryCount,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
// The fallback path may not exist on a fresh install — mkdir-p
|
|
122
|
+
// before writing. Cheap and idempotent. Without this the writer
|
|
123
|
+
// throws ENOENT in environments where the operator hasn't booted
|
|
124
|
+
// claude before (the dir is normally created by claude itself
|
|
125
|
+
// on first run).
|
|
126
|
+
mkdirSync(dirname(statePath), { recursive: true })
|
|
127
|
+
writeFileSync(statePath, JSON.stringify(state), 'utf8')
|
|
128
|
+
emitLog(
|
|
129
|
+
deps,
|
|
130
|
+
`silent-end: wrote state file turnKey=${args.turnKey} retryCount=${retryCount}\n`,
|
|
131
|
+
)
|
|
132
|
+
} catch (err) {
|
|
133
|
+
emitLog(
|
|
134
|
+
deps,
|
|
135
|
+
`silent-end: failed to write state file: ${(err as Error).message}\n`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Clear the silent-end state file IFF it belongs to the given turnKey.
|
|
142
|
+
* Called the moment a reply / stream_reply first-emit lands so the
|
|
143
|
+
* Stop hook doesn't fire a stale block on the next stop.
|
|
144
|
+
*
|
|
145
|
+
* Fail-silent: missing file, mismatched turnKey, or read/unlink errors
|
|
146
|
+
* are all benign. The Stop hook itself defends against stale files via
|
|
147
|
+
* the retryCount mechanism.
|
|
148
|
+
*/
|
|
149
|
+
export function clearSilentEndState(turnKey: string, deps?: SilentEndDeps): void {
|
|
150
|
+
const statePath = resolveStatePath(deps)
|
|
151
|
+
if (!existsSync(statePath)) return
|
|
152
|
+
try {
|
|
153
|
+
const prev = JSON.parse(readFileSync(statePath, 'utf8')) as Partial<SilentEndState>
|
|
154
|
+
if (prev.turnKey !== turnKey) return
|
|
155
|
+
unlinkSync(statePath)
|
|
156
|
+
emitLog(deps, `silent-end: cleared state file turnKey=${turnKey}\n`)
|
|
157
|
+
} catch {
|
|
158
|
+
// best-effort
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the state file (for tests + diagnostics). Returns null when
|
|
164
|
+
* absent or unparsable.
|
|
165
|
+
*/
|
|
166
|
+
export function readSilentEndState(deps?: SilentEndDeps): SilentEndState | null {
|
|
167
|
+
const statePath = resolveStatePath(deps)
|
|
168
|
+
if (!existsSync(statePath)) return null
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(readFileSync(statePath, 'utf8')) as SilentEndState
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -81,6 +81,13 @@ export interface StreamSendOpts {
|
|
|
81
81
|
* accept this parameter, so the controller omits it from edit opts.
|
|
82
82
|
*/
|
|
83
83
|
protect_content?: boolean
|
|
84
|
+
/**
|
|
85
|
+
* When true, the initial `sendMessage` is silent (no device ping).
|
|
86
|
+
* Has no effect on `editMessageText` — Telegram never pings on edits.
|
|
87
|
+
* Used by mid-turn `stream_reply` calls under the #1122 conversational
|
|
88
|
+
* pacing redesign so only the final answer pings.
|
|
89
|
+
*/
|
|
90
|
+
disable_notification?: boolean
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export type RetryPolicy = <T>(
|
|
@@ -113,6 +120,11 @@ export interface StreamControllerConfig {
|
|
|
113
120
|
* accept protect_content.
|
|
114
121
|
*/
|
|
115
122
|
protectContent?: boolean
|
|
123
|
+
/**
|
|
124
|
+
* When true, the initial `sendMessage` is silent (no device ping).
|
|
125
|
+
* editMessageText never pings regardless. Default false. #1122.
|
|
126
|
+
*/
|
|
127
|
+
disableNotification?: boolean
|
|
116
128
|
/**
|
|
117
129
|
* Inline keyboard markup attached to every send and edit. Without this,
|
|
118
130
|
* editMessageText strips any previously attached keyboard. The progress-
|
|
@@ -228,6 +240,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
228
240
|
}
|
|
229
241
|
: {}),
|
|
230
242
|
...(protectContent === true ? { protect_content: true } : {}),
|
|
243
|
+
...(cfg.disableNotification === true ? { disable_notification: true } : {}),
|
|
231
244
|
}
|
|
232
245
|
|
|
233
246
|
// Strip parse_mode from a copy of opts — used for the parse-entities
|
|
@@ -100,6 +100,12 @@ export interface StreamReplyArgs {
|
|
|
100
100
|
* Applied on the initial send only (editMessageText ignores it).
|
|
101
101
|
*/
|
|
102
102
|
protect_content?: boolean
|
|
103
|
+
/**
|
|
104
|
+
* When true, the INITIAL `sendMessage` is silent (no device ping).
|
|
105
|
+
* Edits never ping regardless. Used by mid-turn stream_reply calls
|
|
106
|
+
* under the #1122 conversational-pacing redesign. Default false.
|
|
107
|
+
*/
|
|
108
|
+
disable_notification?: boolean
|
|
103
109
|
/**
|
|
104
110
|
* Optional surgical quote text. When set along with `reply_to`, the initial
|
|
105
111
|
* send includes `reply_parameters: { message_id, quote: { text, position: 0 } }`
|
|
@@ -508,6 +514,7 @@ export async function handleStreamReply(
|
|
|
508
514
|
...(replyToMessageId != null ? { replyToMessageId } : {}),
|
|
509
515
|
...(args.quote_text != null && replyToMessageId != null ? { quoteText: args.quote_text } : {}),
|
|
510
516
|
...(args.protect_content === true ? { protectContent: true } : {}),
|
|
517
|
+
...(args.disable_notification === true ? { disableNotification: true } : {}),
|
|
511
518
|
...(args.reply_markup != null ? { replyMarkup: args.reply_markup } : {}),
|
|
512
519
|
previewTransport: resolvedTransport,
|
|
513
520
|
isPrivateChat: deps.isPrivateChat === true,
|