switchroom 0.13.33 → 0.13.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/timezone-hook.sh +1 -1
  2. package/dist/agent-scheduler/index.js +8 -1
  3. package/dist/auth-broker/index.js +8 -1
  4. package/dist/cli/switchroom.js +176 -26
  5. package/dist/host-control/main.js +5222 -203
  6. package/dist/vault/approvals/kernel-server.js +9 -2
  7. package/dist/vault/broker/server.js +9 -2
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +1 -1
  10. package/telegram-plugin/dist/gateway/gateway.js +234 -31
  11. package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
  12. package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
  13. package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
  14. package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
  15. package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
  16. package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
  17. package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
  18. package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
  19. package/telegram-plugin/gateway/gateway.ts +112 -15
  20. package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
  21. package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
  22. package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
  24. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
  25. package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
  26. package/telegram-plugin/pending-work-progress.ts +37 -1
  27. package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
  28. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
  29. package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
  30. package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
  33. package/telegram-plugin/tests/silent-end.test.ts +227 -38
  34. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
@@ -10948,7 +10948,7 @@ var init_dist = __esm(() => {
10948
10948
  });
10949
10949
 
10950
10950
  // src/config/schema.ts
10951
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10951
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10952
10952
  var init_schema = __esm(() => {
10953
10953
  init_zod();
10954
10954
  CodeRepoEntrySchema = exports_external.object({
@@ -11294,8 +11294,15 @@ var init_schema = __esm(() => {
11294
11294
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
11295
11295
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
11296
11296
  });
11297
+ AutoReleaseCheckSchema = exports_external.object({
11298
+ enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
11299
+ interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
11300
+ apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
11301
+ image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
11302
+ });
11297
11303
  HostControlConfigSchema = exports_external.object({
11298
- enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11304
+ enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
11305
+ auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
11299
11306
  });
11300
11307
  HostdConfigSchema = exports_external.object({
11301
11308
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
@@ -10948,7 +10948,7 @@ var init_zod = __esm(() => {
10948
10948
  });
10949
10949
 
10950
10950
  // src/config/schema.ts
10951
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10951
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
10952
10952
  var init_schema = __esm(() => {
10953
10953
  init_zod();
10954
10954
  CodeRepoEntrySchema = exports_external.object({
@@ -11294,8 +11294,15 @@ var init_schema = __esm(() => {
11294
11294
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
11295
11295
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
11296
11296
  });
11297
+ AutoReleaseCheckSchema = exports_external.object({
11298
+ enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
11299
+ interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
11300
+ apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
11301
+ image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
11302
+ });
11297
11303
  HostControlConfigSchema = exports_external.object({
11298
- enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11304
+ enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
11305
+ auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
11299
11306
  });
11300
11307
  HostdConfigSchema = exports_external.object({
11301
11308
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.33",
3
+ "version": "0.13.36",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -108,7 +108,7 @@ If no sub-agents are configured, do the work yourself.
108
108
 
109
109
  By default, every restart starts a **fresh `claude` session** — the in-flight transcript is NOT carried over (`session_continuity.resume_mode: handoff`, the default since switchroom #362). Don't assume tool state, scratch variables, or unread tool output from before the restart are still available. What does survive:
110
110
 
111
- - **Handoff briefing** — on a clean shutdown, the Stop hook writes a bounded raw transcript tail of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you can reorient — read it, and lean on your memory files for anything older. If the prior session crashed before the Stop hook fired, a live briefing is assembled from recent Telegram messages, Hindsight recall, and today's daily memory file (`.handoff-briefing.md`).
111
+ - **Handoff briefing** — on a clean shutdown, the Stop hook writes a bounded raw transcript tail of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you can reorient — read it, and lean on your memory files for anything older. If `.handoff.md` is missing or stale (fresh agent, or pre-Stop-hook crash), `start.sh` runs `handoff-briefing.sh` to assemble `.handoff-briefing.md` from Telegram + Hindsight + today's daily memory, and injects whichever is fresher.
112
112
  - **Hindsight memory** — auto-recall fires on every inbound user message and surfaces relevant memories from past sessions. Long-term facts, decisions, and mental models live here, not in the transcript.
113
113
  - **Telegram history** — the gateway's SQLite buffer remembers every inbound/outbound message. Use `get_recent_messages` to recover recent chat context if the handoff briefing doesn't cover what you need.
114
114
  - **`SWITCHROOM_PENDING_TURN`** — if your previous session was killed mid-turn (watchdog, SIGTERM, timeout), start.sh exports this env var plus the chat/thread/last-user-message context. Acknowledge the interruption and ask for direction rather than silently resuming.
@@ -23592,7 +23592,7 @@ var init_dist = __esm(() => {
23592
23592
  });
23593
23593
 
23594
23594
  // ../src/config/schema.ts
23595
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23595
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23596
23596
  var init_schema = __esm(() => {
23597
23597
  init_zod();
23598
23598
  CodeRepoEntrySchema = exports_external.object({
@@ -23938,8 +23938,15 @@ var init_schema = __esm(() => {
23938
23938
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
23939
23939
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
23940
23940
  });
23941
+ AutoReleaseCheckSchema = exports_external.object({
23942
+ enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false \u2014 opt-in."),
23943
+ interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
23944
+ apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
23945
+ image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
23946
+ });
23941
23947
  HostControlConfigSchema = exports_external.object({
23942
- enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
23948
+ enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
23949
+ auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
23943
23950
  });
23944
23951
  HostdConfigSchema = exports_external.object({
23945
23952
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
@@ -25285,6 +25292,39 @@ function parseDriveScope(scope) {
25285
25292
  return { action, target: { kind: "doc", doc_id: rest } };
25286
25293
  }
25287
25294
 
25295
+ // gateway/oversize-card-body.ts
25296
+ function truncateRawToFit(input) {
25297
+ const { raw, render, cap, sentinel } = input;
25298
+ const hardLimit = input.hardLimit ?? cap + 196;
25299
+ const fullBody = render(raw);
25300
+ if (fullBody.length <= cap) {
25301
+ return { body: fullBody, truncated: false };
25302
+ }
25303
+ let lo = 0;
25304
+ let hi = raw.length;
25305
+ let bestSliceLen = 0;
25306
+ while (lo <= hi) {
25307
+ const mid = lo + hi >>> 1;
25308
+ const candidate = raw.slice(0, mid) + sentinel;
25309
+ if (render(candidate).length <= cap) {
25310
+ bestSliceLen = mid;
25311
+ lo = mid + 1;
25312
+ } else {
25313
+ hi = mid - 1;
25314
+ }
25315
+ }
25316
+ let chosenRaw = raw.slice(0, bestSliceLen);
25317
+ const lastNl = chosenRaw.lastIndexOf(`
25318
+ `);
25319
+ if (lastNl > 0)
25320
+ chosenRaw = chosenRaw.slice(0, lastNl);
25321
+ let body = render(chosenRaw + sentinel);
25322
+ if (body.length > hardLimit) {
25323
+ body = body.slice(0, hardLimit - 1);
25324
+ }
25325
+ return { body, truncated: true };
25326
+ }
25327
+
25288
25328
  // secret-detect/suppressor.ts
25289
25329
  function isSuppressed(text, start, end) {
25290
25330
  const left = Math.max(0, start - WINDOW);
@@ -29053,49 +29093,90 @@ var init_materialize_bot_token = __esm(() => {
29053
29093
  // gateway/config-approval-handler.ts
29054
29094
  var exports_config_approval_handler = {};
29055
29095
  __export(exports_config_approval_handler, {
29096
+ truncateDiffForCard: () => truncateDiffForCard,
29056
29097
  resolvePendingConfigApproval: () => resolvePendingConfigApproval,
29057
29098
  parseConfigApprovalCallback: () => parseConfigApprovalCallback,
29058
29099
  handleRequestConfigFinalize: () => handleRequestConfigFinalize,
29059
29100
  handleRequestConfigApproval: () => handleRequestConfigApproval,
29060
29101
  buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
29061
29102
  _resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
29062
- _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest
29103
+ _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
29104
+ DIFF_SENTINEL: () => DIFF_SENTINEL
29063
29105
  });
29106
+ function truncateDiffForCard(unifiedDiff, maxLines = 50, maxChars = 3000) {
29107
+ const sentinel = `
29108
+ [\u2026 diff continues, see attached file]`;
29109
+ const lines = unifiedDiff.split(`
29110
+ `);
29111
+ let out;
29112
+ if (lines.length <= maxLines) {
29113
+ out = unifiedDiff;
29114
+ } else {
29115
+ out = lines.slice(0, maxLines).join(`
29116
+ `);
29117
+ }
29118
+ if (out.length > maxChars) {
29119
+ const cap = out.slice(0, maxChars);
29120
+ const lastNl = cap.lastIndexOf(`
29121
+ `);
29122
+ out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
29123
+ }
29124
+ return out === unifiedDiff ? out : out + sentinel;
29125
+ }
29126
+ function escapeHtml11(s) {
29127
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29128
+ }
29129
+ function clipReason(reason) {
29130
+ if (reason.length <= REASON_MAX_CHARS)
29131
+ return reason;
29132
+ return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) + REASON_ELLIPSIS;
29133
+ }
29064
29134
  function buildConfigApprovalCardBody(args) {
29065
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29066
- return `\uD83D\uDEE0 <b>Config edit proposed</b>
29067
- ` + `Agent: <code>${esc(args.agentName)}</code>
29068
- ` + `Reason: ${esc(args.reason)}
29069
-
29070
- ` + `<pre>${esc(args.unifiedDiff)}</pre>`;
29135
+ const safeReason = clipReason(args.reason);
29136
+ const render = (diff) => `\uD83D\uDEE0 <b>Config edit proposed</b>
29137
+ ` + `Agent: <code>${escapeHtml11(args.agentName)}</code>
29138
+ ` + `Reason: ${escapeHtml11(safeReason)}
29139
+
29140
+ ` + `<pre>${escapeHtml11(diff)}</pre>`;
29141
+ return truncateRawToFit({
29142
+ raw: args.unifiedDiff,
29143
+ render,
29144
+ cap: RENDERED_BODY_CAP2,
29145
+ sentinel: DIFF_SENTINEL,
29146
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT2
29147
+ });
29071
29148
  }
29072
29149
  async function handleRequestConfigApproval(client3, msg, deps) {
29073
- const reply = (verdict, reason) => {
29150
+ const reply = (verdict, reason, denySource) => {
29074
29151
  try {
29075
29152
  client3.send({
29076
29153
  type: "config_approval_resolved",
29077
29154
  requestId: msg.requestId,
29078
29155
  verdict,
29079
- ...reason ? { reason } : {}
29156
+ ...reason ? { reason } : {},
29157
+ ...denySource ? { denySource } : {}
29080
29158
  });
29081
29159
  } catch (err) {
29082
29160
  deps.log?.(`config_approval_resolved send failed (requestId=${msg.requestId}): ${err.message}`);
29083
29161
  }
29084
29162
  };
29085
29163
  if (msg.agentName !== deps.agentName) {
29086
- reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
29164
+ reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`, "dispatch_failure");
29087
29165
  return;
29088
29166
  }
29089
29167
  const target = deps.loadTargetChat();
29090
29168
  if (target === null) {
29091
- reply("deny", "no target chat available \u2014 operator not paired?");
29169
+ reply("deny", "no target chat available \u2014 operator not paired?", "dispatch_failure");
29092
29170
  return;
29093
29171
  }
29094
- const body = buildConfigApprovalCardBody({
29172
+ const prelim = truncateDiffForCard(msg.unifiedDiff);
29173
+ const built = buildConfigApprovalCardBody({
29095
29174
  agentName: msg.agentName,
29096
29175
  reason: msg.reason,
29097
- unifiedDiff: msg.unifiedDiff
29176
+ unifiedDiff: prelim
29098
29177
  });
29178
+ const body = built.body;
29179
+ const oversize = prelim !== msg.unifiedDiff || built.truncated;
29099
29180
  const replyMarkup = deps.buildKeyboard(msg.requestId);
29100
29181
  const posted = await deps.postCard({
29101
29182
  chatId: target.chatId,
@@ -29104,9 +29185,12 @@ async function handleRequestConfigApproval(client3, msg, deps) {
29104
29185
  replyMarkup
29105
29186
  });
29106
29187
  if (posted === null) {
29107
- reply("deny", "Telegram sendMessage failed");
29188
+ reply("deny", "Telegram sendMessage failed", "dispatch_failure");
29108
29189
  return;
29109
29190
  }
29191
+ if (oversize) {
29192
+ await maybePostAttachment(deps, target, msg);
29193
+ }
29110
29194
  const entry = {
29111
29195
  requestId: msg.requestId,
29112
29196
  client: client3,
@@ -29172,8 +29256,21 @@ ${escapeHtml11(msg.detail)}` : ""}`;
29172
29256
  deps.log?.(`config finalize card edit failed (requestId=${msg.requestId}): ${err.message}`);
29173
29257
  }
29174
29258
  }
29175
- function escapeHtml11(s) {
29176
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29259
+ async function maybePostAttachment(deps, target, msg) {
29260
+ if (deps.postAttachment === undefined) {
29261
+ deps.log?.(`oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`);
29262
+ return;
29263
+ }
29264
+ try {
29265
+ await deps.postAttachment({
29266
+ chatId: target.chatId,
29267
+ ...target.threadId !== undefined ? { threadId: target.threadId } : {},
29268
+ filename: `config-edit-${msg.requestId}.patch`,
29269
+ content: msg.unifiedDiff
29270
+ });
29271
+ } catch (err) {
29272
+ deps.log?.(`config approval attachment failed (requestId=${msg.requestId}): ${err.message}`);
29273
+ }
29177
29274
  }
29178
29275
  function _resetPendingConfigApprovalsForTest() {
29179
29276
  for (const entry of pending.values()) {
@@ -29200,7 +29297,8 @@ function parseConfigApprovalCallback(data) {
29200
29297
  return null;
29201
29298
  return { requestId, choice };
29202
29299
  }
29203
- var pending;
29300
+ var pending, TELEGRAM_SENDMESSAGE_LIMIT2 = 4096, RENDERED_BODY_CAP2 = 3900, REASON_MAX_CHARS = 500, REASON_ELLIPSIS = "\u2026", DIFF_SENTINEL = `
29301
+ [\u2026 diff continues, see attached file]`;
29204
29302
  var init_config_approval_handler = __esm(() => {
29205
29303
  pending = new Map;
29206
29304
  });
@@ -37484,6 +37582,10 @@ function tick2(now) {
37484
37582
  clearPending(key, "timeout");
37485
37583
  continue;
37486
37584
  }
37585
+ if (activeDeps2.isActiveTurnNewerThan != null && activeDeps2.isActiveTurnNewerThan(key, s.activatedAt)) {
37586
+ clearPending(key, "stale_turn");
37587
+ continue;
37588
+ }
37487
37589
  const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt;
37488
37590
  if (sinceEdit < EDIT_INTERVAL_MS)
37489
37591
  continue;
@@ -43307,6 +43409,45 @@ var RequestSchema3 = exports_external.discriminatedUnion("op", [
43307
43409
  ConfigProposeEditRequestSchema
43308
43410
  ]);
43309
43411
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
43412
+ var ErrorFixSchema = exports_external.discriminatedUnion("kind", [
43413
+ exports_external.object({
43414
+ kind: exports_external.literal("flip_yaml_flag"),
43415
+ yaml_path: exports_external.string(),
43416
+ to: exports_external.unknown()
43417
+ }),
43418
+ exports_external.object({
43419
+ kind: exports_external.literal("request_vault_grant"),
43420
+ vault_key: exports_external.string()
43421
+ }),
43422
+ exports_external.object({
43423
+ kind: exports_external.literal("operator_action"),
43424
+ subkind: exports_external.enum(["policy_denied", "infra", "out_of_scope"]),
43425
+ operator_steps: exports_external.array(exports_external.string()).min(1).optional()
43426
+ }),
43427
+ exports_external.object({
43428
+ kind: exports_external.literal("retry_after"),
43429
+ retry_at: exports_external.string()
43430
+ }),
43431
+ exports_external.object({
43432
+ kind: exports_external.literal("quota_exceeded"),
43433
+ quota: exports_external.string(),
43434
+ current: exports_external.number(),
43435
+ limit: exports_external.number()
43436
+ }),
43437
+ exports_external.object({
43438
+ kind: exports_external.literal("bad_input"),
43439
+ field: exports_external.string().optional()
43440
+ })
43441
+ ]);
43442
+ var ErrorEnvelopeSchema = exports_external.object({
43443
+ v: exports_external.literal(1),
43444
+ code: exports_external.string().regex(/^(E_[A-Z0-9_]+|VAULT-[A-Z-]+)$/),
43445
+ human: exports_external.string().min(1),
43446
+ why: exports_external.string().optional(),
43447
+ fix: ErrorFixSchema.optional(),
43448
+ docs: exports_external.string().url().optional(),
43449
+ request_id: exports_external.string().min(1)
43450
+ });
43310
43451
  var ResponseEnvelope = {
43311
43452
  v: exports_external.literal(1),
43312
43453
  request_id: exports_external.string().min(1).max(128),
@@ -43316,7 +43457,8 @@ var ResponseEnvelope = {
43316
43457
  audit_id: exports_external.string().min(1).optional(),
43317
43458
  stdout_tail: exports_external.string().optional(),
43318
43459
  stderr_tail: exports_external.string().optional(),
43319
- error: exports_external.string().optional()
43460
+ error: exports_external.string().optional(),
43461
+ error_envelope: ErrorEnvelopeSchema.optional()
43320
43462
  };
43321
43463
  var ResponseSchema3 = exports_external.object(ResponseEnvelope);
43322
43464
  function encodeRequest3(req) {
@@ -44076,6 +44218,10 @@ function validateInput(input) {
44076
44218
  var DEFAULT_TTL_MS = 5 * 60 * 1000;
44077
44219
  var MAX_TTL_MS = 30 * 60 * 1000;
44078
44220
  var MIN_TTL_MS = 30 * 1000;
44221
+ var TELEGRAM_SENDMESSAGE_LIMIT = 4096;
44222
+ var RENDERED_BODY_CAP = 3900;
44223
+ var OVERSIZE_SENTINEL = `
44224
+ [\u2026 preview truncated; open in Drive for full context]`;
44079
44225
  async function handleRequestDriveApproval(client3, msg, deps) {
44080
44226
  const reply = (event) => {
44081
44227
  try {
@@ -44151,20 +44297,36 @@ async function handleRequestDriveApproval(client3, msg, deps) {
44151
44297
  });
44152
44298
  return;
44153
44299
  }
44300
+ let cardText = card.text;
44301
+ let truncatedForFit = false;
44302
+ if (cardText.length > RENDERED_BODY_CAP) {
44303
+ const fit = truncateRawToFit({
44304
+ raw: card.text,
44305
+ render: (slice) => slice,
44306
+ cap: RENDERED_BODY_CAP,
44307
+ sentinel: OVERSIZE_SENTINEL,
44308
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT
44309
+ });
44310
+ cardText = fit.body;
44311
+ truncatedForFit = fit.truncated;
44312
+ }
44154
44313
  const posted = await deps.postCard({
44155
44314
  chatId: target.chatId,
44156
44315
  ...target.threadId !== undefined ? { threadId: target.threadId } : {},
44157
- text: card.text,
44316
+ text: cardText,
44158
44317
  replyMarkup: card.reply_markup
44159
44318
  });
44160
44319
  if (posted === null) {
44161
44320
  reply({
44162
44321
  correlationId: msg.correlationId,
44163
44322
  ok: false,
44164
- reason: "Telegram sendMessage failed"
44323
+ reason: truncatedForFit ? "Telegram sendMessage failed even after oversize-body truncation" : "Telegram sendMessage failed"
44165
44324
  });
44166
44325
  return;
44167
44326
  }
44327
+ if (truncatedForFit) {
44328
+ deps.log?.(`drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`);
44329
+ }
44168
44330
  deps.log?.(`drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`);
44169
44331
  reply({
44170
44332
  correlationId: msg.correlationId,
@@ -44188,10 +44350,10 @@ var REQUEST_ID_RE = /^[0-9a-f]{32}$/;
44188
44350
  var PENDING_FILE_ID_SENTINEL = "pending-create";
44189
44351
  function buildDiffPreviewCard(input) {
44190
44352
  if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
44191
- throw new Error(`buildDiffPreviewCard: suggestRequestId must be 8 hex chars (got '${input.suggestRequestId}')`);
44353
+ throw new Error(`buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`);
44192
44354
  }
44193
44355
  if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
44194
- throw new Error(`buildDiffPreviewCard: writeRequestId must be 8 hex chars (got '${input.writeRequestId}')`);
44356
+ throw new Error(`buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`);
44195
44357
  }
44196
44358
  const preview = input.preview;
44197
44359
  const bodyLines = [];
@@ -45787,6 +45949,11 @@ function readCleanShutdownMarker(path) {
45787
45949
  return null;
45788
45950
  }
45789
45951
  }
45952
+ function clearCleanShutdownMarker(path) {
45953
+ try {
45954
+ unlinkSync8(path);
45955
+ } catch {}
45956
+ }
45790
45957
  function shouldSuppressRecoveryBanner(marker, now, maxAgeMs = DEFAULT_MAX_AGE_MS) {
45791
45958
  if (marker === null)
45792
45959
  return false;
@@ -48504,10 +48671,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48504
48671
  }
48505
48672
 
48506
48673
  // ../src/build-info.ts
48507
- var VERSION = "0.13.33";
48508
- var COMMIT_SHA = "a8018071";
48509
- var COMMIT_DATE = "2026-05-24T20:31:16Z";
48510
- var LATEST_PR = 1740;
48674
+ var VERSION = "0.13.36";
48675
+ var COMMIT_SHA = "73e8bb05";
48676
+ var COMMIT_DATE = "2026-05-25T03:53:49Z";
48677
+ var LATEST_PR = 1785;
48511
48678
  var COMMITS_AHEAD_OF_TAG = 0;
48512
48679
 
48513
48680
  // gateway/boot-version.ts
@@ -48555,9 +48722,17 @@ function composeBootVersionString(inputs) {
48555
48722
  var import_grammy6 = __toESM(require_mod2(), 1);
48556
48723
  function classifyRejection(err, opts = {}) {
48557
48724
  const isGrammy = opts.isGrammyError != null ? opts.isGrammyError(err) : err instanceof import_grammy6.GrammyError;
48725
+ const isHttp = opts.isHttpError != null ? opts.isHttpError(err) : err instanceof import_grammy6.HttpError;
48726
+ if (isHttp)
48727
+ return "log_only";
48558
48728
  if (!isGrammy)
48559
48729
  return "shutdown";
48560
48730
  const e = err;
48731
+ if (e.error_code === 429)
48732
+ return "log_only";
48733
+ if (typeof e.error_code === "number" && e.error_code >= 500 && e.error_code < 600) {
48734
+ return "log_only";
48735
+ }
48561
48736
  if (e.error_code !== 400)
48562
48737
  return "shutdown";
48563
48738
  const desc = (e.description ?? "").toLowerCase();
@@ -50405,7 +50580,11 @@ startTimer2({
50405
50580
  ...ctx.threadId != null ? { threadId: ctx.threadId } : {}
50406
50581
  });
50407
50582
  },
50408
- emitMetric: (event) => emitRuntimeMetric(event)
50583
+ emitMetric: (event) => emitRuntimeMetric(event),
50584
+ isActiveTurnNewerThan: (key, activatedAt) => {
50585
+ const turnStartedAt = activeTurnStartedAt.get(key);
50586
+ return turnStartedAt != null && turnStartedAt > activatedAt;
50587
+ }
50409
50588
  });
50410
50589
  var inboundSpool = STATIC ? undefined : createInboundSpool({
50411
50590
  path: join32(STATE_DIR, "inbound-spool.jsonl"),
@@ -50807,6 +50986,16 @@ ${reminder}
50807
50986
  `);
50808
50987
  }
50809
50988
  },
50989
+ postAttachment: async (args) => {
50990
+ const input = new import_grammy9.InputFile(Buffer.from(args.content, "utf8"), args.filename);
50991
+ await robustApiCall(() => bot.api.sendDocument(args.chatId, input, {
50992
+ ...args.threadId !== undefined ? { message_thread_id: args.threadId } : {}
50993
+ }), {
50994
+ chat_id: String(args.chatId),
50995
+ verb: "config-approval-attachment",
50996
+ ...args.threadId !== undefined ? { threadId: args.threadId } : {}
50997
+ });
50998
+ },
50810
50999
  log: (m) => process.stderr.write(`telegram gateway: config-approval \u2014 ${m}
50811
51000
  `)
50812
51001
  });
@@ -51122,7 +51311,9 @@ ${url}`;
51122
51311
  });
51123
51312
  noteOutbound(statusKey(chat_id, threadId), Date.now());
51124
51313
  noteOutbound2(statusKey(chat_id, threadId), Date.now());
51125
- clearSilentEndState(statusKey(chat_id, threadId));
51314
+ if (isFinalAnswerReply({ text: rawText, disableNotification })) {
51315
+ clearSilentEndState(statusKey(chat_id, threadId));
51316
+ }
51126
51317
  if (previewMessageId != null && reply_to != null && replyMode !== "off") {
51127
51318
  await deleteStalePreview(previewMessageId);
51128
51319
  previewMessageId = null;
@@ -51160,6 +51351,7 @@ ${url}`;
51160
51351
  logOutbound("edit", chat_id, decision.messageId, decision.mergedText.length, "silent-anchor-merge");
51161
51352
  process.stderr.write(`telegram gateway: silent-reply auto-edit \u2014 ` + `chat=${chat_id} anchor=${decision.messageId} merged_len=${decision.mergedText.length}
51162
51353
  `);
51354
+ clearPending(statusKey(chat_id, threadId), "reply_finalize");
51163
51355
  noteOutbound3(statusKey(chat_id, threadId), {
51164
51356
  messageId: decision.messageId,
51165
51357
  text: decision.mergedText,
@@ -51300,6 +51492,7 @@ ${url}`;
51300
51492
  if (sentIds.length === chunks.length && chunks.length > 0) {
51301
51493
  const anchorMsgId = sentIds[chunks.length - 1];
51302
51494
  if (typeof anchorMsgId === "number") {
51495
+ clearPending(statusKey(chat_id, threadId), "reply_finalize");
51303
51496
  noteOutbound3(statusKey(chat_id, threadId), {
51304
51497
  messageId: anchorMsgId,
51305
51498
  text: chunks[chunks.length - 1],
@@ -51445,7 +51638,13 @@ async function executeStreamReply(args) {
51445
51638
  const sKey = statusKey(streamChatId, streamThreadId);
51446
51639
  noteOutbound(sKey, Date.now());
51447
51640
  noteOutbound2(sKey, Date.now());
51448
- clearSilentEndState(sKey);
51641
+ if (isFinalAnswerReply({
51642
+ text: args.text ?? "",
51643
+ disableNotification: args.disable_notification === true,
51644
+ done: args.done === true
51645
+ })) {
51646
+ clearSilentEndState(sKey);
51647
+ }
51449
51648
  }
51450
51649
  }
51451
51650
  const result = await handleStreamReply({
@@ -51509,6 +51708,7 @@ async function executeStreamReply(args) {
51509
51708
  outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
51510
51709
  const streamFormat = args.format ?? (access.parseMode ?? "html");
51511
51710
  const streamParseMode = streamFormat === "html" ? "HTML" : streamFormat === "markdownv2" ? "MarkdownV2" : undefined;
51711
+ clearPending(statusKey(sChatId, sThreadId), "reply_finalize");
51512
51712
  noteOutbound3(statusKey(sChatId, sThreadId), {
51513
51713
  messageId: result.messageId,
51514
51714
  text: args.text,
@@ -51521,6 +51721,8 @@ async function executeStreamReply(args) {
51521
51721
  done: args.done === true
51522
51722
  })) {
51523
51723
  turn.finalAnswerDelivered = true;
51724
+ const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
51725
+ clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
51524
51726
  }
51525
51727
  {
51526
51728
  const sChat = args.chat_id;
@@ -57917,6 +58119,7 @@ var didOneTimeSetup = false;
57917
58119
  process.stderr.write(`telegram gateway: boot.clean_shutdown_marker_stale age=${ageSec}s signal=${cleanMarker.signal}${reasonTag}
57918
58120
  `);
57919
58121
  }
58122
+ clearCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH);
57920
58123
  }
57921
58124
  if (marker) {
57922
58125
  const ageMs = nowMs2 - marker.ts;
@@ -247,3 +247,43 @@ For posterity:
247
247
  - **v2 rewrite** (this PR series, also numbered #553): same Phase 3
248
248
  harness, plus three v2 helpers; new spec test file pins the
249
249
  three-class contract.
250
+
251
+
252
+ ## Silent-end contract (#1122 / #1161 / #1664 / #1741 / #1744)
253
+
254
+ The "silent-end" safety net catches turns that end without the agent delivering a final answer via `reply` / `stream_reply`. The hook re-prompts once; on the second consecutive silent end it gives up and sends a fallback message so the turn never just vanishes.
255
+
256
+ ### What it is
257
+
258
+ A per-agent state file (`$TELEGRAM_STATE_DIR/silent-end-pending.json`, fallback `~/.claude/channels/telegram/silent-end-pending.json`) acts as a one-bit handshake between the gateway and the Stop hook (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`). When the file exists, the Stop hook blocks the session stop and injects a re-prompt; when absent, the stop is allowed.
259
+
260
+ ### When the file is WRITTEN
261
+
262
+ The gateway writes the file at `turn_end` if and only if `turn.finalAnswerDelivered === false` — i.e. no `reply` or `stream_reply` call during this turn passed the `isFinalAnswerReply` predicate. See `gateway.ts` around L7267 (`recordUndeliveredTurnEnd`). The write is idempotent across re-prompt rounds: the same turnKey inherits the prior `retryCount`; a different turnKey resets to 0.
263
+
264
+ ### When the file is CLEARED
265
+
266
+ The gateway calls `clearSilentEndState(turnKey)` on every reply that qualifies as the final answer. Three call sites:
267
+
268
+ 1. `executeReply` (gateway.ts L4599-4611) — fires on every `reply` tool call. Clears iff `isFinalAnswerReply` returns true.
269
+ 2. `executeStreamReply` first-emit branch (gateway.ts L5172-5195) — fires only on the FIRST emit per stream (gated by `!activeDraftStreams.has(sKey)`). Clears iff that first emit qualifies as final.
270
+ 3. `executeStreamReply` final-answer site (gateway.ts L5335-5358, added in #1744 follow-up) — fires on every emit that qualifies as final, regardless of whether it is the first emit. This is the load-bearing site for streams whose first emit was ack-shaped but whose later emit (typically `done=true`) carries the real answer. Without site 3, such streams leak the state file.
271
+
272
+ The clear is fail-silent and turnKey-keyed: a clear for turnKey A does NOT unlink a state file written for turnKey B (see `silent-end.ts clearSilentEndState`). This makes calling it unconditionally on every final-answer emit safe.
273
+
274
+ ### The gate predicate
275
+
276
+ A reply qualifies as the final answer when `isFinalAnswerReply` returns true. The predicate (in `final-answer-detect.ts`) is a logical OR of three signals:
277
+
278
+ - `done === true` (stream_reply terminal call), OR
279
+ - `disableNotification === false` (pacing-contract final-answer signal), OR
280
+ - `text.length >= 200` (length backstop for substantive replies mis-marked as interim).
281
+
282
+ A reply with `disable_notification: true`, short text, and no `done` is an interim ack — it never clears the state.
283
+
284
+ ### Send sites and tests
285
+
286
+ - `executeReply` (gateway.ts:4340) — single send site, single clear (site 1 above).
287
+ - `executeStreamReply` (gateway.ts:5089) — two clear sites (sites 2 and 3 above) to cover both the first-emit and the later-emit final-answer paths.
288
+ - Unit coverage: `tests/silent-end.test.ts` (`#1741` block) — the executeReply gate as a unit.
289
+ - Integration coverage: `tests/silent-end-integration.test.ts` (#1744) — the stream first-emit-vs-later-emit branching, with the ack-then-final regression case pinned.