switchroom 0.13.2 → 0.13.4

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 (69) hide show
  1. package/dist/agent-scheduler/index.js +2 -2
  2. package/dist/auth-broker/index.js +2 -2
  3. package/dist/cli/switchroom.js +132 -214
  4. package/dist/host-control/main.js +2 -2
  5. package/dist/vault/approvals/kernel-server.js +2 -2
  6. package/dist/vault/broker/server.js +2 -2
  7. package/package.json +1 -1
  8. package/profiles/_base/start.sh.hbs +8 -8
  9. package/profiles/default/CLAUDE.md.hbs +1 -1
  10. package/telegram-plugin/dist/gateway/gateway.js +42 -10
  11. package/telegram-plugin/gateway/boot-probes.ts +13 -6
  12. package/telegram-plugin/gateway/gateway.ts +44 -6
  13. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +5 -1
  14. package/telegram-plugin/silent-end.ts +56 -0
  15. package/telegram-plugin/tests/boot-probes.test.ts +26 -2
  16. package/telegram-plugin/tests/silent-end.test.ts +69 -0
  17. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +166 -0
  18. package/skills/buildkite-agent-infrastructure/SKILL.md +0 -321
  19. package/skills/buildkite-agent-infrastructure/agents/openai.yaml +0 -6
  20. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-large.png +0 -0
  21. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-small.png +0 -0
  22. package/skills/buildkite-agent-infrastructure/references/audit-logging.md +0 -87
  23. package/skills/buildkite-agent-infrastructure/references/graphql-mutations.md +0 -690
  24. package/skills/buildkite-agent-infrastructure/references/instance-shapes.md +0 -38
  25. package/skills/buildkite-agent-infrastructure/references/pipeline-templates.md +0 -73
  26. package/skills/buildkite-agent-infrastructure/references/self-hosted-agents.md +0 -137
  27. package/skills/buildkite-agent-infrastructure/references/sso-saml.md +0 -92
  28. package/skills/buildkite-agent-runtime/SKILL.md +0 -509
  29. package/skills/buildkite-agent-runtime/agents/openai.yaml +0 -6
  30. package/skills/buildkite-agent-runtime/assets/buildkite-icon-large.png +0 -0
  31. package/skills/buildkite-agent-runtime/assets/buildkite-icon-small.png +0 -0
  32. package/skills/buildkite-agent-runtime/references/flag-reference.md +0 -417
  33. package/skills/buildkite-agent-runtime/references/patterns-and-recipes.md +0 -555
  34. package/skills/buildkite-api/SKILL.md +0 -308
  35. package/skills/buildkite-api/agents/openai.yaml +0 -6
  36. package/skills/buildkite-api/assets/buildkite-icon-large.png +0 -0
  37. package/skills/buildkite-api/assets/buildkite-icon-small.png +0 -0
  38. package/skills/buildkite-api/references/graphql-reference.md +0 -195
  39. package/skills/buildkite-api/references/patterns.md +0 -44
  40. package/skills/buildkite-api/references/webhooks.md +0 -161
  41. package/skills/buildkite-cli/SKILL.md +0 -397
  42. package/skills/buildkite-cli/agents/openai.yaml +0 -6
  43. package/skills/buildkite-cli/assets/buildkite-icon-large.png +0 -0
  44. package/skills/buildkite-cli/assets/buildkite-icon-small.png +0 -0
  45. package/skills/buildkite-cli/references/command-reference.md +0 -181
  46. package/skills/buildkite-migration/SKILL.md +0 -195
  47. package/skills/buildkite-pipelines/SKILL.md +0 -481
  48. package/skills/buildkite-pipelines/agents/openai.yaml +0 -6
  49. package/skills/buildkite-pipelines/assets/buildkite-icon-large.png +0 -0
  50. package/skills/buildkite-pipelines/assets/buildkite-icon-small.png +0 -0
  51. package/skills/buildkite-pipelines/examples/basic-pipeline.yml +0 -24
  52. package/skills/buildkite-pipelines/examples/optimized-pipeline.yml +0 -100
  53. package/skills/buildkite-pipelines/references/advanced-patterns.md +0 -286
  54. package/skills/buildkite-pipelines/references/retry-and-error-codes.md +0 -131
  55. package/skills/buildkite-pipelines/references/step-types-reference.md +0 -225
  56. package/skills/buildkite-secure-delivery/SKILL.md +0 -182
  57. package/skills/buildkite-secure-delivery/agents/openai.yaml +0 -6
  58. package/skills/buildkite-secure-delivery/assets/buildkite-icon-large.png +0 -0
  59. package/skills/buildkite-secure-delivery/assets/buildkite-icon-small.png +0 -0
  60. package/skills/buildkite-secure-delivery/references/oidc-cloud-providers.md +0 -83
  61. package/skills/buildkite-secure-delivery/references/package-publishing.md +0 -100
  62. package/skills/buildkite-test-engine/SKILL.md +0 -256
  63. package/skills/buildkite-test-engine/agents/openai.yaml +0 -6
  64. package/skills/buildkite-test-engine/assets/buildkite-icon-large.png +0 -0
  65. package/skills/buildkite-test-engine/assets/buildkite-icon-small.png +0 -0
  66. package/skills/buildkite-test-engine/examples/bktec-splitting.yml +0 -16
  67. package/skills/buildkite-test-engine/examples/collector-pipeline.yml +0 -11
  68. package/skills/buildkite-test-engine/references/collectors.md +0 -198
  69. package/skills/buildkite-test-engine/references/splitting-examples.md +0 -93
@@ -13863,7 +13863,7 @@ var profileFields = {
13863
13863
  extends: exports_external.string().optional(),
13864
13864
  bot_token: exports_external.string().optional(),
13865
13865
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
13866
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
13866
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
13867
13867
  soul: exports_external.object({
13868
13868
  name: exports_external.string().optional(),
13869
13869
  style: exports_external.string().optional(),
@@ -13924,7 +13924,7 @@ var AgentSchema = exports_external.object({
13924
13924
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
13925
13925
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
13926
13926
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
13927
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
13927
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
13928
13928
  auth: exports_external.object({
13929
13929
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
13930
13930
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -11122,7 +11122,7 @@ var init_schema = __esm(() => {
11122
11122
  extends: exports_external.string().optional(),
11123
11123
  bot_token: exports_external.string().optional(),
11124
11124
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11125
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11125
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11126
11126
  soul: exports_external.object({
11127
11127
  name: exports_external.string().optional(),
11128
11128
  style: exports_external.string().optional(),
@@ -11183,7 +11183,7 @@ var init_schema = __esm(() => {
11183
11183
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11184
11184
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11185
11185
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11186
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11186
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11187
11187
  auth: exports_external.object({
11188
11188
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11189
11189
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -11122,7 +11122,7 @@ var init_schema = __esm(() => {
11122
11122
  extends: exports_external.string().optional(),
11123
11123
  bot_token: exports_external.string().optional(),
11124
11124
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11125
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11125
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11126
11126
  soul: exports_external.object({
11127
11127
  name: exports_external.string().optional(),
11128
11128
  style: exports_external.string().optional(),
@@ -11183,7 +11183,7 @@ var init_schema = __esm(() => {
11183
11183
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11184
11184
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11185
11185
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11186
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11186
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11187
11187
  auth: exports_external.object({
11188
11188
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11189
11189
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
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": {
@@ -422,13 +422,13 @@ mkdir -p "$TELEGRAM_STATE_DIR" 2>/dev/null || true
422
422
 
423
423
  {{#if handoffEnabled}}
424
424
  # --- Session handoff briefing ---
425
- # On a normal shutdown the Stop hook writes .handoff.md (compact
426
- # summary of the prior session) into the agent dir. Here we merge that
427
- # briefing into --append-system-prompt so the fresh session wakes up
428
- # already knowing what was going on. If the prior session crashed
429
- # without firing the Stop hook, we fall back to a synchronous
430
- # summarization capped at 2s — if that doesn't finish, skip gracefully
431
- # and rely on Hindsight's per-turn auto-recall.
425
+ # On a normal shutdown the Stop hook writes .handoff.md (a bounded
426
+ # raw transcript tail of the prior session see RFC #1620) into the
427
+ # agent dir. Here we merge that into --append-system-prompt so the
428
+ # fresh session wakes up able to reorient. If the prior session
429
+ # crashed without firing the Stop hook, we fall back to building the
430
+ # tail synchronously, capped at 2s — if that doesn't finish, skip
431
+ # gracefully and rely on Hindsight's per-turn auto-recall.
432
432
  #
433
433
  # In 'handoff' mode (the default as of #362), when no .handoff.md is
434
434
  # available from the Stop hook, we also run handoff-briefing.sh which
@@ -461,7 +461,7 @@ fi
461
461
  export SWITCHROOM_HANDOFF_SHOW_LINE={{#if handoffShowLine}}true{{else}}false{{/if}}
462
462
  APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuoted}}}{{else}}""{{/if}}
463
463
  # Inject .handoff-briefing.md first (assembled from live sources), then
464
- # .handoff.md (LLM-generated session summary from Stop hook). If both
464
+ # .handoff.md (raw transcript tail from the Stop hook). If both
465
465
  # exist, separate them with a divider so the agent sees both.
466
466
  if [ -s "$HANDOFF_BRIEFING_FILE" ]; then
467
467
  _BRIEFING_CONTENT=$(cat "$HANDOFF_BRIEFING_FILE")
@@ -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 compact summary of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you wake up already knowing what was going on. 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 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`).
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.
@@ -23766,7 +23766,7 @@ var init_schema = __esm(() => {
23766
23766
  extends: exports_external.string().optional(),
23767
23767
  bot_token: exports_external.string().optional(),
23768
23768
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) \u2014 mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
23769
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
23769
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
23770
23770
  soul: exports_external.object({
23771
23771
  name: exports_external.string().optional(),
23772
23772
  style: exports_external.string().optional(),
@@ -23827,7 +23827,7 @@ var init_schema = __esm(() => {
23827
23827
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
23828
23828
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) \u2014 a pinned agent does " + "not inherit the fleet channel, and vice versa."),
23829
23829
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
23830
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
23830
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
23831
23831
  auth: exports_external.object({
23832
23832
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only \u2014 " + "this agent talks to the named account regardless of fleet active. See RFC H \u00a74.5.")
23833
23833
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -27542,16 +27542,17 @@ function uptimeMsForStarttime(starttimeTicks, fs2 = realProcFs) {
27542
27542
  }
27543
27543
  }
27544
27544
  function nextStepForAgentState(agentName3, state4) {
27545
+ const tailCmd = process.env.SWITCHROOM_RUNTIME === "docker" ? `docker logs --tail 100 switchroom-${agentName3}` : `journalctl --user -u switchroom-${agentName3} -n 100`;
27545
27546
  if (state4 === "failed") {
27546
- return `Service failed \u2014 inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\` then \`switchroom agent restart ${agentName3}\``;
27547
+ return `Service failed \u2014 inspect with \`${tailCmd}\` then \`switchroom agent restart ${agentName3}\``;
27547
27548
  }
27548
27549
  if (state4 === "inactive") {
27549
- return `Service inactive \u2014 start with \`switchroom agent start ${agentName3}\` (or \`systemctl --user start switchroom-${agentName3}\`)`;
27550
+ return `Service inactive \u2014 start with \`switchroom agent start ${agentName3}\``;
27550
27551
  }
27551
27552
  if (state4 === "deactivating" || state4 === "activating" || state4 === "auto-restart") {
27552
27553
  return `Service is in a transient \`${state4}\` state \u2014 re-check with \`switchroom agent status ${agentName3}\` in a few seconds`;
27553
27554
  }
27554
- return `Inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\``;
27555
+ return `Inspect with \`${tailCmd}\``;
27555
27556
  }
27556
27557
  function probeAgentProcessDocker() {
27557
27558
  const found = findAgentProcessInContainer();
@@ -37121,6 +37122,7 @@ function startTimer(deps) {
37121
37122
  import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "node:fs";
37122
37123
  import { dirname as dirname6, join as join6 } from "node:path";
37123
37124
  import { homedir as homedir2 } from "node:os";
37125
+ var SILENT_END_MAX_RETRIES = 1;
37124
37126
  function resolveStateDir(deps) {
37125
37127
  if (deps?.stateDir != null)
37126
37128
  return deps.stateDir;
@@ -37182,6 +37184,27 @@ function clearSilentEndState(turnKey, deps) {
37182
37184
  `);
37183
37185
  } catch {}
37184
37186
  }
37187
+ function readSilentEndState(deps) {
37188
+ const statePath = resolveStatePath(deps);
37189
+ if (!existsSync5(statePath))
37190
+ return null;
37191
+ try {
37192
+ return JSON.parse(readFileSync3(statePath, "utf8"));
37193
+ } catch {
37194
+ return null;
37195
+ }
37196
+ }
37197
+ function recordSilentTurnEnd(args, deps) {
37198
+ const prev = readSilentEndState(deps);
37199
+ if (prev != null && prev.turnKey === args.turnKey && prev.retryCount >= SILENT_END_MAX_RETRIES) {
37200
+ clearSilentEndState(args.turnKey, deps);
37201
+ emitLog(deps, `silent-end: re-prompt exhausted for turnKey=${args.turnKey} ` + `(retryCount=${prev.retryCount} >= ${SILENT_END_MAX_RETRIES}) \u2014 ` + `caller should deliver a fallback
37202
+ `);
37203
+ return { exhausted: true };
37204
+ }
37205
+ writeSilentEndState(args, deps);
37206
+ return { exhausted: false };
37207
+ }
37185
37208
 
37186
37209
  // turn-flush-safety.ts
37187
37210
  var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
@@ -47679,10 +47702,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47679
47702
  }
47680
47703
 
47681
47704
  // ../src/build-info.ts
47682
- var VERSION = "0.13.2";
47683
- var COMMIT_SHA = "afa0fbea";
47684
- var COMMIT_DATE = "2026-05-21T00:58:22Z";
47685
- var LATEST_PR = 1610;
47705
+ var VERSION = "0.13.4";
47706
+ var COMMIT_SHA = "b4fd0264";
47707
+ var COMMIT_DATE = "2026-05-21T09:34:13Z";
47708
+ var LATEST_PR = 1628;
47686
47709
  var COMMITS_AHEAD_OF_TAG = 0;
47687
47710
 
47688
47711
  // gateway/boot-version.ts
@@ -48170,6 +48193,7 @@ function resolveCallingSubagent(opts) {
48170
48193
 
48171
48194
  // gateway/gateway.ts
48172
48195
  var REPLY_TO_TEXT_MAX = 200;
48196
+ var SILENT_END_FALLBACK_TEXT = "\u26A0\uFE0F The agent finished working but didn\u2019t send a reply \u2014 your last " + "message may not have been answered. Please try asking again.";
48173
48197
  installStderrTimestamps();
48174
48198
  installPluginLogger();
48175
48199
  installGlobalErrorHandlers();
@@ -51610,11 +51634,19 @@ function handleSessionEvent(ev) {
51610
51634
  ended_via: outboundMetrics.outboundCount > 0 ? "reply" : "silent"
51611
51635
  });
51612
51636
  if (outboundMetrics.outboundCount === 0) {
51613
- writeSilentEndState({
51637
+ const silentEnd = recordSilentTurnEnd({
51614
51638
  chatId,
51615
51639
  threadId: threadId ?? null,
51616
51640
  turnKey: tKey
51617
51641
  });
51642
+ if (silentEnd.exhausted) {
51643
+ process.stderr.write(`telegram gateway: WARN silent-end fallback \u2014 agent stayed ` + `silent after the Stop-hook re-prompt; delivering fallback message chat=${chatId} turnKey=${tKey} (#1161)
51644
+ `);
51645
+ retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(chatId, SILENT_END_FALLBACK_TEXT, tid != null ? { message_thread_id: tid } : {}), { threadId, chat_id: chatId, verb: "silent-end-fallback.sendMessage" }).catch((err) => {
51646
+ process.stderr.write(`telegram gateway: silent-end fallback send failed: ${err instanceof Error ? err.message : String(err)}
51647
+ `);
51648
+ });
51649
+ }
51618
51650
  }
51619
51651
  clear(tKey);
51620
51652
  endTurn(tKey);
@@ -422,24 +422,31 @@ export function uptimeMsForStarttime(
422
422
  }
423
423
 
424
424
  /**
425
- * Compute a remediation hint for a non-active agent systemd state. Returns
425
+ * Compute a remediation hint for a non-active agent state. Returns
426
426
  * `undefined` when no actionable hint applies. Per `reference/principles.md`
427
427
  * principle 1, every degraded/fail row should tell the user what to do next.
428
- * Hints share a common journalctl shape so they're greppable across
429
- * agents.
428
+ *
429
+ * Runtime-aware: v0.7+ agents run in Docker — there is no systemd unit or
430
+ * `journalctl` in-container, so the log-tail command is gated on
431
+ * `SWITCHROOM_RUNTIME` exactly like the boot-card crash row (see
432
+ * `boot-card.ts` and #1376/#1382). All hints share a common log-tail shape
433
+ * so they stay greppable across agents.
430
434
  */
431
435
  function nextStepForAgentState(agentName: string, state: string): string | undefined {
436
+ const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
437
+ ? `docker logs --tail 100 switchroom-${agentName}`
438
+ : `journalctl --user -u switchroom-${agentName} -n 100`
432
439
  if (state === 'failed') {
433
- return `Service failed — inspect with \`journalctl --user -u switchroom-${agentName} -n 100\` then \`switchroom agent restart ${agentName}\``
440
+ return `Service failed — inspect with \`${tailCmd}\` then \`switchroom agent restart ${agentName}\``
434
441
  }
435
442
  if (state === 'inactive') {
436
- return `Service inactive — start with \`switchroom agent start ${agentName}\` (or \`systemctl --user start switchroom-${agentName}\`)`
443
+ return `Service inactive — start with \`switchroom agent start ${agentName}\``
437
444
  }
438
445
  if (state === 'deactivating' || state === 'activating' || state === 'auto-restart') {
439
446
  return `Service is in a transient \`${state}\` state — re-check with \`switchroom agent status ${agentName}\` in a few seconds`
440
447
  }
441
448
  // Unknown state — keep the door open with a generic hint.
442
- return `Inspect with \`journalctl --user -u switchroom-${agentName} -n 100\``
449
+ return `Inspect with \`${tailCmd}\``
443
450
  }
444
451
 
445
452
  function probeAgentProcessDocker(): ProbeResult {
@@ -76,7 +76,7 @@ import {
76
76
  import { emitRuntimeMetric } from '../runtime-metrics.js'
77
77
  import { classifyInbound } from '../inbound-classifier.js'
78
78
  import * as silencePoke from '../silence-poke.js'
79
- import { writeSilentEndState, clearSilentEndState } from '../silent-end.js'
79
+ import { writeSilentEndState, clearSilentEndState, recordSilentTurnEnd } from '../silent-end.js'
80
80
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
81
81
  import { type SessionEvent } from '../session-tail.js'
82
82
  import {
@@ -139,6 +139,16 @@ import { validateStringArray } from './access-validator.js'
139
139
  * identical envelope shapes.
140
140
  */
141
141
  const REPLY_TO_TEXT_MAX = 200
142
+
143
+ /**
144
+ * #1161 — user-facing fallback delivered when a user-message turn ends
145
+ * with zero outbound messages AND the deterministic Stop-hook re-prompt
146
+ * has already been exhausted. Without this the user only sees the
147
+ * progress card vanish; silence must never be the failure mode.
148
+ */
149
+ const SILENT_END_FALLBACK_TEXT =
150
+ '⚠️ The agent finished working but didn’t send a reply — your last ' +
151
+ 'message may not have been answered. Please try asking again.'
142
152
  import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
143
153
  import {
144
154
  validateInlineKeyboard,
@@ -6290,16 +6300,44 @@ function handleSessionEvent(ev: SessionEvent): void {
6290
6300
  longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
6291
6301
  ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
6292
6302
  })
6293
- // #1122 PR4 fix: deterministic silent-end detection (see the
6294
- // silent-marker path above for the rationale). The Stop hook
6295
- // reads the file we write here and blocks the session-end so
6296
- // the agent can be re-prompted to call reply.
6303
+ // #1122 PR4 / #1161: deterministic silent-end handling (see the
6304
+ // silent-marker path above for the rationale).
6305
+ // - first silent-end recordSilentTurnEnd writes the state
6306
+ // file so the Stop hook (silent-end-interrupt-stop.mjs)
6307
+ // blocks the session-end and re-prompts the agent to reply.
6308
+ // - the Stop-hook re-prompt is already spent and the agent is
6309
+ // STILL silent → recordSilentTurnEnd returns exhausted:true;
6310
+ // deliver a user-facing fallback so the turn never just
6311
+ // vanishes (the user otherwise only sees the card disappear).
6297
6312
  if (outboundMetrics.outboundCount === 0) {
6298
- writeSilentEndState({
6313
+ const silentEnd = recordSilentTurnEnd({
6299
6314
  chatId,
6300
6315
  threadId: threadId ?? null,
6301
6316
  turnKey: tKey,
6302
6317
  })
6318
+ if (silentEnd.exhausted) {
6319
+ process.stderr.write(
6320
+ `telegram gateway: WARN silent-end fallback — agent stayed ` +
6321
+ `silent after the Stop-hook re-prompt; delivering fallback ` +
6322
+ `message chat=${chatId} turnKey=${tKey} (#1161)\n`,
6323
+ )
6324
+ void retryWithThreadFallback(
6325
+ robustApiCall,
6326
+ (tid) =>
6327
+ bot.api.sendMessage(
6328
+ chatId,
6329
+ SILENT_END_FALLBACK_TEXT,
6330
+ tid != null ? { message_thread_id: tid } : {},
6331
+ ),
6332
+ { threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
6333
+ ).catch((err) => {
6334
+ process.stderr.write(
6335
+ `telegram gateway: silent-end fallback send failed: ${
6336
+ err instanceof Error ? err.message : String(err)
6337
+ }\n`,
6338
+ )
6339
+ })
6340
+ }
6303
6341
  }
6304
6342
  signalTracker.clear(tKey)
6305
6343
  silencePoke.endTurn(tKey)
@@ -9,7 +9,9 @@
9
9
  * decision:block to re-prompt the agent instead of letting the session close.
10
10
  *
11
11
  * On the second silent-end (retryCount >= MAX_RETRIES), the hook allows the
12
- * stop so the gateway can render the "🙊 Ended without reply" warning card.
12
+ * stop. The gateway's turn-end path (recordSilentTurnEnd in silent-end.ts)
13
+ * detects the exhausted re-prompt and delivers a user-facing fallback
14
+ * message so the turn never silently vanishes (#1161).
13
15
  *
14
16
  * Carve-outs preserved:
15
17
  * - wasAutonomous=true turns: the gateway never writes a state file for
@@ -30,6 +32,8 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
30
32
  import { join } from 'node:path'
31
33
  import { homedir } from 'node:os'
32
34
 
35
+ // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
36
+ // (this hook is a standalone .mjs and can't import the TS module).
33
37
  const MAX_RETRIES = 1
34
38
 
35
39
  function readStdin() {
@@ -51,6 +51,14 @@ export interface SilentEndDeps {
51
51
  log?: (line: string) => void
52
52
  }
53
53
 
54
+ /**
55
+ * How many times the Stop hook re-prompts a silent-end turn before it
56
+ * gives up. MUST stay in sync with `MAX_RETRIES` in the Stop hook
57
+ * (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`) — the hook is a
58
+ * standalone `.mjs` and can't import this module.
59
+ */
60
+ export const SILENT_END_MAX_RETRIES = 1
61
+
54
62
  function resolveStateDir(deps?: SilentEndDeps): string {
55
63
  if (deps?.stateDir != null) return deps.stateDir
56
64
  const env = process.env.TELEGRAM_STATE_DIR
@@ -172,3 +180,51 @@ export function readSilentEndState(deps?: SilentEndDeps): SilentEndState | null
172
180
  return null
173
181
  }
174
182
  }
183
+
184
+ /**
185
+ * Record a user-message turn that ended with zero outbound messages and
186
+ * report whether the deterministic re-prompt has been exhausted. This is
187
+ * the gateway's single entry point for the main turn-end path.
188
+ *
189
+ * - First silent-end of a turn (no prior state, or prior `retryCount`
190
+ * still below `SILENT_END_MAX_RETRIES`) → writes the state file via
191
+ * `writeSilentEndState`, so `silent-end-interrupt-stop.mjs` blocks
192
+ * the stop and re-prompts the agent. Returns `{ exhausted: false }`.
193
+ *
194
+ * - A silent-end where the prior state for the SAME turn already shows
195
+ * `retryCount >= SILENT_END_MAX_RETRIES` → the Stop hook already
196
+ * spent its re-prompt and the agent is STILL silent. Recovery has
197
+ * failed. Clears the state file (so the Stop hook on this final turn
198
+ * finds nothing pending and allows the stop cleanly) and returns
199
+ * `{ exhausted: true }` — the caller MUST then deliver a user-facing
200
+ * fallback so the turn never just vanishes (#1161).
201
+ *
202
+ * Chat-less autonomous wakeup turns never reach here: the gateway only
203
+ * creates a `currentTurn` (and therefore only runs a turn-end handler)
204
+ * when the inbound event carries a chat id. Cron-fired turns DO carry a
205
+ * topic chat and reach this path — a cron task that means to stay silent
206
+ * must emit a NO_REPLY sentinel, which routes to the gateway's
207
+ * silent-marker branch and never gets a fallback.
208
+ */
209
+ export function recordSilentTurnEnd(
210
+ args: { chatId: string; threadId: number | null; turnKey: string },
211
+ deps?: SilentEndDeps,
212
+ ): { exhausted: boolean } {
213
+ const prev = readSilentEndState(deps)
214
+ if (
215
+ prev != null &&
216
+ prev.turnKey === args.turnKey &&
217
+ prev.retryCount >= SILENT_END_MAX_RETRIES
218
+ ) {
219
+ clearSilentEndState(args.turnKey, deps)
220
+ emitLog(
221
+ deps,
222
+ `silent-end: re-prompt exhausted for turnKey=${args.turnKey} ` +
223
+ `(retryCount=${prev.retryCount} >= ${SILENT_END_MAX_RETRIES}) — ` +
224
+ `caller should deliver a fallback\n`,
225
+ )
226
+ return { exhausted: true }
227
+ }
228
+ writeSilentEndState(args, deps)
229
+ return { exhausted: false }
230
+ }
@@ -1185,8 +1185,15 @@ describe('uptimeMsForStarttime', () => {
1185
1185
  // the probes covered by the boot-card-dedup-and-next-steps PR so we don't
1186
1186
  // silently lose the hint on a future refactor.
1187
1187
 
1188
- describe('nextStep — agent systemd states', () => {
1189
- it('attaches a journalctl hint when the unit is failed', async () => {
1188
+ describe('nextStep — agent states', () => {
1189
+ const savedRuntime = process.env.SWITCHROOM_RUNTIME
1190
+ afterEach(() => {
1191
+ if (savedRuntime === undefined) delete process.env.SWITCHROOM_RUNTIME
1192
+ else process.env.SWITCHROOM_RUNTIME = savedRuntime
1193
+ })
1194
+
1195
+ it('attaches a journalctl hint when the unit is failed (non-docker runtime)', async () => {
1196
+ delete process.env.SWITCHROOM_RUNTIME
1190
1197
  const exec = makeSequence([makeSystemctlOutput('failed')])
1191
1198
  const r = await probeAgentProcess('klanker', {
1192
1199
  execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
@@ -1199,6 +1206,23 @@ describe('nextStep — agent systemd states', () => {
1199
1206
  expect(r.nextStep).toMatch(/switchroom-klanker/)
1200
1207
  })
1201
1208
 
1209
+ // #1382: the failed/unknown-state hints must follow SWITCHROOM_RUNTIME the
1210
+ // same way the boot-card crash row does (#1376) — no journalctl in-container.
1211
+ it('attaches a docker-logs hint when the unit is failed under SWITCHROOM_RUNTIME=docker', async () => {
1212
+ process.env.SWITCHROOM_RUNTIME = 'docker'
1213
+ const exec = makeSequence([makeSystemctlOutput('failed')])
1214
+ const r = await probeAgentProcess('klanker', {
1215
+ execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
1216
+ sleepImpl: async () => {},
1217
+ retryIntervalMs: 1,
1218
+ retryMaxMs: 0,
1219
+ })
1220
+ expect(r.status).toBe('fail')
1221
+ expect(r.nextStep).toMatch(/docker logs/)
1222
+ expect(r.nextStep).toMatch(/switchroom-klanker/)
1223
+ expect(r.nextStep).not.toMatch(/journalctl/)
1224
+ })
1225
+
1202
1226
  it('attaches a transient-state hint when the unit is activating after retry budget', async () => {
1203
1227
  const exec = makeSequence([makeSystemctlOutput('activating')])
1204
1228
  const r = await probeAgentProcess('klanker', {
@@ -7,6 +7,8 @@ import {
7
7
  writeSilentEndState,
8
8
  clearSilentEndState,
9
9
  readSilentEndState,
10
+ recordSilentTurnEnd,
11
+ SILENT_END_MAX_RETRIES,
10
12
  } from '../silent-end.js'
11
13
 
12
14
  let stateDir: string
@@ -118,6 +120,73 @@ describe('silent-end.ts — gateway state writer', () => {
118
120
  })
119
121
  })
120
122
 
123
+ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
124
+ it('first silent-end of a turn writes state and reports exhausted:false', () => {
125
+ const r = recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' })
126
+ expect(r.exhausted).toBe(false)
127
+ expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_', retryCount: 0 })
128
+ })
129
+
130
+ it('reports exhausted:false while prior retryCount is still below the cap', () => {
131
+ // The Stop hook has not yet been able to push retryCount to the cap.
132
+ const path = join(stateDir, 'silent-end-pending.json')
133
+ writeFileSync(path, JSON.stringify({
134
+ chatId: 'c', threadId: null, turnKey: 'c:_',
135
+ retryCount: SILENT_END_MAX_RETRIES - 1, timestamp: 0,
136
+ }))
137
+ const r = recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' })
138
+ expect(r.exhausted).toBe(false)
139
+ // State is (re)written, inheriting the prior counter for the same turn.
140
+ expect(readSilentEndState()!.retryCount).toBe(SILENT_END_MAX_RETRIES - 1)
141
+ })
142
+
143
+ it('reports exhausted:true and clears state once the re-prompt cap is reached', () => {
144
+ // The Stop hook already blocked once and pushed retryCount to the cap;
145
+ // the agent is STILL silent on this re-prompted turn.
146
+ const path = join(stateDir, 'silent-end-pending.json')
147
+ writeFileSync(path, JSON.stringify({
148
+ chatId: 'c', threadId: null, turnKey: 'c:_',
149
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
150
+ }))
151
+ const r = recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' })
152
+ expect(r.exhausted).toBe(true)
153
+ // State cleared so the Stop hook on this final turn allows the stop.
154
+ expect(readSilentEndState()).toBeNull()
155
+ })
156
+
157
+ it('treats a capped prior state for a DIFFERENT turn as a fresh silent-end', () => {
158
+ const path = join(stateDir, 'silent-end-pending.json')
159
+ writeFileSync(path, JSON.stringify({
160
+ chatId: 'old', threadId: null, turnKey: 'old:_',
161
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
162
+ }))
163
+ const r = recordSilentTurnEnd({ chatId: 'new', threadId: 9, turnKey: 'new:9' })
164
+ expect(r.exhausted).toBe(false)
165
+ expect(readSilentEndState()).toMatchObject({ turnKey: 'new:9', retryCount: 0 })
166
+ })
167
+
168
+ it('full lifecycle: silent → re-prompt → still silent → exhausted', () => {
169
+ // 1. Turn ends silent — first record.
170
+ expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(false)
171
+ // 2. Stop hook blocks and increments retryCount (simulated).
172
+ const path = join(stateDir, 'silent-end-pending.json')
173
+ const s = readSilentEndState()!
174
+ writeFileSync(path, JSON.stringify({ ...s, retryCount: s.retryCount + 1 }))
175
+ // 3. Re-prompted turn ends silent again — recovery exhausted.
176
+ expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(true)
177
+ expect(readSilentEndState()).toBeNull()
178
+ })
179
+
180
+ it('SILENT_END_MAX_RETRIES matches MAX_RETRIES in the Stop hook', () => {
181
+ // The hook is a standalone .mjs and hardcodes its own copy — this
182
+ // guards the two from drifting apart.
183
+ const hookSrc = readFileSync(join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs'), 'utf8')
184
+ const m = hookSrc.match(/const MAX_RETRIES = (\d+)/)
185
+ expect(m).not.toBeNull()
186
+ expect(Number(m![1])).toBe(SILENT_END_MAX_RETRIES)
187
+ })
188
+ })
189
+
121
190
  describe('silent-end-interrupt-stop hook — integration', () => {
122
191
  const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
123
192