switchroom 0.21.7 → 0.21.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/tmp-reaper.sh +234 -0
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +2 -2
  4. package/dist/cli/notion-write-pretool.mjs +1 -1
  5. package/dist/cli/switchroom.js +3421 -2744
  6. package/dist/host-control/main.js +177 -13
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +5 -4
  10. package/profiles/_base/start.sh.hbs +115 -0
  11. package/profiles/_shared/local-time.md.hbs +6 -0
  12. package/profiles/default/CLAUDE.md.hbs +0 -12
  13. package/telegram-plugin/dist/gateway/gateway.js +1017 -465
  14. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  16. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  17. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  19. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  20. package/telegram-plugin/gateway/gateway.ts +12 -10
  21. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  22. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  23. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  25. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  26. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  27. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  28. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  29. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  30. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  31. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  32. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  33. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  34. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
@@ -11763,7 +11763,7 @@ var init_schema = __esm(() => {
11763
11763
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
11764
11764
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
11765
11765
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
11766
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11766
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
11767
11767
  experimental: exports_external.object({
11768
11768
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
11769
11769
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
21565
21565
  }
21566
21566
 
21567
21567
  // src/build-info.ts
21568
- var VERSION = "0.21.7";
21568
+ var VERSION = "0.21.8";
21569
21569
 
21570
21570
  // src/setup/hindsight-recall-passthrough.ts
21571
21571
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
@@ -21649,7 +21649,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
21649
21649
  return typeof value === "number" && Number.isFinite(value);
21650
21650
  });
21651
21651
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
21652
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
21652
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol", "local-time"];
21653
21653
  for (const name of SHARED_FRAGMENTS) {
21654
21654
  const fragPath = join3(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
21655
21655
  if (existsSync6(fragPath)) {
@@ -22357,6 +22357,7 @@ import { mkdir as mkdir2, chmod, chown, unlink, appendFile } from "node:fs/promi
22357
22357
  import {
22358
22358
  readdirSync as readdirSync6,
22359
22359
  existsSync as existsSync17,
22360
+ statSync as statSync9,
22360
22361
  readFileSync as readFileSync13,
22361
22362
  writeFileSync as writeFileSync7,
22362
22363
  renameSync as renameSync6,
@@ -22497,13 +22498,14 @@ var AgentSmokeRequestSchema = exports_external.object({
22497
22498
  deep: exports_external.boolean().optional()
22498
22499
  })
22499
22500
  });
22501
+ var CANONICAL_CONFIG_PATH = "/state/config/switchroom.yaml";
22500
22502
  var ConfigProposeEditRequestSchema = exports_external.object({
22501
22503
  ...RequestEnvelope,
22502
22504
  op: exports_external.literal("config_propose_edit"),
22503
22505
  args: exports_external.object({
22504
22506
  unified_diff: exports_external.string().min(1).max(MAX_FRAME_BYTES2 - 1024),
22505
22507
  reason: exports_external.string().min(1).max(500),
22506
- target_path: exports_external.literal("/state/config/switchroom.yaml")
22508
+ target_path: exports_external.literal(CANONICAL_CONFIG_PATH)
22507
22509
  })
22508
22510
  });
22509
22511
  var RequestSchema2 = exports_external.discriminatedUnion("op", [
@@ -30376,6 +30378,43 @@ function writeFileInPlacePreservingInode(targetPath, content) {
30376
30378
  throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
30377
30379
  }
30378
30380
  }
30381
+ function verifyConfigWriteObserved(configPath, expected, snapshot, proposedChangedPaths) {
30382
+ const expectedBuf = Buffer.from(expected, "utf-8");
30383
+ let observed;
30384
+ let size = null;
30385
+ let mtimeMs = null;
30386
+ try {
30387
+ observed = readFileSync13(configPath);
30388
+ const st = statSync9(configPath);
30389
+ size = st.size;
30390
+ mtimeMs = st.mtimeMs;
30391
+ } catch (e) {
30392
+ return {
30393
+ ok: false,
30394
+ reasons: [`post-write read-back of ${configPath} failed: ${e.message}`],
30395
+ observed: null,
30396
+ size: null,
30397
+ mtimeMs: null
30398
+ };
30399
+ }
30400
+ const reasons = [];
30401
+ if (!observed.equals(expectedBuf)) {
30402
+ let firstDiff = 0;
30403
+ const min = Math.min(observed.length, expectedBuf.length);
30404
+ while (firstDiff < min && observed[firstDiff] === expectedBuf[firstDiff]) {
30405
+ firstDiff += 1;
30406
+ }
30407
+ reasons.push(`written bytes are not observable at ${configPath} ` + `(expected ${expectedBuf.length} bytes, read back ${observed.length}, ` + `first difference at offset ${firstDiff})`);
30408
+ }
30409
+ const observedChangedPaths = classifyBlastRadius(snapshot, observed.toString("utf-8")).changedPaths;
30410
+ const approvedSet = new Set(proposedChangedPaths);
30411
+ const observedSet = new Set(observedChangedPaths);
30412
+ const sameSet = approvedSet.size === observedSet.size && [...approvedSet].every((p) => observedSet.has(p));
30413
+ if (!sameSet) {
30414
+ reasons.push(`on-disk change set diverged from what was approved ` + `(approved: [${[...approvedSet].sort().join(", ")}]; ` + `observed on disk: [${[...observedSet].sort().join(", ")}])`);
30415
+ }
30416
+ return { ok: reasons.length === 0, reasons, observed, size, mtimeMs };
30417
+ }
30379
30418
  var STATUS_RETENTION_MS = 10 * 60 * 1000;
30380
30419
  var STATUS_MAX_ENTRIES = 256;
30381
30420
  var TAIL_BYTES = 4096;
@@ -30385,7 +30424,71 @@ function isAutoRolloutRequestId(requestId) {
30385
30424
  return requestId.startsWith(AUTO_ROLLOUT_REQUEST_PREFIX);
30386
30425
  }
30387
30426
  var AUTO_ROLLOUT_LATCH_FILENAME = "auto-rollout-latch.json";
30388
- var HOSTD_FALLBACK_CONFIG_PATH = "/state/config/switchroom.yaml";
30427
+ var HOSTD_FALLBACK_CONFIG_PATH = CANONICAL_CONFIG_PATH;
30428
+ function statIdentity(path3) {
30429
+ const st = statSync9(path3);
30430
+ return { dev: st.dev, ino: st.ino };
30431
+ }
30432
+ function checkConfigPathProvenance(writePath, resolveFleetConfig = findConfigFile, identify = statIdentity) {
30433
+ let resolvedPath;
30434
+ try {
30435
+ resolvedPath = resolveFleetConfig();
30436
+ } catch (e) {
30437
+ return {
30438
+ ok: true,
30439
+ writePath,
30440
+ resolvedPath: null,
30441
+ identityChecked: false,
30442
+ detail: `fleet config resolver could not name a config file ` + `(${e.message}) — nothing to compare against ${writePath}`
30443
+ };
30444
+ }
30445
+ if (resolvedPath === writePath) {
30446
+ return {
30447
+ ok: true,
30448
+ writePath,
30449
+ resolvedPath,
30450
+ identityChecked: false,
30451
+ detail: `write target and fleet config path are the same string (${writePath})`
30452
+ };
30453
+ }
30454
+ let write;
30455
+ let fleet;
30456
+ try {
30457
+ write = identify(writePath);
30458
+ fleet = identify(resolvedPath);
30459
+ } catch (e) {
30460
+ return {
30461
+ ok: false,
30462
+ writePath,
30463
+ resolvedPath,
30464
+ identityChecked: false,
30465
+ detail: `write target ${writePath} differs from the fleet config path ` + `${resolvedPath}, and file identity could not be established ` + `(${e.message}) — compared as strings, not inodes`
30466
+ };
30467
+ }
30468
+ if (write.dev === fleet.dev && write.ino === fleet.ino) {
30469
+ return {
30470
+ ok: true,
30471
+ writePath,
30472
+ resolvedPath,
30473
+ identityChecked: true,
30474
+ detail: `write target ${writePath} and fleet config path ${resolvedPath} are ` + `two names for the SAME file (dev=${write.dev} ino=${write.ino})`
30475
+ };
30476
+ }
30477
+ return {
30478
+ ok: false,
30479
+ writePath,
30480
+ resolvedPath,
30481
+ identityChecked: true,
30482
+ detail: `write target ${writePath} (dev=${write.dev} ino=${write.ino}) is a ` + `DIFFERENT file from the config the fleet reads, ${resolvedPath} ` + `(dev=${fleet.dev} ino=${fleet.ino}) — a write here would be invisible ` + `to every other reader`
30483
+ };
30484
+ }
30485
+ var CONFIG_PATH_PROVENANCE_TAG = "hostd-config-path-provenance";
30486
+ function configPathProvenanceWarning(resolveFleetConfig = findConfigFile, identify = statIdentity) {
30487
+ const prov = checkConfigPathProvenance(CANONICAL_CONFIG_PATH, resolveFleetConfig, identify);
30488
+ if (prov.ok)
30489
+ return null;
30490
+ return `${CONFIG_PATH_PROVENANCE_TAG}: ${prov.detail}. ` + `config_propose_edit will REFUSE with E_CONFIG_PATH_MISMATCH until this ` + `agrees — check the hostd bind mount over ${CANONICAL_CONFIG_PATH} and ` + `the SWITCHROOM_CONFIG it exports alongside it.`;
30491
+ }
30389
30492
  function resolveHostdConfigPath(explicit) {
30390
30493
  if (explicit)
30391
30494
  return explicit;
@@ -31572,6 +31675,12 @@ class HostdServer {
31572
31675
  return err("E_CONFIG_EDIT_DISABLED", "config_propose_edit is disabled").why("operator opt-in per RFC §3.3").fixFlipFlag("hostd.config_edit_enabled", true).docs("https://switchroom.dev/docs/config-edit#opt-in").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31573
31676
  }
31574
31677
  const configPath = this.opts.configPath ?? req.args.target_path;
31678
+ if (this.opts.configPath === undefined) {
31679
+ const provenance = checkConfigPathProvenance(configPath, this.opts.resolveFleetConfigPath ?? findConfigFile, this.opts.identifyForProvenance ?? statIdentity);
31680
+ if (!provenance.ok) {
31681
+ return this.configPathMismatch(provenance.detail, req, caller, started);
31682
+ }
31683
+ }
31575
31684
  const verdict = validateConfigEdit({
31576
31685
  configPath,
31577
31686
  targetPath: req.args.target_path,
@@ -31746,10 +31855,10 @@ class HostdServer {
31746
31855
  }
31747
31856
  }
31748
31857
  try {
31749
- writeFileInPlacePreservingInode(configPath, postApplyFresh);
31858
+ this.writeLiveConfig(configPath, postApplyFresh);
31750
31859
  } catch (e) {
31751
31860
  try {
31752
- writeFileInPlacePreservingInode(configPath, snapshot);
31861
+ this.writeLiveConfig(configPath, snapshot);
31753
31862
  } catch {}
31754
31863
  await approval.finalize({
31755
31864
  outcome: "reconcile_failed_rolled_back",
@@ -31757,8 +31866,31 @@ class HostdServer {
31757
31866
  });
31758
31867
  return this.reconcileFailedRolledBack(`write failed: ${e.message}`, req, caller, started);
31759
31868
  }
31760
- const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(caller.kind === "agent" ? ["apply", "--only", callerName, "--non-interactive"] : ["apply", "--non-interactive"]));
31761
- const recRes = await runner({ requestId: approvalId });
31869
+ const observation = verifyConfigWriteObserved(configPath, postApplyFresh, snapshot, proposedChangedPaths);
31870
+ if (!observation.ok) {
31871
+ const why = `post-write verification failed: ${observation.reasons.join("; ")}`;
31872
+ const snapshotBuf = Buffer.from(snapshot, "utf-8");
31873
+ const alreadySnapshot = observation.observed !== null && observation.observed.equals(snapshotBuf);
31874
+ let restoreDetail;
31875
+ if (alreadySnapshot) {
31876
+ restoreDetail = "live config already byte-identical to the pre-write snapshot; no restore needed";
31877
+ } else {
31878
+ try {
31879
+ this.writeLiveConfig(configPath, snapshot);
31880
+ restoreDetail = "rolled back to the pre-write snapshot";
31881
+ } catch (e) {
31882
+ restoreDetail = `SNAPSHOT RESTORE ALSO FAILED: ${e.message} — ` + `live config at ${configPath} is in an UNKNOWN state, inspect it by hand`;
31883
+ }
31884
+ }
31885
+ await approval.finalize({
31886
+ outcome: "reconcile_failed_rolled_back",
31887
+ detail: `${why}; ${restoreDetail}`
31888
+ });
31889
+ return this.writeNotObserved(`${why}; ${restoreDetail}`, req, caller, started);
31890
+ }
31891
+ const reconcileEnv = { SWITCHROOM_CONFIG: configPath };
31892
+ const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(caller.kind === "agent" ? ["apply", "--only", callerName, "--non-interactive"] : ["apply", "--non-interactive"], reconcileEnv));
31893
+ const recRes = await runner({ requestId: approvalId, env: reconcileEnv });
31762
31894
  if (recRes.exit_code === 0) {
31763
31895
  const blast = classifyBlastRadius(snapshot, postApplyFresh);
31764
31896
  await approval.finalize({
@@ -31766,19 +31898,21 @@ class HostdServer {
31766
31898
  affectedAgents: blast.agents,
31767
31899
  fleetWide: blast.fleetWide
31768
31900
  });
31901
+ const writeEvidence = `config write observed: path=${configPath} ` + `size=${observation.size} mtime_ms=${observation.mtimeMs}`;
31769
31902
  return {
31770
31903
  v: 1,
31771
31904
  request_id: req.request_id,
31772
31905
  result: "completed",
31773
31906
  exit_code: 0,
31774
31907
  duration_ms: Date.now() - started,
31775
- stdout_tail: tail(recRes.stdout),
31908
+ stdout_tail: tail(`${recRes.stdout}
31909
+ --- ${writeEvidence} ---`),
31776
31910
  stderr_tail: tail(recRes.stderr)
31777
31911
  };
31778
31912
  }
31779
31913
  let rollbackDetail = "";
31780
31914
  try {
31781
- writeFileInPlacePreservingInode(configPath, snapshot);
31915
+ this.writeLiveConfig(configPath, snapshot);
31782
31916
  } catch (e) {
31783
31917
  rollbackDetail = `snapshot restore failed: ${e.message}`;
31784
31918
  await approval.finalize({
@@ -31787,7 +31921,7 @@ class HostdServer {
31787
31921
  });
31788
31922
  return this.reconcileFailedRolledBack(rollbackDetail, req, caller, started);
31789
31923
  }
31790
- const recRes2 = await runner({ requestId: approvalId });
31924
+ const recRes2 = await runner({ requestId: approvalId, env: reconcileEnv });
31791
31925
  const recoveryNote = recRes2.exit_code === 0 ? "rolled back successfully" : `rolled back but recovery reconcile also failed (exit ${recRes2.exit_code})`;
31792
31926
  await approval.finalize({
31793
31927
  outcome: "reconcile_failed_rolled_back",
@@ -31803,6 +31937,27 @@ class HostdServer {
31803
31937
  const built = err("E_CONFIG_CHANGED", "config changed since the proposal was validated; re-propose against the current config").why(why).fixBadInput("unified_diff").op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).asDenied().build(req.request_id, Date.now() - started);
31804
31938
  return { ...built, error: legacy };
31805
31939
  }
31940
+ writeLiveConfig(path3, content) {
31941
+ (this.opts.writeConfigFile ?? writeFileInPlacePreservingInode)(path3, content);
31942
+ }
31943
+ writeNotObserved(detail, req, caller, started) {
31944
+ const legacy = `E_WRITE_NOT_OBSERVED: ${detail}`;
31945
+ const built = err("E_WRITE_NOT_OBSERVED", "config write could not be observed on the target file; apply aborted before reconcile").why(detail).fixOperatorAction("infra", [
31946
+ "confirm hostd reads and writes the SAME switchroom.yaml the fleet reads (check the hostd bind mount over /state/config/switchroom.yaml)",
31947
+ "check for a competing writer to the config (operator hand-edit or editor daemon) during the apply window",
31948
+ "inspect the live config by hand before re-proposing — the diff was valid, the write path was not"
31949
+ ]).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
31950
+ return { ...built, error: legacy };
31951
+ }
31952
+ configPathMismatch(detail, req, caller, started) {
31953
+ const legacy = `E_CONFIG_PATH_MISMATCH: ${detail}`;
31954
+ const built = err("E_CONFIG_PATH_MISMATCH", "hostd would write a different switchroom.yaml than the fleet reads; apply refused before any write").why(detail).fixOperatorAction("infra", [
31955
+ `confirm the hostd container bind-mounts the live switchroom.yaml onto ${CANONICAL_CONFIG_PATH}`,
31956
+ "confirm SWITCHROOM_CONFIG inside the hostd container points at that same path (hostd install exports it alongside the mount)",
31957
+ "re-propose once the two agree — the diff was never applied, nothing was written"
31958
+ ]).op("config_propose_edit").caller(caller.kind === "agent" ? "agent" : "operator").agentName(caller.kind === "agent" ? caller.name : undefined).build(req.request_id, Date.now() - started);
31959
+ return { ...built, error: legacy };
31960
+ }
31806
31961
  reconcileFailedRolledBack(detail, req, caller, started, output) {
31807
31962
  const legacy = `E_RECONCILE_FAILED_ROLLED_BACK: ${detail}`;
31808
31963
  const built = err("E_RECONCILE_FAILED_ROLLED_BACK", "config write or reconcile failed; live file rolled back to snapshot").why(detail).fixOperatorAction("infra", [
@@ -31878,7 +32033,11 @@ ${output.recovery.stderr}` : "";
31878
32033
  name: "bot_token",
31879
32034
  cmd: "test -f /state/agent/telegram/.env && grep -qE '^TELEGRAM_BOT_TOKEN=[0-9]+:' /state/agent/telegram/.env"
31880
32035
  },
31881
- { name: "state", cmd: "test -w /state/agent" }
32036
+ { name: "state", cmd: "test -w /state/agent" },
32037
+ {
32038
+ name: "tzdata",
32039
+ cmd: "test ! -L /etc/localtime && python3 -c 'import sys;" + "from datetime import datetime;" + "from zoneinfo import ZoneInfo;" + 'sys.exit(0 if datetime.now(ZoneInfo("Etc/UTC")).utcoffset()' + ".total_seconds()==0 else 1)'"
32040
+ }
31882
32041
  ];
31883
32042
  if (req.args.deep) {
31884
32043
  PROBES.push({
@@ -34146,6 +34305,11 @@ async function main() {
34146
34305
  `);
34147
34306
  process.exit(2);
34148
34307
  }
34308
+ const provenanceWarning = configPathProvenanceWarning();
34309
+ if (provenanceWarning !== null) {
34310
+ process.stderr.write(`hostd: ${provenanceWarning}
34311
+ `);
34312
+ }
34149
34313
  const agentUids = {};
34150
34314
  for (const name of Object.keys(config.agents)) {
34151
34315
  agentUids[name] = allocateAgentUid(name);
@@ -5203,7 +5203,7 @@ var init_schema = __esm(() => {
5203
5203
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
5204
5204
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
5205
5205
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
5206
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
5206
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
5207
5207
  experimental: exports_external.object({
5208
5208
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
5209
5209
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
@@ -19544,7 +19544,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
19544
19544
  return typeof value === "number" && Number.isFinite(value);
19545
19545
  });
19546
19546
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
19547
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
19547
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol", "local-time"];
19548
19548
  for (const name of SHARED_FRAGMENTS) {
19549
19549
  const fragPath = join2(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
19550
19550
  if (existsSync5(fragPath)) {
@@ -4799,7 +4799,7 @@ var init_schema = __esm(() => {
4799
4799
  repos: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Repo slug must be kebab-case ASCII: start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens"), exports_external.object({
4800
4800
  url: exports_external.string().min(1).describe("Git remote URL for the repo (e.g. 'git@github.com:org/repo.git' or " + "'https://github.com/org/repo.git'). Used verbatim for git clone."),
4801
4801
  branch_default: exports_external.string().optional().describe("Default branch to track (defaults to the remote's HEAD, typically 'main'). " + "The per-agent branch 'agent/<agentName>/main' fast-forwards to this branch " + "when the worktree is clean on session start.")
4802
- })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated worktree for each " + "repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main, backed by a " + "shared bare clone at ~/.switchroom/repos/<slug>.git. The worktree path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
4802
+ })).optional().describe("Repos this agent operates on. Switchroom provisions a dedicated standing tree for " + "each repo at <agentDir>/work/<slug>/ on branch agent/<agentName>/main an " + "independent clone (own refs/stash, hardlinked objects) seeded from a shared bare " + "clone at ~/.switchroom/repos/<slug>.git. The tree's path is injected " + "into the agent's environment as SWITCHROOM_REPO_<SLUG_UPPER>. " + "Agents without this field continue to work unchanged."),
4803
4803
  experimental: exports_external.object({
4804
4804
  legacy_pty: exports_external.boolean().optional().describe("Opt out of the default tmux supervisor (#725) and run the agent " + "under the legacy PTY supervisor instead. Default: false."),
4805
4805
  legacy_autoaccept_expect: exports_external.boolean().optional().describe("Opt the autoaccept gateway back into the legacy expect-script " + "behaviour instead of the tmux send-keys path. Default: false.")
@@ -19957,7 +19957,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
19957
19957
  return typeof value === "number" && Number.isFinite(value);
19958
19958
  });
19959
19959
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
19960
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
19960
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol", "local-time"];
19961
19961
  for (const name of SHARED_FRAGMENTS) {
19962
19962
  const fragPath = join2(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
19963
19963
  if (existsSync5(fragPath)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.7",
4
+ "version": "0.21.8",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.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/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.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/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.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 telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/hermes-messages-paging.test.ts telegram-plugin/tests/hermes-session-search.test.ts telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.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/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.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/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.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 telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.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-scope-persist.test.ts src/vault/broker/server-tokenless-scope.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.test.ts src/vault/approvals/self-approval-bypass.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/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.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/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
+ "test:bun": "bun test telegram-plugin/tests/hermes-messages-paging.test.ts telegram-plugin/tests/hermes-session-search.test.ts telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.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-scope-persist.test.ts src/vault/broker/server-tokenless-scope.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.test.ts src/vault/approvals/self-approval-bypass.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/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.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/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.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/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "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-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-parked-turn-start-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
31
+ "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-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-parked-turn-start-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-claude-cli-lockstep.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
34
  "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
@@ -53,6 +53,7 @@
53
53
  "lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
54
54
  "lint:release-asset-contract": "node scripts/check-release-asset-names.mjs",
55
55
  "lint:foreign-db-readonly": "node scripts/check-foreign-db-readonly.mjs",
56
+ "lint:claude-cli-lockstep": "node scripts/check-claude-cli-lockstep.mjs",
56
57
  "lint:changelog-entry": "node scripts/check-changelog-entry.mjs",
57
58
  "changelog:generate": "node scripts/gen-changelog-entry.mjs",
58
59
  "lint:agent-attribution-trailers": "node scripts/check-agent-attribution-trailers.mjs",
@@ -33,6 +33,31 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
33
33
  # same path the rest of start.sh + the MCP sidecar expects.
34
34
  export TELEGRAM_STATE_DIR="{{agentDir}}/telegram"
35
35
 
36
+ # --- Boot-resume generation token: OPEN it (switchroom#4641) -------------
37
+ #
38
+ # The gateway's boot-resume block (orphan-turn reaper, `resume_interrupted`
39
+ # synthetic, bridge-dead marker, `.pending-turn.env`) is a ONCE-PER-CONTAINER-
40
+ # BOOT action. But the gateway is a SUPERVISED SIDECAR: when it crashes the
41
+ # supervisor respawns it and its boot block runs again — against a claude
42
+ # session that never stopped. That is #4641: it stamped the still-executing
43
+ # turn `ended_via='restart'` and told the live session it had been killed.
44
+ #
45
+ # THIS line is the whole generation mechanism. Deleting the token here — in
46
+ # the outer pass, exactly once per container boot, BEFORE the gateway is
47
+ # forked below and where the supervisor can never re-run it — is what makes
48
+ # "the token exists" mean "a gateway in this same container generation
49
+ # already completed its boot resume". The gateway writes the token at the
50
+ # END of that block (agent-process-liveness.ts `markBootResumeComplete`) and
51
+ # skips the block entirely when it finds one. No timing, no clock
52
+ # comparison: a gateway that dies mid-boot leaves no token, so its
53
+ # replacement redoes the work and a genuinely interrupted turn is never lost.
54
+ #
55
+ # `agent-process.json` (written by the inner pass just before it `exec`s the
56
+ # agent) goes too: it names a pid from the PREVIOUS generation, and the
57
+ # pid namespace is reset by a container restart, so leaving it risks a
58
+ # (pid, starttime) collision against an unrelated live process.
59
+ rm -f "$TELEGRAM_STATE_DIR/.boot-resume-done" "$TELEGRAM_STATE_DIR/agent-process.json" 2>/dev/null || true
60
+
36
61
  # SWITCHROOM_AGENT_NAME is the canonical "which agent am I" identity. It is
37
62
  # normally supplied by the container env (compose.ts) so it is already
38
63
  # present here, but the authoritative inner-pass export lives at line ~387,
@@ -538,6 +563,32 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
538
563
  bun /opt/switchroom/agent-scheduler/index.js &
539
564
  fi
540
565
 
566
+ # 3a) switchroom-tmp-reaper — bounded janitor for the /tmp tmpfs.
567
+ #
568
+ # /tmp is a RAM-backed tmpfs sized by `resources.tmp_size`
569
+ # (DEFAULT_TMP_SIZE = 2g) and mounted noexec by Docker's tmpfs
570
+ # defaults. Nothing ages out of a tmpfs, so a long-running container
571
+ # accumulates every scratch dir any tool forgot to remove until writes
572
+ # start failing ENOSPC — and the failure surfaces in the victim (npm
573
+ # ci, git, the CLI's own staging), never at the cause. Observed live:
574
+ # 4,462 top-level entries and 490 MiB used, 204 MiB of it two orphaned
575
+ # compile artifacts from one test run.
576
+ #
577
+ # Deliberately NOT "raise tmp_size": a bigger tmpfs converts a fast,
578
+ # loud failure into a slow leak against host RAM. The safety contract
579
+ # (age floor, open-file check, /tmp-only scope) lives in the script's
580
+ # header and is pinned by tests/tmp-reaper.test.ts.
581
+ #
582
+ # Kill switch: SWITCHROOM_TMP_REAPER=0 in the container env. Supervised
583
+ # like the other sidecars so a crash self-heals and its log rotates;
584
+ # NOT --oneshot-ok, because a clean exit from an endless loop is
585
+ # abnormal and must restart.
586
+ if [ "$SWITCHROOM_TMP_REAPER" != "0" ] \
587
+ && [ -x /opt/switchroom/bin/tmp-reaper.sh ]; then
588
+ _switchroom_supervise tmp-reaper /var/log/switchroom/tmp-reaper.log \
589
+ bash /opt/switchroom/bin/tmp-reaper.sh &
590
+ fi
591
+
541
592
  # 3b) Buzz co-channel inbound sidecar (Phase 1, channels.buzz).
542
593
  # A supervised sibling that opens a WebSocket Nostr subscription to a
543
594
  # closed Buzz relay, NIP-42-authenticates, and injects allowlisted
@@ -2475,6 +2526,70 @@ fi
2475
2526
  rm -f "$_session_model_resolved_tmp"
2476
2527
  unset _session_model_resolved_tmp
2477
2528
 
2529
+ # --- Agent-process identity record (switchroom#4641) ---
2530
+ #
2531
+ # The gateway and the agent are SEPARATE processes: the gateway runs as a
2532
+ # supervised sidecar (see `_switchroom_supervise gateway` above) while THIS
2533
+ # shell becomes the agent via `exec claude` below. A gateway crash respawns
2534
+ # only the gateway — this process, its conversation, its in-flight turn and
2535
+ # its sub-agents all keep running. The gateway used to read its own boot as
2536
+ # "the agent restarted" and fire a false `resume_interrupted` into the live
2537
+ # session ("your previous turn was interrupted", "your sub-agents were killed")
2538
+ # while everything it named was demonstrably still alive.
2539
+ #
2540
+ # What DECIDES that is the per-container-boot generation token cleared in the
2541
+ # outer pass above (`.boot-resume-done`). This record is the SAFETY VETO on top
2542
+ # of it: if the token says "already done" but the recorded agent process is
2543
+ # provably gone, the gateway runs the boot resume anyway. It can only ever
2544
+ # re-enable a resume, never suppress a legitimate one — which is why nothing
2545
+ # here depends on when the gateway was forked relative to this shell.
2546
+ #
2547
+ # So publish this process's identity for the gateway to probe:
2548
+ # pid — `$$`, and because the `exec` below REPLACES this shell rather
2549
+ # than forking, that pid IS the claude process's pid.
2550
+ # starttime — `/proc/<pid>/stat` field 22, clock ticks since host boot.
2551
+ # Assigned at fork and NOT reset by `exec`, so the value read
2552
+ # here is exactly what claude will carry. PIDs are reused (and a
2553
+ # container restart resets the pid namespace), so identity is the
2554
+ # PAIR — the gateway rejects a pid whose starttime disagrees.
2555
+ #
2556
+ # Deliberately NOT recorded: `comm`. It is "bash" here and becomes claude's
2557
+ # after the exec, so recording it would guarantee a mismatch. (An earlier
2558
+ # review suggested recording it and requiring the live process to look
2559
+ # claude-ish, to close a fresh-boot race cheaply — the generation token closes
2560
+ # that race outright, so the extra guard would only add a way to fail.)
2561
+ #
2562
+ # Written LAST, immediately before the exec: a record must never name a
2563
+ # process that does not exist yet. Best-effort throughout — a missing or torn
2564
+ # record makes the gateway fail open to its pre-#4641 behaviour.
2565
+ _sr_agent_stat=$(cat "/proc/$$/stat" 2>/dev/null || true)
2566
+ if [ -n "$_sr_agent_stat" ]; then
2567
+ # comm is parenthesised and may contain spaces/`)`, so split on the LAST ") ".
2568
+ _sr_agent_rest=${_sr_agent_stat##*') '}
2569
+ # Field 22 (starttime) is the 20th field after comm. Subshell + `set -f` so
2570
+ # the word split neither globs nor clobbers this script's positional params.
2571
+ _sr_agent_start=$( set -f; set -- $_sr_agent_rest; printf '%s' "${20}" )
2572
+ case "$_sr_agent_start" in
2573
+ ''|*[!0-9]*) _sr_agent_start="" ;;
2574
+ esac
2575
+ if [ -n "$_sr_agent_start" ]; then
2576
+ _sr_agent_dir="${TELEGRAM_STATE_DIR:-{{agentDir}}/telegram}"
2577
+ mkdir -p "$_sr_agent_dir" 2>/dev/null || true
2578
+ _sr_agent_tmp="$_sr_agent_dir/agent-process.json.tmp.$$"
2579
+ if printf '{"pid":%s,"starttime":"%s","boot_at":%s}\n' \
2580
+ "$$" "$_sr_agent_start" "$(( $(date +%s) * 1000 ))" > "$_sr_agent_tmp" 2>/dev/null; then
2581
+ mv -f "$_sr_agent_tmp" "$_sr_agent_dir/agent-process.json" 2>/dev/null || true
2582
+ fi
2583
+ rm -f "$_sr_agent_tmp" 2>/dev/null || true
2584
+ # Passive context for the agent process itself (diagnostics only — the
2585
+ # gateway reads the FILE; it was already running when this env was set).
2586
+ export SWITCHROOM_AGENT_PID="$$"
2587
+ export SWITCHROOM_AGENT_STARTTIME="$_sr_agent_start"
2588
+ unset _sr_agent_dir _sr_agent_tmp
2589
+ fi
2590
+ fi
2591
+ unset _sr_agent_stat _sr_agent_rest _sr_agent_start
2592
+
2478
2593
  {{#if useSwitchroomPlugin}}
2479
2594
  if [ -n "$APPEND_PROMPT" ]; then
2480
2595
  exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL" $_EFFORT_ARG{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
@@ -0,0 +1,6 @@
1
+ ## Local time — never show a human a UTC timestamp
2
+
3
+ Convert to the agent's zone (`SWITCHROOM_TIMEZONE`, else `TZ`; the container clock is already local).
4
+
5
+ - **Never hardcode an offset or a zone abbreviation** (`+10:00`, `AEST`) — both move with DST; derive from the zone.
6
+ - **Attach a naive timestamp's true zone before converting** (`.replace(tzinfo=ZoneInfo("UTC"))`) — `.astimezone()` on a naive value assumes the process clock.
@@ -16,9 +16,7 @@ You are operating in the **{{topicName}}** {{#if topicEmoji}}{{topicEmoji}} {{/i
16
16
  {{/if}}
17
17
 
18
18
  ## Core Behavior
19
- - Respond helpfully, concisely, and conversationally.
20
19
  - Use your available tools when they add clear value — don't force tool use when a plain answer suffices.
21
- - Save important facts, preferences, and decisions to memory so you can recall them later.
22
20
  - When asked to do something ambiguous, ask one clarifying question rather than guessing.
23
21
  - If a task has multiple steps, outline your plan before executing.
24
22
 
@@ -58,7 +56,6 @@ Hard rules the agent must follow during reflect — guardrails that are always a
58
56
 
59
57
  Retain proactively when:
60
58
  - The user shares a preference or fact about themselves
61
- - The user gives you a correction or rule (these go to directives, not retain)
62
59
  - A significant decision was made and the rationale matters for next time
63
60
  - You did real work and the result + the path you took would be useful next session
64
61
 
@@ -68,8 +65,6 @@ Don't retain:
68
65
  - Sensitive content the user explicitly asked you to not remember
69
66
  - Things already in a mental model — they'll be re-derived from underlying memories
70
67
 
71
- Auto-retain (see above) covers routine capture; use manual `retain` for high-signal observations you want immediately searchable.
72
-
73
68
  ### When to synthesize — concrete triggers
74
69
 
75
70
  Auto-recall and auto-retain feed the bank but never *synthesize* — that's on you, only if you act on these triggers. Each has a backstop:
@@ -134,13 +129,6 @@ Discipline (you read peers' attacker-influenced output, nothing taps your shell)
134
129
  Your transcript is this power's audit trail; keep your actions legible.
135
130
  {{/if}}
136
131
 
137
- ## Tools
138
- {{#if tools}}
139
- Use the tools available to you to accomplish tasks effectively. Prefer the simplest tool that gets the job done.
140
- {{else}}
141
- Use your available tools when appropriate. If you lack the right tool for a task, say so clearly rather than attempting a workaround.
142
- {{/if}}
143
-
144
132
  {{#if schedule}}
145
133
  ## Scheduled Tasks
146
134
  You have scheduled tasks configured. At fire time an in-container scheduler sidecar injects a synthesized inbound turn into **your running session** — a scheduled task arrives as an ordinary turn tagged `<channel source="cron">`, using your normal session, context, and model, and it shows up in your transcript and Hindsight memory like any other turn (it is *not* an isolated one-shot `claude -p` process). They survive reboots via the container restart policy plus an at-least-once boot replay.