switchroom 0.13.2 → 0.13.3

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 (66) hide show
  1. package/dist/agent-scheduler/index.js +2 -2
  2. package/dist/auth-broker/index.js +2 -2
  3. package/dist/cli/switchroom.js +21 -18
  4. package/dist/host-control/main.js +2 -2
  5. package/dist/vault/approvals/kernel-server.js +2 -2
  6. package/dist/vault/broker/server.js +2 -2
  7. package/package.json +1 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +42 -10
  9. package/telegram-plugin/gateway/boot-probes.ts +13 -6
  10. package/telegram-plugin/gateway/gateway.ts +44 -6
  11. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +5 -1
  12. package/telegram-plugin/silent-end.ts +56 -0
  13. package/telegram-plugin/tests/boot-probes.test.ts +26 -2
  14. package/telegram-plugin/tests/silent-end.test.ts +69 -0
  15. package/skills/buildkite-agent-infrastructure/SKILL.md +0 -321
  16. package/skills/buildkite-agent-infrastructure/agents/openai.yaml +0 -6
  17. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-large.png +0 -0
  18. package/skills/buildkite-agent-infrastructure/assets/buildkite-icon-small.png +0 -0
  19. package/skills/buildkite-agent-infrastructure/references/audit-logging.md +0 -87
  20. package/skills/buildkite-agent-infrastructure/references/graphql-mutations.md +0 -690
  21. package/skills/buildkite-agent-infrastructure/references/instance-shapes.md +0 -38
  22. package/skills/buildkite-agent-infrastructure/references/pipeline-templates.md +0 -73
  23. package/skills/buildkite-agent-infrastructure/references/self-hosted-agents.md +0 -137
  24. package/skills/buildkite-agent-infrastructure/references/sso-saml.md +0 -92
  25. package/skills/buildkite-agent-runtime/SKILL.md +0 -509
  26. package/skills/buildkite-agent-runtime/agents/openai.yaml +0 -6
  27. package/skills/buildkite-agent-runtime/assets/buildkite-icon-large.png +0 -0
  28. package/skills/buildkite-agent-runtime/assets/buildkite-icon-small.png +0 -0
  29. package/skills/buildkite-agent-runtime/references/flag-reference.md +0 -417
  30. package/skills/buildkite-agent-runtime/references/patterns-and-recipes.md +0 -555
  31. package/skills/buildkite-api/SKILL.md +0 -308
  32. package/skills/buildkite-api/agents/openai.yaml +0 -6
  33. package/skills/buildkite-api/assets/buildkite-icon-large.png +0 -0
  34. package/skills/buildkite-api/assets/buildkite-icon-small.png +0 -0
  35. package/skills/buildkite-api/references/graphql-reference.md +0 -195
  36. package/skills/buildkite-api/references/patterns.md +0 -44
  37. package/skills/buildkite-api/references/webhooks.md +0 -161
  38. package/skills/buildkite-cli/SKILL.md +0 -397
  39. package/skills/buildkite-cli/agents/openai.yaml +0 -6
  40. package/skills/buildkite-cli/assets/buildkite-icon-large.png +0 -0
  41. package/skills/buildkite-cli/assets/buildkite-icon-small.png +0 -0
  42. package/skills/buildkite-cli/references/command-reference.md +0 -181
  43. package/skills/buildkite-migration/SKILL.md +0 -195
  44. package/skills/buildkite-pipelines/SKILL.md +0 -481
  45. package/skills/buildkite-pipelines/agents/openai.yaml +0 -6
  46. package/skills/buildkite-pipelines/assets/buildkite-icon-large.png +0 -0
  47. package/skills/buildkite-pipelines/assets/buildkite-icon-small.png +0 -0
  48. package/skills/buildkite-pipelines/examples/basic-pipeline.yml +0 -24
  49. package/skills/buildkite-pipelines/examples/optimized-pipeline.yml +0 -100
  50. package/skills/buildkite-pipelines/references/advanced-patterns.md +0 -286
  51. package/skills/buildkite-pipelines/references/retry-and-error-codes.md +0 -131
  52. package/skills/buildkite-pipelines/references/step-types-reference.md +0 -225
  53. package/skills/buildkite-secure-delivery/SKILL.md +0 -182
  54. package/skills/buildkite-secure-delivery/agents/openai.yaml +0 -6
  55. package/skills/buildkite-secure-delivery/assets/buildkite-icon-large.png +0 -0
  56. package/skills/buildkite-secure-delivery/assets/buildkite-icon-small.png +0 -0
  57. package/skills/buildkite-secure-delivery/references/oidc-cloud-providers.md +0 -83
  58. package/skills/buildkite-secure-delivery/references/package-publishing.md +0 -100
  59. package/skills/buildkite-test-engine/SKILL.md +0 -256
  60. package/skills/buildkite-test-engine/agents/openai.yaml +0 -6
  61. package/skills/buildkite-test-engine/assets/buildkite-icon-large.png +0 -0
  62. package/skills/buildkite-test-engine/assets/buildkite-icon-small.png +0 -0
  63. package/skills/buildkite-test-engine/examples/bktec-splitting.yml +0 -16
  64. package/skills/buildkite-test-engine/examples/collector-pipeline.yml +0 -11
  65. package/skills/buildkite-test-engine/references/collectors.md +0 -198
  66. package/skills/buildkite-test-engine/references/splitting-examples.md +0 -93
@@ -11130,7 +11130,7 @@ var profileFields = {
11130
11130
  extends: exports_external.string().optional(),
11131
11131
  bot_token: exports_external.string().optional(),
11132
11132
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11133
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11133
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11134
11134
  soul: exports_external.object({
11135
11135
  name: exports_external.string().optional(),
11136
11136
  style: exports_external.string().optional(),
@@ -11191,7 +11191,7 @@ var AgentSchema = exports_external.object({
11191
11191
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11192
11192
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11193
11193
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11194
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11194
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11195
11195
  auth: exports_external.object({
11196
11196
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11197
11197
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -11130,7 +11130,7 @@ var profileFields = {
11130
11130
  extends: exports_external.string().optional(),
11131
11131
  bot_token: exports_external.string().optional(),
11132
11132
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11133
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11133
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11134
11134
  soul: exports_external.object({
11135
11135
  name: exports_external.string().optional(),
11136
11136
  style: exports_external.string().optional(),
@@ -11191,7 +11191,7 @@ var AgentSchema = exports_external.object({
11191
11191
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11192
11192
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11193
11193
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11194
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11194
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11195
11195
  auth: exports_external.object({
11196
11196
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11197
11197
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -13694,7 +13694,7 @@ var init_schema = __esm(() => {
13694
13694
  extends: exports_external.string().optional(),
13695
13695
  bot_token: exports_external.string().optional(),
13696
13696
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) \u2014 mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
13697
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
13697
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
13698
13698
  soul: exports_external.object({
13699
13699
  name: exports_external.string().optional(),
13700
13700
  style: exports_external.string().optional(),
@@ -13755,7 +13755,7 @@ var init_schema = __esm(() => {
13755
13755
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
13756
13756
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) \u2014 a pinned agent does " + "not inherit the fleet channel, and vice versa."),
13757
13757
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
13758
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
13758
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
13759
13759
  auth: exports_external.object({
13760
13760
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only \u2014 " + "this agent talks to the named account regardless of fleet active. See RFC H \u00a74.5.")
13761
13761
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -47248,8 +47248,8 @@ var {
47248
47248
  } = import__.default;
47249
47249
 
47250
47250
  // src/build-info.ts
47251
- var VERSION = "0.13.2";
47252
- var COMMIT_SHA = "afa0fbea";
47251
+ var VERSION = "0.13.3";
47252
+ var COMMIT_SHA = "e357d33b";
47253
47253
 
47254
47254
  // src/cli/agent.ts
47255
47255
  init_source();
@@ -50658,18 +50658,22 @@ async function summarize(opts) {
50658
50658
  await mirrorToHindsight(parsed.briefing, opts).catch(() => {});
50659
50659
  return "ok";
50660
50660
  }
50661
+ function buildHandoffClaudeArgs(opts) {
50662
+ return [
50663
+ "-p",
50664
+ opts.user,
50665
+ "--model",
50666
+ opts.model,
50667
+ "--append-system-prompt",
50668
+ opts.system,
50669
+ "--no-session-persistence",
50670
+ "--strict-mcp-config"
50671
+ ];
50672
+ }
50661
50673
  var defaultClaudeCliRunner = {
50662
50674
  async run({ model, system, user, timeoutMs }) {
50663
50675
  return await new Promise((resolve14, reject) => {
50664
- const args = [
50665
- "-p",
50666
- user,
50667
- "--model",
50668
- model,
50669
- "--append-system-prompt",
50670
- system,
50671
- "--no-session-persistence"
50672
- ];
50676
+ const args = buildHandoffClaudeArgs({ model, system, user });
50673
50677
  const env2 = {
50674
50678
  ...process.env,
50675
50679
  FORCE_COLOR: "0",
@@ -53330,7 +53334,6 @@ Scaffolding agent: ${name}
53330
53334
  }
53331
53335
  try {
53332
53336
  const result = reconcileAgent(n, agentConfig, agentsDir, config.telegram, config, configPath, { preserveClaudeMd: opts.preserveClaudeMd });
53333
- const unitChanges = [];
53334
53337
  const allChanges = [...result.changes];
53335
53338
  if (allChanges.length === 0) {
53336
53339
  console.log(source_default.gray(` ${n}: already in sync`));
@@ -53341,7 +53344,7 @@ Scaffolding agent: ${name}
53341
53344
  const semantics = result.changesBySemantics;
53342
53345
  if (semantics) {
53343
53346
  const { hot, staleTillRestart } = semantics;
53344
- const restartRequired = [...semantics.restartRequired, ...unitChanges];
53347
+ const restartRequired = [...semantics.restartRequired];
53345
53348
  if (restartRequired.length > 0) {
53346
53349
  console.log(source_default.yellow(`
53347
53350
  Changed (restart required \u2014 soul/MCP/settings/start.sh):`));
@@ -53384,7 +53387,7 @@ Restart: switchroom agent restart ${n}`));
53384
53387
  }
53385
53388
  }
53386
53389
  const changesBySemantics = result.changesBySemantics;
53387
- const autoRestartNeeded = !opts.noRestart && (changesBySemantics !== undefined && changesBySemantics.restartRequired.length > 0 || unitChanges.length > 0);
53390
+ const autoRestartNeeded = !opts.noRestart && changesBySemantics !== undefined && changesBySemantics.restartRequired.length > 0;
53388
53391
  const shouldRestart = (opts.restart || opts.gracefulRestart || autoRestartNeeded) && allChanges.length > 0;
53389
53392
  if (shouldRestart) {
53390
53393
  try {
@@ -53815,7 +53818,7 @@ switchroom agent add: ${name}
53815
53818
  process.exit(1);
53816
53819
  }
53817
53820
  }));
53818
- agent.command("rename <old> <new>").description("Rename an agent slug: stop, snapshot, rename dir + systemd units + vault key, " + "update switchroom.yaml, reconcile, start. Rolls back on any failure.").option("--hindsight <mode>", 'Hindsight bank handling: "preserve" (default, keep old bank) or "fresh" (drop, recreate on first run). "migrate" is deferred.', "preserve").option("-y, --yes", "Skip confirmation prompt").action(withConfigError(async (oldName, newName, opts) => {
53821
+ agent.command("rename <old> <new>").description("Rename an agent slug: stop, snapshot, rename dir + vault key, " + "update switchroom.yaml, reconcile, start. Rolls back on any failure.").option("--hindsight <mode>", 'Hindsight bank handling: "preserve" (default, keep old bank) or "fresh" (drop, recreate on first run). "migrate" is deferred.', "preserve").option("-y, --yes", "Skip confirmation prompt").action(withConfigError(async (oldName, newName, opts) => {
53819
53822
  const configPath = getConfigPath(program3);
53820
53823
  const config = getConfig(program3);
53821
53824
  if (!config.agents[oldName]) {
@@ -53834,7 +53837,7 @@ switchroom agent add: ${name}
53834
53837
  if (!opts.yes) {
53835
53838
  process.stdout.write(source_default.yellow(`
53836
53839
  Rename agent "${oldName}" \u2192 "${newName}"?
53837
- ` + ` This will: stop ${oldName}, copy dir, rename systemd units,
53840
+ ` + ` This will: stop ${oldName}, copy dir,
53838
53841
  ` + ` rename vault keys (if passphrase available), update switchroom.yaml,
53839
53842
  ` + ` reconcile, and start ${newName}.
53840
53843
  ` + ` Hindsight mode: ${hindsightMode}
@@ -13863,7 +13863,7 @@ var profileFields = {
13863
13863
  extends: exports_external.string().optional(),
13864
13864
  bot_token: exports_external.string().optional(),
13865
13865
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
13866
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
13866
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
13867
13867
  soul: exports_external.object({
13868
13868
  name: exports_external.string().optional(),
13869
13869
  style: exports_external.string().optional(),
@@ -13924,7 +13924,7 @@ var AgentSchema = exports_external.object({
13924
13924
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
13925
13925
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
13926
13926
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
13927
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
13927
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
13928
13928
  auth: exports_external.object({
13929
13929
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
13930
13930
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -11122,7 +11122,7 @@ var init_schema = __esm(() => {
11122
11122
  extends: exports_external.string().optional(),
11123
11123
  bot_token: exports_external.string().optional(),
11124
11124
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11125
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11125
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11126
11126
  soul: exports_external.object({
11127
11127
  name: exports_external.string().optional(),
11128
11128
  style: exports_external.string().optional(),
@@ -11183,7 +11183,7 @@ var init_schema = __esm(() => {
11183
11183
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11184
11184
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11185
11185
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11186
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11186
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11187
11187
  auth: exports_external.object({
11188
11188
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11189
11189
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -11122,7 +11122,7 @@ var init_schema = __esm(() => {
11122
11122
  extends: exports_external.string().optional(),
11123
11123
  bot_token: exports_external.string().optional(),
11124
11124
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11125
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
11125
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
11126
11126
  soul: exports_external.object({
11127
11127
  name: exports_external.string().optional(),
11128
11128
  style: exports_external.string().optional(),
@@ -11183,7 +11183,7 @@ var init_schema = __esm(() => {
11183
11183
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11184
11184
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11185
11185
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11186
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
11186
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11187
11187
  auth: exports_external.object({
11188
11188
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11189
11189
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
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": {
@@ -23766,7 +23766,7 @@ var init_schema = __esm(() => {
23766
23766
  extends: exports_external.string().optional(),
23767
23767
  bot_token: exports_external.string().optional(),
23768
23768
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) \u2014 mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
23769
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the systemd " + "unit as TZ= so subprocess `date`/`Date.now()` are correct. If unset " + "at every cascade layer, switchroom auto-detects from /etc/timezone " + "and warns on `reconcile` when the detected zone is UTC."),
23769
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
23770
23770
  soul: exports_external.object({
23771
23771
  name: exports_external.string().optional(),
23772
23772
  style: exports_external.string().optional(),
@@ -23827,7 +23827,7 @@ var init_schema = __esm(() => {
23827
23827
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
23828
23828
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) \u2014 a pinned agent does " + "not inherit the fleet channel, and vice versa."),
23829
23829
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
23830
- timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "systemd unit's TZ= env."),
23830
+ timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
23831
23831
  auth: exports_external.object({
23832
23832
  override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only \u2014 " + "this agent talks to the named account regardless of fleet active. See RFC H \u00a74.5.")
23833
23833
  }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
@@ -27542,16 +27542,17 @@ function uptimeMsForStarttime(starttimeTicks, fs2 = realProcFs) {
27542
27542
  }
27543
27543
  }
27544
27544
  function nextStepForAgentState(agentName3, state4) {
27545
+ const tailCmd = process.env.SWITCHROOM_RUNTIME === "docker" ? `docker logs --tail 100 switchroom-${agentName3}` : `journalctl --user -u switchroom-${agentName3} -n 100`;
27545
27546
  if (state4 === "failed") {
27546
- return `Service failed \u2014 inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\` then \`switchroom agent restart ${agentName3}\``;
27547
+ return `Service failed \u2014 inspect with \`${tailCmd}\` then \`switchroom agent restart ${agentName3}\``;
27547
27548
  }
27548
27549
  if (state4 === "inactive") {
27549
- return `Service inactive \u2014 start with \`switchroom agent start ${agentName3}\` (or \`systemctl --user start switchroom-${agentName3}\`)`;
27550
+ return `Service inactive \u2014 start with \`switchroom agent start ${agentName3}\``;
27550
27551
  }
27551
27552
  if (state4 === "deactivating" || state4 === "activating" || state4 === "auto-restart") {
27552
27553
  return `Service is in a transient \`${state4}\` state \u2014 re-check with \`switchroom agent status ${agentName3}\` in a few seconds`;
27553
27554
  }
27554
- return `Inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\``;
27555
+ return `Inspect with \`${tailCmd}\``;
27555
27556
  }
27556
27557
  function probeAgentProcessDocker() {
27557
27558
  const found = findAgentProcessInContainer();
@@ -37121,6 +37122,7 @@ function startTimer(deps) {
37121
37122
  import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "node:fs";
37122
37123
  import { dirname as dirname6, join as join6 } from "node:path";
37123
37124
  import { homedir as homedir2 } from "node:os";
37125
+ var SILENT_END_MAX_RETRIES = 1;
37124
37126
  function resolveStateDir(deps) {
37125
37127
  if (deps?.stateDir != null)
37126
37128
  return deps.stateDir;
@@ -37182,6 +37184,27 @@ function clearSilentEndState(turnKey, deps) {
37182
37184
  `);
37183
37185
  } catch {}
37184
37186
  }
37187
+ function readSilentEndState(deps) {
37188
+ const statePath = resolveStatePath(deps);
37189
+ if (!existsSync5(statePath))
37190
+ return null;
37191
+ try {
37192
+ return JSON.parse(readFileSync3(statePath, "utf8"));
37193
+ } catch {
37194
+ return null;
37195
+ }
37196
+ }
37197
+ function recordSilentTurnEnd(args, deps) {
37198
+ const prev = readSilentEndState(deps);
37199
+ if (prev != null && prev.turnKey === args.turnKey && prev.retryCount >= SILENT_END_MAX_RETRIES) {
37200
+ clearSilentEndState(args.turnKey, deps);
37201
+ emitLog(deps, `silent-end: re-prompt exhausted for turnKey=${args.turnKey} ` + `(retryCount=${prev.retryCount} >= ${SILENT_END_MAX_RETRIES}) \u2014 ` + `caller should deliver a fallback
37202
+ `);
37203
+ return { exhausted: true };
37204
+ }
37205
+ writeSilentEndState(args, deps);
37206
+ return { exhausted: false };
37207
+ }
37185
37208
 
37186
37209
  // turn-flush-safety.ts
37187
37210
  var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
@@ -47679,10 +47702,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47679
47702
  }
47680
47703
 
47681
47704
  // ../src/build-info.ts
47682
- var VERSION = "0.13.2";
47683
- var COMMIT_SHA = "afa0fbea";
47684
- var COMMIT_DATE = "2026-05-21T00:58:22Z";
47685
- var LATEST_PR = 1610;
47705
+ var VERSION = "0.13.3";
47706
+ var COMMIT_SHA = "e357d33b";
47707
+ var COMMIT_DATE = "2026-05-21T07:05:47Z";
47708
+ var LATEST_PR = 1621;
47686
47709
  var COMMITS_AHEAD_OF_TAG = 0;
47687
47710
 
47688
47711
  // gateway/boot-version.ts
@@ -48170,6 +48193,7 @@ function resolveCallingSubagent(opts) {
48170
48193
 
48171
48194
  // gateway/gateway.ts
48172
48195
  var REPLY_TO_TEXT_MAX = 200;
48196
+ var SILENT_END_FALLBACK_TEXT = "\u26A0\uFE0F The agent finished working but didn\u2019t send a reply \u2014 your last " + "message may not have been answered. Please try asking again.";
48173
48197
  installStderrTimestamps();
48174
48198
  installPluginLogger();
48175
48199
  installGlobalErrorHandlers();
@@ -51610,11 +51634,19 @@ function handleSessionEvent(ev) {
51610
51634
  ended_via: outboundMetrics.outboundCount > 0 ? "reply" : "silent"
51611
51635
  });
51612
51636
  if (outboundMetrics.outboundCount === 0) {
51613
- writeSilentEndState({
51637
+ const silentEnd = recordSilentTurnEnd({
51614
51638
  chatId,
51615
51639
  threadId: threadId ?? null,
51616
51640
  turnKey: tKey
51617
51641
  });
51642
+ if (silentEnd.exhausted) {
51643
+ process.stderr.write(`telegram gateway: WARN silent-end fallback \u2014 agent stayed ` + `silent after the Stop-hook re-prompt; delivering fallback message chat=${chatId} turnKey=${tKey} (#1161)
51644
+ `);
51645
+ retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(chatId, SILENT_END_FALLBACK_TEXT, tid != null ? { message_thread_id: tid } : {}), { threadId, chat_id: chatId, verb: "silent-end-fallback.sendMessage" }).catch((err) => {
51646
+ process.stderr.write(`telegram gateway: silent-end fallback send failed: ${err instanceof Error ? err.message : String(err)}
51647
+ `);
51648
+ });
51649
+ }
51618
51650
  }
51619
51651
  clear(tKey);
51620
51652
  endTurn(tKey);
@@ -422,24 +422,31 @@ export function uptimeMsForStarttime(
422
422
  }
423
423
 
424
424
  /**
425
- * Compute a remediation hint for a non-active agent systemd state. Returns
425
+ * Compute a remediation hint for a non-active agent state. Returns
426
426
  * `undefined` when no actionable hint applies. Per `reference/principles.md`
427
427
  * principle 1, every degraded/fail row should tell the user what to do next.
428
- * Hints share a common journalctl shape so they're greppable across
429
- * agents.
428
+ *
429
+ * Runtime-aware: v0.7+ agents run in Docker — there is no systemd unit or
430
+ * `journalctl` in-container, so the log-tail command is gated on
431
+ * `SWITCHROOM_RUNTIME` exactly like the boot-card crash row (see
432
+ * `boot-card.ts` and #1376/#1382). All hints share a common log-tail shape
433
+ * so they stay greppable across agents.
430
434
  */
431
435
  function nextStepForAgentState(agentName: string, state: string): string | undefined {
436
+ const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
437
+ ? `docker logs --tail 100 switchroom-${agentName}`
438
+ : `journalctl --user -u switchroom-${agentName} -n 100`
432
439
  if (state === 'failed') {
433
- return `Service failed — inspect with \`journalctl --user -u switchroom-${agentName} -n 100\` then \`switchroom agent restart ${agentName}\``
440
+ return `Service failed — inspect with \`${tailCmd}\` then \`switchroom agent restart ${agentName}\``
434
441
  }
435
442
  if (state === 'inactive') {
436
- return `Service inactive — start with \`switchroom agent start ${agentName}\` (or \`systemctl --user start switchroom-${agentName}\`)`
443
+ return `Service inactive — start with \`switchroom agent start ${agentName}\``
437
444
  }
438
445
  if (state === 'deactivating' || state === 'activating' || state === 'auto-restart') {
439
446
  return `Service is in a transient \`${state}\` state — re-check with \`switchroom agent status ${agentName}\` in a few seconds`
440
447
  }
441
448
  // Unknown state — keep the door open with a generic hint.
442
- return `Inspect with \`journalctl --user -u switchroom-${agentName} -n 100\``
449
+ return `Inspect with \`${tailCmd}\``
443
450
  }
444
451
 
445
452
  function probeAgentProcessDocker(): ProbeResult {
@@ -76,7 +76,7 @@ import {
76
76
  import { emitRuntimeMetric } from '../runtime-metrics.js'
77
77
  import { classifyInbound } from '../inbound-classifier.js'
78
78
  import * as silencePoke from '../silence-poke.js'
79
- import { writeSilentEndState, clearSilentEndState } from '../silent-end.js'
79
+ import { writeSilentEndState, clearSilentEndState, recordSilentTurnEnd } from '../silent-end.js'
80
80
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
81
81
  import { type SessionEvent } from '../session-tail.js'
82
82
  import {
@@ -139,6 +139,16 @@ import { validateStringArray } from './access-validator.js'
139
139
  * identical envelope shapes.
140
140
  */
141
141
  const REPLY_TO_TEXT_MAX = 200
142
+
143
+ /**
144
+ * #1161 — user-facing fallback delivered when a user-message turn ends
145
+ * with zero outbound messages AND the deterministic Stop-hook re-prompt
146
+ * has already been exhausted. Without this the user only sees the
147
+ * progress card vanish; silence must never be the failure mode.
148
+ */
149
+ const SILENT_END_FALLBACK_TEXT =
150
+ '⚠️ The agent finished working but didn’t send a reply — your last ' +
151
+ 'message may not have been answered. Please try asking again.'
142
152
  import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
143
153
  import {
144
154
  validateInlineKeyboard,
@@ -6290,16 +6300,44 @@ function handleSessionEvent(ev: SessionEvent): void {
6290
6300
  longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
6291
6301
  ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
6292
6302
  })
6293
- // #1122 PR4 fix: deterministic silent-end detection (see the
6294
- // silent-marker path above for the rationale). The Stop hook
6295
- // reads the file we write here and blocks the session-end so
6296
- // the agent can be re-prompted to call reply.
6303
+ // #1122 PR4 / #1161: deterministic silent-end handling (see the
6304
+ // silent-marker path above for the rationale).
6305
+ // - first silent-end recordSilentTurnEnd writes the state
6306
+ // file so the Stop hook (silent-end-interrupt-stop.mjs)
6307
+ // blocks the session-end and re-prompts the agent to reply.
6308
+ // - the Stop-hook re-prompt is already spent and the agent is
6309
+ // STILL silent → recordSilentTurnEnd returns exhausted:true;
6310
+ // deliver a user-facing fallback so the turn never just
6311
+ // vanishes (the user otherwise only sees the card disappear).
6297
6312
  if (outboundMetrics.outboundCount === 0) {
6298
- writeSilentEndState({
6313
+ const silentEnd = recordSilentTurnEnd({
6299
6314
  chatId,
6300
6315
  threadId: threadId ?? null,
6301
6316
  turnKey: tKey,
6302
6317
  })
6318
+ if (silentEnd.exhausted) {
6319
+ process.stderr.write(
6320
+ `telegram gateway: WARN silent-end fallback — agent stayed ` +
6321
+ `silent after the Stop-hook re-prompt; delivering fallback ` +
6322
+ `message chat=${chatId} turnKey=${tKey} (#1161)\n`,
6323
+ )
6324
+ void retryWithThreadFallback(
6325
+ robustApiCall,
6326
+ (tid) =>
6327
+ bot.api.sendMessage(
6328
+ chatId,
6329
+ SILENT_END_FALLBACK_TEXT,
6330
+ tid != null ? { message_thread_id: tid } : {},
6331
+ ),
6332
+ { threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
6333
+ ).catch((err) => {
6334
+ process.stderr.write(
6335
+ `telegram gateway: silent-end fallback send failed: ${
6336
+ err instanceof Error ? err.message : String(err)
6337
+ }\n`,
6338
+ )
6339
+ })
6340
+ }
6303
6341
  }
6304
6342
  signalTracker.clear(tKey)
6305
6343
  silencePoke.endTurn(tKey)
@@ -9,7 +9,9 @@
9
9
  * decision:block to re-prompt the agent instead of letting the session close.
10
10
  *
11
11
  * On the second silent-end (retryCount >= MAX_RETRIES), the hook allows the
12
- * stop so the gateway can render the "🙊 Ended without reply" warning card.
12
+ * stop. The gateway's turn-end path (recordSilentTurnEnd in silent-end.ts)
13
+ * detects the exhausted re-prompt and delivers a user-facing fallback
14
+ * message so the turn never silently vanishes (#1161).
13
15
  *
14
16
  * Carve-outs preserved:
15
17
  * - wasAutonomous=true turns: the gateway never writes a state file for
@@ -30,6 +32,8 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
30
32
  import { join } from 'node:path'
31
33
  import { homedir } from 'node:os'
32
34
 
35
+ // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
36
+ // (this hook is a standalone .mjs and can't import the TS module).
33
37
  const MAX_RETRIES = 1
34
38
 
35
39
  function readStdin() {
@@ -51,6 +51,14 @@ export interface SilentEndDeps {
51
51
  log?: (line: string) => void
52
52
  }
53
53
 
54
+ /**
55
+ * How many times the Stop hook re-prompts a silent-end turn before it
56
+ * gives up. MUST stay in sync with `MAX_RETRIES` in the Stop hook
57
+ * (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`) — the hook is a
58
+ * standalone `.mjs` and can't import this module.
59
+ */
60
+ export const SILENT_END_MAX_RETRIES = 1
61
+
54
62
  function resolveStateDir(deps?: SilentEndDeps): string {
55
63
  if (deps?.stateDir != null) return deps.stateDir
56
64
  const env = process.env.TELEGRAM_STATE_DIR
@@ -172,3 +180,51 @@ export function readSilentEndState(deps?: SilentEndDeps): SilentEndState | null
172
180
  return null
173
181
  }
174
182
  }
183
+
184
+ /**
185
+ * Record a user-message turn that ended with zero outbound messages and
186
+ * report whether the deterministic re-prompt has been exhausted. This is
187
+ * the gateway's single entry point for the main turn-end path.
188
+ *
189
+ * - First silent-end of a turn (no prior state, or prior `retryCount`
190
+ * still below `SILENT_END_MAX_RETRIES`) → writes the state file via
191
+ * `writeSilentEndState`, so `silent-end-interrupt-stop.mjs` blocks
192
+ * the stop and re-prompts the agent. Returns `{ exhausted: false }`.
193
+ *
194
+ * - A silent-end where the prior state for the SAME turn already shows
195
+ * `retryCount >= SILENT_END_MAX_RETRIES` → the Stop hook already
196
+ * spent its re-prompt and the agent is STILL silent. Recovery has
197
+ * failed. Clears the state file (so the Stop hook on this final turn
198
+ * finds nothing pending and allows the stop cleanly) and returns
199
+ * `{ exhausted: true }` — the caller MUST then deliver a user-facing
200
+ * fallback so the turn never just vanishes (#1161).
201
+ *
202
+ * Chat-less autonomous wakeup turns never reach here: the gateway only
203
+ * creates a `currentTurn` (and therefore only runs a turn-end handler)
204
+ * when the inbound event carries a chat id. Cron-fired turns DO carry a
205
+ * topic chat and reach this path — a cron task that means to stay silent
206
+ * must emit a NO_REPLY sentinel, which routes to the gateway's
207
+ * silent-marker branch and never gets a fallback.
208
+ */
209
+ export function recordSilentTurnEnd(
210
+ args: { chatId: string; threadId: number | null; turnKey: string },
211
+ deps?: SilentEndDeps,
212
+ ): { exhausted: boolean } {
213
+ const prev = readSilentEndState(deps)
214
+ if (
215
+ prev != null &&
216
+ prev.turnKey === args.turnKey &&
217
+ prev.retryCount >= SILENT_END_MAX_RETRIES
218
+ ) {
219
+ clearSilentEndState(args.turnKey, deps)
220
+ emitLog(
221
+ deps,
222
+ `silent-end: re-prompt exhausted for turnKey=${args.turnKey} ` +
223
+ `(retryCount=${prev.retryCount} >= ${SILENT_END_MAX_RETRIES}) — ` +
224
+ `caller should deliver a fallback\n`,
225
+ )
226
+ return { exhausted: true }
227
+ }
228
+ writeSilentEndState(args, deps)
229
+ return { exhausted: false }
230
+ }
@@ -1185,8 +1185,15 @@ describe('uptimeMsForStarttime', () => {
1185
1185
  // the probes covered by the boot-card-dedup-and-next-steps PR so we don't
1186
1186
  // silently lose the hint on a future refactor.
1187
1187
 
1188
- describe('nextStep — agent systemd states', () => {
1189
- it('attaches a journalctl hint when the unit is failed', async () => {
1188
+ describe('nextStep — agent states', () => {
1189
+ const savedRuntime = process.env.SWITCHROOM_RUNTIME
1190
+ afterEach(() => {
1191
+ if (savedRuntime === undefined) delete process.env.SWITCHROOM_RUNTIME
1192
+ else process.env.SWITCHROOM_RUNTIME = savedRuntime
1193
+ })
1194
+
1195
+ it('attaches a journalctl hint when the unit is failed (non-docker runtime)', async () => {
1196
+ delete process.env.SWITCHROOM_RUNTIME
1190
1197
  const exec = makeSequence([makeSystemctlOutput('failed')])
1191
1198
  const r = await probeAgentProcess('klanker', {
1192
1199
  execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
@@ -1199,6 +1206,23 @@ describe('nextStep — agent systemd states', () => {
1199
1206
  expect(r.nextStep).toMatch(/switchroom-klanker/)
1200
1207
  })
1201
1208
 
1209
+ // #1382: the failed/unknown-state hints must follow SWITCHROOM_RUNTIME the
1210
+ // same way the boot-card crash row does (#1376) — no journalctl in-container.
1211
+ it('attaches a docker-logs hint when the unit is failed under SWITCHROOM_RUNTIME=docker', async () => {
1212
+ process.env.SWITCHROOM_RUNTIME = 'docker'
1213
+ const exec = makeSequence([makeSystemctlOutput('failed')])
1214
+ const r = await probeAgentProcess('klanker', {
1215
+ execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
1216
+ sleepImpl: async () => {},
1217
+ retryIntervalMs: 1,
1218
+ retryMaxMs: 0,
1219
+ })
1220
+ expect(r.status).toBe('fail')
1221
+ expect(r.nextStep).toMatch(/docker logs/)
1222
+ expect(r.nextStep).toMatch(/switchroom-klanker/)
1223
+ expect(r.nextStep).not.toMatch(/journalctl/)
1224
+ })
1225
+
1202
1226
  it('attaches a transient-state hint when the unit is activating after retry budget', async () => {
1203
1227
  const exec = makeSequence([makeSystemctlOutput('activating')])
1204
1228
  const r = await probeAgentProcess('klanker', {