switchroom 0.14.20 → 0.14.22

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 (53) hide show
  1. package/dist/agent-scheduler/index.js +2 -3
  2. package/dist/auth-broker/index.js +2 -3
  3. package/dist/cli/notion-write-pretool.mjs +2 -3
  4. package/dist/cli/switchroom.js +16 -8
  5. package/dist/host-control/main.js +2 -3
  6. package/dist/vault/approvals/kernel-server.js +2 -3
  7. package/dist/vault/broker/server.js +2 -3
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +11 -24
  10. package/profiles/_shared/telegram-style.md.hbs +2 -2
  11. package/profiles/default/CLAUDE.md.hbs +4 -1
  12. package/skills/switchroom-runtime/SKILL.md +6 -16
  13. package/telegram-plugin/agent-dir.ts +15 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +655 -514
  15. package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
  16. package/telegram-plugin/gateway/gateway.ts +246 -83
  17. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  18. package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
  19. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  20. package/telegram-plugin/registry/turns-schema.ts +138 -33
  21. package/telegram-plugin/stream-reply-handler.ts +1 -11
  22. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  23. package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
  24. package/telegram-plugin/tests/e2e.test.ts +2 -77
  25. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  26. package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
  27. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  28. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  29. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  30. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
  31. package/telegram-plugin/tests/races.test.ts +0 -26
  32. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  33. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  34. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  35. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  36. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  37. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  38. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  39. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  40. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  41. package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
  42. package/telegram-plugin/tool-activity-summary.ts +55 -0
  43. package/telegram-plugin/uat/assertions.ts +53 -0
  44. package/telegram-plugin/uat/driver.ts +30 -0
  45. package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
  46. package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
  47. package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
  48. package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
  50. package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
  51. package/telegram-plugin/worker-activity-feed.ts +11 -5
  52. package/telegram-plugin/handoff-continuity.ts +0 -206
  53. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -11038,7 +11038,6 @@ var SessionSchema = exports_external.object({
11038
11038
  }).optional();
11039
11039
  var SessionContinuitySchema = exports_external.object({
11040
11040
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
11041
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '↩️ Picked up…' " + "line to the first assistant reply after a restart (default true)."),
11042
11041
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11043
11042
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11044
11043
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -11070,10 +11069,10 @@ var TelegramChannelSchema = exports_external.object({
11070
11069
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
11070
  coalesce: exports_external.object({
11072
11071
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
11073
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11072
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11074
11073
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11075
11074
  interrupt: exports_external.object({
11076
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11075
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11077
11076
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11078
11077
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11079
11078
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
@@ -11038,7 +11038,6 @@ var SessionSchema = exports_external.object({
11038
11038
  }).optional();
11039
11039
  var SessionContinuitySchema = exports_external.object({
11040
11040
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
11041
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '↩️ Picked up…' " + "line to the first assistant reply after a restart (default true)."),
11042
11041
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11043
11042
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11044
11043
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -11070,10 +11069,10 @@ var TelegramChannelSchema = exports_external.object({
11070
11069
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
11070
  coalesce: exports_external.object({
11072
11071
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
11073
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11072
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11074
11073
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11075
11074
  interrupt: exports_external.object({
11076
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11075
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11077
11076
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11078
11077
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11079
11078
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
@@ -11785,7 +11785,6 @@ var SessionSchema = exports_external.object({
11785
11785
  }).optional();
11786
11786
  var SessionContinuitySchema = exports_external.object({
11787
11787
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
11788
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '\u21a9\ufe0f Picked up\u2026' " + "line to the first assistant reply after a restart (default true)."),
11789
11788
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11790
11789
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11791
11790
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -11817,10 +11816,10 @@ var TelegramChannelSchema = exports_external.object({
11817
11816
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11818
11817
  coalesce: exports_external.object({
11819
11818
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
11820
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11819
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11821
11820
  }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11822
11821
  interrupt: exports_external.object({
11823
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11822
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11824
11823
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11825
11824
  }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11826
11825
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
@@ -13602,7 +13602,6 @@ var init_schema = __esm(() => {
13602
13602
  }).optional();
13603
13603
  SessionContinuitySchema = exports_external.object({
13604
13604
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
13605
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '\u21a9\ufe0f Picked up\u2026' " + "line to the first assistant reply after a restart (default true)."),
13606
13605
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
13607
13606
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
13608
13607
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -13634,10 +13633,10 @@ var init_schema = __esm(() => {
13634
13633
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13635
13634
  coalesce: exports_external.object({
13636
13635
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
13637
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
13636
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
13638
13637
  }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13639
13638
  interrupt: exports_external.object({
13640
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
13639
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
13641
13640
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
13642
13641
  }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
13643
13642
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
@@ -49421,8 +49420,8 @@ var {
49421
49420
  } = import__.default;
49422
49421
 
49423
49422
  // src/build-info.ts
49424
- var VERSION = "0.14.20";
49425
- var COMMIT_SHA = "c8b965b2";
49423
+ var VERSION = "0.14.22";
49424
+ var COMMIT_SHA = "ab2692b9";
49426
49425
 
49427
49426
  // src/cli/agent.ts
49428
49427
  init_source();
@@ -51162,7 +51161,6 @@ function buildWorkspaceContext(args) {
51162
51161
  sessionMaxIdleSecs: parseDurationToSeconds(agentConfig.session?.max_idle),
51163
51162
  sessionMaxTurns: agentConfig.session?.max_turns,
51164
51163
  handoffEnabled: agentConfig.session_continuity?.enabled !== false,
51165
- handoffShowLine: agentConfig.session_continuity?.show_handoff_line !== false,
51166
51164
  resumeMode: agentConfig.session_continuity?.resume_mode ?? "handoff",
51167
51165
  resumeMaxBytes: agentConfig.session_continuity?.resume_max_bytes ?? 2000000,
51168
51166
  resumeModeHasContinuePath: (() => {
@@ -52202,7 +52200,6 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
52202
52200
  sessionMaxIdleSecs: parseDurationToSeconds(agentConfig.session?.max_idle),
52203
52201
  sessionMaxTurns: agentConfig.session?.max_turns,
52204
52202
  handoffEnabled: agentConfig.session_continuity?.enabled !== false,
52205
- handoffShowLine: agentConfig.session_continuity?.show_handoff_line !== false,
52206
52203
  resumeMode: agentConfig.session_continuity?.resume_mode ?? "handoff",
52207
52204
  resumeMaxBytes: agentConfig.session_continuity?.resume_max_bytes ?? 2000000
52208
52205
  };
@@ -70835,6 +70832,7 @@ var SCHEMA_SQL = `
70835
70832
  user_prompt_preview TEXT,
70836
70833
  assistant_reply_preview TEXT,
70837
70834
  tool_call_count INTEGER,
70835
+ interrupt_reason TEXT,
70838
70836
  created_at INTEGER NOT NULL,
70839
70837
  updated_at INTEGER NOT NULL
70840
70838
  );
@@ -70845,11 +70843,14 @@ var PHASE1_MIGRATIONS = [
70845
70843
  `ALTER TABLE turns ADD COLUMN assistant_reply_preview TEXT`,
70846
70844
  `ALTER TABLE turns ADD COLUMN tool_call_count INTEGER`
70847
70845
  ];
70846
+ var PHASE2_MIGRATIONS = [
70847
+ `ALTER TABLE turns ADD COLUMN interrupt_reason TEXT`
70848
+ ];
70848
70849
  function applySchema(db) {
70849
70850
  db.exec("PRAGMA journal_mode = WAL");
70850
70851
  db.exec("PRAGMA synchronous = NORMAL");
70851
70852
  db.exec(SCHEMA_SQL);
70852
- for (const sql of PHASE1_MIGRATIONS) {
70853
+ for (const sql of [...PHASE1_MIGRATIONS, ...PHASE2_MIGRATIONS]) {
70853
70854
  try {
70854
70855
  db.exec(sql);
70855
70856
  } catch (err) {
@@ -70885,6 +70886,7 @@ function mapRow(row) {
70885
70886
  user_prompt_preview: row.user_prompt_preview,
70886
70887
  assistant_reply_preview: row.assistant_reply_preview,
70887
70888
  tool_call_count: row.tool_call_count,
70889
+ interrupt_reason: row.interrupt_reason,
70888
70890
  created_at: row.created_at,
70889
70891
  updated_at: row.updated_at
70890
70892
  };
@@ -70898,6 +70900,12 @@ function listTurnsForAgent(db, opts = {}) {
70898
70900
  `).all(limit);
70899
70901
  return rows.map(mapRow);
70900
70902
  }
70903
+ var INTERRUPTED_VIA = new Set([
70904
+ "restart",
70905
+ "sigterm",
70906
+ "timeout",
70907
+ "unknown"
70908
+ ]);
70901
70909
 
70902
70910
  // telegram-plugin/registry/subagents-schema.ts
70903
70911
  var SUBAGENTS_SCHEMA_SQL = `
@@ -13773,7 +13773,6 @@ var SessionSchema = exports_external.object({
13773
13773
  }).optional();
13774
13774
  var SessionContinuitySchema = exports_external.object({
13775
13775
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
13776
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '↩️ Picked up…' " + "line to the first assistant reply after a restart (default true)."),
13777
13776
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
13778
13777
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
13779
13778
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -13805,10 +13804,10 @@ var TelegramChannelSchema = exports_external.object({
13805
13804
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13806
13805
  coalesce: exports_external.object({
13807
13806
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
13808
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
13807
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
13809
13808
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13810
13809
  interrupt: exports_external.object({
13811
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
13810
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
13812
13811
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
13813
13812
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
13814
13813
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
@@ -11353,7 +11353,6 @@ var init_schema = __esm(() => {
11353
11353
  }).optional();
11354
11354
  SessionContinuitySchema = exports_external.object({
11355
11355
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
11356
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '↩️ Picked up…' " + "line to the first assistant reply after a restart (default true)."),
11357
11356
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11358
11357
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11359
11358
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -11385,10 +11384,10 @@ var init_schema = __esm(() => {
11385
11384
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
11385
  coalesce: exports_external.object({
11387
11386
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
11388
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11387
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11389
11388
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11390
11389
  interrupt: exports_external.object({
11391
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11390
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11392
11391
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11393
11392
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11394
11393
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
@@ -11353,7 +11353,6 @@ var init_schema = __esm(() => {
11353
11353
  }).optional();
11354
11354
  SessionContinuitySchema = exports_external.object({
11355
11355
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
11356
- show_handoff_line: exports_external.boolean().optional().describe("Whether the telegram plugin prepends a visible '↩️ Picked up…' " + "line to the first assistant reply after a restart (default true)."),
11357
11356
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
11358
11357
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
11359
11358
  resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
@@ -11385,10 +11384,10 @@ var init_schema = __esm(() => {
11385
11384
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
11385
  coalesce: exports_external.object({
11387
11386
  window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged)."),
11388
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11387
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11389
11388
  }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11390
11389
  interrupt: exports_external.object({
11391
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11390
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11392
11391
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11393
11392
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11394
11393
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.20",
3
+ "version": "0.14.22",
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": {
@@ -23,9 +23,9 @@
23
23
  "build": "node scripts/build.mjs",
24
24
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
25
25
  "pretest": "npm run build",
26
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
26
+ "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -27,20 +27,6 @@
27
27
  # same one v0.6 systemd has always honored, just enforced inside the
28
28
  # container instead of by the host's user systemd manager.
29
29
 
30
- {{#if handoffEnabled}}
31
- # The telegram-plugin gateway reads SWITCHROOM_HANDOFF_SHOW_LINE to
32
- # decide whether to prepend the visible "↩️ Picked up where we left
33
- # off …" line on the first reply after a restart. It MUST be exported
34
- # *before* the gateway is forked in the docker preamble below (and
35
- # before the tmux re-exec), otherwise the gateway — the sole consumer —
36
- # never inherits it and session_continuity.show_handoff_line:false
37
- # silently no-ops on every docker agent. Living here, ahead of the
38
- # runtime branch, covers docker (both the outer fork pass and the inner
39
- # tmux pass) and the v0.6 non-docker path in one place. Gated on
40
- # handoffEnabled so a handoff-disabled agent emits no handoff env at all.
41
- export SWITCHROOM_HANDOFF_SHOW_LINE={{#if handoffShowLine}}true{{else}}false{{/if}}
42
- {{/if}}
43
-
44
30
  if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER" ]; then
45
31
  # Hoist TELEGRAM_STATE_DIR up here so the gateway daemon (forked
46
32
  # below) finds gateway.sock / gateway.pid.json / history.db at the
@@ -441,14 +427,16 @@ export SWITCHROOM_PRIOR_SESSION_EPOCH
441
427
  #
442
428
  # The gateway writes <agentDir>/.pending-turn.env on boot if the previous
443
429
  # shutdown left an interrupted turn (ended_via='restart' / 'sigterm' /
444
- # 'timeout' or never closed at all). Source it here so the agent process
445
- # inherits SWITCHROOM_PENDING_TURN=true plus chat/thread/last-user-msg
446
- # context. The agent's resume protocol (per-agent CLAUDE.md) reads these
447
- # and decides whether to ack the interruption + ask for direction or
448
- # silently continue.
430
+ # 'timeout' or never closed at all). These vars are now PASSIVE forensic
431
+ # context only the actual wake-and-resume is driven by a synthesized
432
+ # inbound the gateway injects (<channel source="resume_interrupted"> or
433
+ # "resume_watchdog_timeout">), not by the agent polling these vars. They
434
+ # stay exported so the wake-audit / "why did you restart" protocols
435
+ # (skills/switchroom-runtime) can read what was in flight without
436
+ # re-deriving it. SWITCHROOM_PENDING_INTERRUPT_REASON carries the boot
437
+ # classifier's idle snapshot for a 'timeout'-killed turn.
449
438
  #
450
- # Consume the file (rm) so it only applies to ONE boot — multi-restart
451
- # scenarios shouldn't re-fire the resume prompt repeatedly.
439
+ # Consume the file (rm) so it only applies to ONE boot.
452
440
  SWITCHROOM_PENDING_TURN_ENV="{{agentDir}}/.pending-turn.env"
453
441
  if [ -f "$SWITCHROOM_PENDING_TURN_ENV" ]; then
454
442
  # shellcheck disable=SC1090
@@ -460,7 +448,8 @@ if [ -f "$SWITCHROOM_PENDING_TURN_ENV" ]; then
460
448
  SWITCHROOM_PENDING_THREAD_ID \
461
449
  SWITCHROOM_PENDING_USER_MSG_ID \
462
450
  SWITCHROOM_PENDING_ENDED_VIA \
463
- SWITCHROOM_PENDING_STARTED_AT
451
+ SWITCHROOM_PENDING_STARTED_AT \
452
+ SWITCHROOM_PENDING_INTERRUPT_REASON
464
453
  fi
465
454
 
466
455
  # --- Wake audit sentinel ---
@@ -525,8 +514,6 @@ if [ ! -s "$HANDOFF_FILE" ]; then
525
514
  timeout 5 handoff-briefing.sh 2>/dev/null || true
526
515
  fi
527
516
  fi
528
- # SWITCHROOM_HANDOFF_SHOW_LINE is exported near the top of this script
529
- # (ahead of the docker preamble) so the gateway sidecar inherits it.
530
517
  APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuoted}}}{{else}}""{{/if}}
531
518
  # Inject .handoff-briefing.md first (assembled from live sources), then
532
519
  # .handoff.md (raw transcript tail from the Stop hook). If both
@@ -53,7 +53,7 @@ If both `queued` and `steering` are somehow present, `steering` wins (explicit o
53
53
 
54
54
  Don't use `accent` on routine conversational replies — it's for status communication, not decoration. Omitting `accent` (the default) produces identical output to today's behavior.
55
55
 
56
- **Resume protocol — interrupted turns.** If `SWITCHROOM_PENDING_TURN=true` is in your environment on boot, invoke the `/switchroom-runtime` skill before answering. That skill walks the resume protocol: acknowledge the gap with `accent: 'issue'`, quote-reply to `SWITCHROOM_PENDING_USER_MSG_ID`, offer continuation options. Don't silently pick up where you left off. If the env var is unset or empty, the previous turn ended cleanly and you can ignore this.
56
+ **Resume protocol — interrupted turns.** If your previous turn was interrupted, the gateway wakes you on its own with a synthesized inbound (`<channel source="resume_interrupted">` or `<channel source="resume_watchdog_timeout">`) — you don't poll for it. A clean interrupt (operator restart / SIGTERM / crash) means **resume the work and tell the user you're picking it back up; don't ask.** A hang-watchdog kill means **don't silently resume report what happened (killed after N min of no progress) and ask whether to retry.** The inbound text spells out which case applies; `/switchroom-runtime` has the full protocol.
57
57
 
58
58
  **Long replies → Telegraph Instant View.** When the operator has telegraph enabled (per-agent flag `telegraph.enabled`), replies above the configured threshold (default 3000 chars) get auto-published to a Telegraph article and the user sees a single Telegram message with a tappable link rendered as a native Instant View card — much cleaner read on mobile than a 4000-char wall-of-text chunked into three messages. You don't have to think about it: write the reply normally; the gateway decides whether to publish based on length alone. Two practical implications: (a) if the user asks "what was in that link?" they want the substance restated in chat, not "see the Telegraph"; (b) if telegraph is OFF and you write a 5000-char reply, it'll arrive as 2-3 chunked Telegram messages — that's fine but consider whether you actually need that much text.
59
59
 
@@ -65,7 +65,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
65
65
 
66
66
  **When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, don't — a sticker is never a substitute for an actual answer. Two stickers in a row is always wrong.
67
67
 
68
- **Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` to interrupt whatever I'm doing and treat the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol fires on the next turn.
68
+ **Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` to interrupt whatever I'm doing and treat the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt is in-process (no restart), so it does not trigger the boot-resume path the remainder is delivered as a fresh turn immediately.
69
69
 
70
70
  **Wake audit on fresh boot.** If `$TELEGRAM_STATE_DIR/.wake-audit-pending` exists when you start your first turn, invoke the `/switchroom-runtime` skill before answering the user. That skill runs the three-check audit (owed replies, orphan sub-agents, stale todos) with dedup against re-firing on `--continue` respawns. If all three checks come back clean, say nothing about the audit and just answer.
71
71
 
@@ -118,7 +118,10 @@ By default, every restart starts a **fresh `claude` session** — the in-flight
118
118
  - **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.
119
119
  - **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.
120
120
  - **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.
121
- - **`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.
121
+ - **Boot-resume inbound** — if your previous session was killed mid-turn, the gateway wakes you on its own with a synthesized inbound (you'll see `<channel source="resume_interrupted">` or `<channel source="resume_watchdog_timeout">`). You don't poll for this it arrives as your first turn. Two cases, and the inbound text spells out which:
122
+ - **`resume_interrupted`** (operator restart / SIGTERM / crash): pick the work back up and carry it to completion. Briefly tell the user you're resuming and roughly how long ago it was interrupted — then just do it. Do NOT ask whether to resume.
123
+ - **`resume_watchdog_timeout`** (hang-watchdog killed it after no progress): do NOT silently resume — it may hang the same way. Tell the user plainly that your last turn was killed after N minutes of no progress, roughly what it was doing, and ask whether to retry or take a different angle. Report only the honest cause; don't invent a deeper root cause.
124
+ The one-shot `SWITCHROOM_PENDING_*` env vars are passive forensic context for the wake-audit / "why did you restart" protocols — not the resume trigger.
122
125
  - **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in your CLAUDE.md, then `rm -f` the sentinel.
123
126
 
124
127
  A config-summary greeting card is sent automatically by the SessionStart hook — you don't need to announce yourself. If your context feels thin (after compaction or any fresh session), proactively recall from Hindsight before proceeding.
@@ -56,29 +56,19 @@ This skill holds the runtime protocols that fire on specific boot signals or use
56
56
 
57
57
  ## Resume protocol — interrupted turns
58
58
 
59
- **Trigger:** the env var `SWITCHROOM_PENDING_TURN=true` is set when your session boots. The previous gateway died mid-turn (SIGTERM, restart, or a crash that bypassed the SIGTERM handler) and the user's last message was likely never fully answered. The accompanying env vars tell you what was in flight:
59
+ **You do not poll for this.** When your previous turn was interrupted, the gateway wakes you on its own at boot by injecting a synthesized inbound — it arrives as your first turn, tagged `<channel source="resume_interrupted">` or `<channel source="resume_watchdog_timeout">`. The inbound text carries the specifics (elapsed time, the original request, tool-call count); this section is the *why* behind the two shapes so you handle each correctly. The policy is decided by how the prior turn ended, not by you.
60
60
 
61
- - `SWITCHROOM_PENDING_CHAT_ID` — the chat the interrupted turn belonged to
62
- - `SWITCHROOM_PENDING_THREAD_ID` — the forum topic id (empty if not a forum)
63
- - `SWITCHROOM_PENDING_USER_MSG_ID` — the inbound message_id that started the turn (you can quote-reply to it for context)
64
- - `SWITCHROOM_PENDING_ENDED_VIA` — `restart` (user ran `switchroom agent restart`), `sigterm` (systemd/manual kill), `timeout` (watchdog), or `unknown` (crash before stamp)
65
- - `SWITCHROOM_PENDING_STARTED_AT` — unix-ms when the turn started
61
+ **Branch 1 — `resume_interrupted` (clean mid-flight interrupt).** The turn was cut off by an operator `switchroom agent restart`, a SIGTERM, or a crash before the turn could finish it was *making progress*, just stopped short. **Resume it. Do not ask whether to.** In your first message, briefly tell the user you're picking the work back up and roughly how long ago it was interrupted (the inbound gives you the elapsed framing, e.g. "~3h ago"), then carry the actual task through to completion. The user has no way to know you remember — the one-line "resuming the X you asked ~3h ago" is what closes that gap. Only if you genuinely can't tell what the work was (no Hindsight, no handoff briefing, empty original prompt) do you say so and ask.
66
62
 
67
- **Your first action on a `SWITCHROOM_PENDING_TURN=true` boot must be to acknowledge the gap and confirm direction.** Don't silently pick up where you left off. The user has no way to know whether you remember what you were doing. Use `reply` with `accent: 'issue'` to make it obvious. Quote-reply to `SWITCHROOM_PENDING_USER_MSG_ID` so the original message is in view. Sample wording (adapt to the situation):
63
+ **Branch 2 `resume_watchdog_timeout` (hang-watchdog kill).** The turn made *no observable progress* for the full hang window (default 5 min) and was killed as a wedge. **Do NOT silently resume it it may hang the same way.** Instead, tell the user plainly what happened: that your last turn was killed after ~N minutes of no progress, and roughly what it was doing (the inbound carries the idle duration and tool-call count). Then ask whether they want you to retry it or take a different angle. Report **only the honest cause** no observable progress for that long — don't speculate about a deeper root cause you can't actually see from a boot record. Use `reply` with `accent: 'issue'` so the report is visually distinct.
68
64
 
69
- > ⚠️ Issue
70
- >
71
- > I was killed mid-turn. Looks like my previous shutdown was via `<endedVia>`. Don't have full context on what I'd already done. Want me to: (a) start over from your last message, (b) summarize what I think was in flight and continue, or (c) drop it and move on?
72
-
73
- The env vars are one-shot (start.sh deletes the file after sourcing), so this prompt only fires on the immediately-following session, not every restart afterward. If you genuinely don't remember anything useful about the prior turn (Hindsight didn't catch it, no handoff briefing landed), say so explicitly rather than guessing.
74
-
75
- If `SWITCHROOM_PENDING_TURN` is unset or empty, do nothing special: the previous turn ended cleanly.
65
+ The `SWITCHROOM_PENDING_*` env vars (`_CHAT_ID`, `_THREAD_ID`, `_USER_MSG_ID`, `_ENDED_VIA`, `_STARTED_AT`, `_INTERRUPT_REASON`) are one-shot passive context for the wake-audit and "why did you restart" protocols below — they are NOT the resume trigger and you don't need to act on them directly.
76
66
 
77
67
  ---
78
68
 
79
69
  ## Wake audit — every fresh boot
80
70
 
81
- **Trigger:** the sentinel file `$TELEGRAM_STATE_DIR/.wake-audit-pending` exists. `start.sh` drops it on every process boot. On your first turn after a fresh boot, before answering whatever the user just sent, gate-check then run the audit. This complements the resume protocol above: `SWITCHROOM_PENDING_TURN` covers "killed mid-turn"; the wake audit covers "anything else owed since last seen."
71
+ **Trigger:** the sentinel file `$TELEGRAM_STATE_DIR/.wake-audit-pending` exists. `start.sh` drops it on every process boot. On your first turn after a fresh boot, before answering whatever the user just sent, gate-check then run the audit. This complements the resume protocol above: the injected `resume_interrupted` / `resume_watchdog_timeout` inbound covers "killed mid-turn"; the wake audit covers "anything else owed since last seen."
82
72
 
83
73
  **Conversation-aware dedup.** start.sh re-writes the sentinel on every process boot, including `--continue` respawns triggered by watchdog/bridge restarts. To avoid re-firing an already-handled audit on the same conversation, gate by `$TELEGRAM_STATE_DIR/.wake-audit-last-completed`:
84
74
 
@@ -149,7 +139,7 @@ The gateway treats a Telegram message starting with `!` (single bang, not `!!` o
149
139
 
150
140
  If the user sends `! actually never mind, do X instead`, you'll boot up and see `actually never mind, do X instead` with no record of what you were doing before. That's intentional.
151
141
 
152
- Doubled `!!` (typo / emphasis) reaches you verbatim. Empty `!` gets a "Send your replacement instruction now" reply from the gateway and never reaches you. The interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol above will fire on the next turn. Keep that pairing in mind when acknowledging.
142
+ Doubled `!!` (typo / emphasis) reaches you verbatim. Empty `!` gets a "Send your replacement instruction now" reply from the gateway and never reaches you. The interrupt is in-process — the gateway keeps running and delivers the remainder as a fresh turn immediately so it does NOT trigger the boot-resume path (that fires only on a real restart). The turn you were running is simply abandoned in favour of the new instruction.
153
143
 
154
144
  ---
155
145
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Resolve the agent's state directory from the environment.
3
+ *
4
+ * `TELEGRAM_STATE_DIR` points at `<agentDir>/telegram`; the agent dir is
5
+ * its parent. Returns null when the env var is unset or blank so callers
6
+ * can degrade gracefully (the gateway runs in non-agent contexts too —
7
+ * tests, local dev).
8
+ */
9
+ import { dirname } from "node:path";
10
+
11
+ export function resolveAgentDirFromEnv(): string | null {
12
+ const state = process.env.TELEGRAM_STATE_DIR;
13
+ if (!state || state.trim().length === 0) return null;
14
+ return dirname(state);
15
+ }