switchroom 0.14.21 → 0.14.23

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 (43) hide show
  1. package/dist/agent-scheduler/index.js +0 -1
  2. package/dist/auth-broker/index.js +0 -1
  3. package/dist/cli/notion-write-pretool.mjs +0 -1
  4. package/dist/cli/switchroom.js +14 -6
  5. package/dist/host-control/main.js +0 -1
  6. package/dist/vault/approvals/kernel-server.js +0 -1
  7. package/dist/vault/broker/server.js +0 -1
  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 +788 -513
  15. package/telegram-plugin/gateway/gateway.ts +216 -61
  16. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  17. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  18. package/telegram-plugin/registry/turns-schema.ts +138 -33
  19. package/telegram-plugin/stream-reply-handler.ts +1 -11
  20. package/telegram-plugin/subagent-watcher.ts +79 -5
  21. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  22. package/telegram-plugin/tests/e2e.test.ts +2 -77
  23. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  24. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  25. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  26. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  27. package/telegram-plugin/tests/races.test.ts +0 -26
  28. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  29. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  30. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  31. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  32. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  33. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  34. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  35. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +7 -3
  36. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +293 -0
  37. package/telegram-plugin/tests/subagent-watcher.test.ts +23 -15
  38. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  39. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  40. package/telegram-plugin/tool-activity-summary.ts +55 -0
  41. package/telegram-plugin/uat/driver.ts +3 -1
  42. package/telegram-plugin/handoff-continuity.ts +0 -206
  43. 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.")
@@ -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.")
@@ -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.")
@@ -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.")
@@ -49421,8 +49420,8 @@ var {
49421
49420
  } = import__.default;
49422
49421
 
49423
49422
  // src/build-info.ts
49424
- var VERSION = "0.14.21";
49425
- var COMMIT_SHA = "62ddded0";
49423
+ var VERSION = "0.14.23";
49424
+ var COMMIT_SHA = "8ac2987a";
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.")
@@ -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.")
@@ -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.")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.21",
3
+ "version": "0.14.23",
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/uat/feed-matcher.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
+ }