switchroom 0.14.3 → 0.14.5

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.
@@ -30840,8 +30840,11 @@ function defaultStatBroker(p) {
30840
30840
  return { kind: "ok-with-stat", ino: inoStr, size };
30841
30841
  }
30842
30842
  function spawnDockerStat(p) {
30843
+ return spawnDockerStatForContainer("switchroom-vault-broker", p);
30844
+ }
30845
+ function spawnDockerStatForContainer(containerName2, p) {
30843
30846
  try {
30844
- const stdout = execFileSync16("docker", ["exec", "switchroom-vault-broker", "stat", "-c", "%i %s", p], { stdio: ["ignore", "pipe", "pipe"], timeout: 3000, encoding: "utf8" });
30847
+ const stdout = execFileSync16("docker", ["exec", containerName2, "stat", "-c", "%i %s", p], { stdio: ["ignore", "pipe", "pipe"], timeout: 3000, encoding: "utf8" });
30845
30848
  return { status: 0, stdout, stderr: "", error: null };
30846
30849
  } catch (err) {
30847
30850
  const e = err;
@@ -30933,9 +30936,80 @@ function runVaultBrokerDurabilityChecks(_config, opts) {
30933
30936
  probeMachineIdMount(),
30934
30937
  formatBindMountResult("vault-broker: vault.enc bind mount", join52(home2, ".switchroom", "vault", "vault.enc"), "/state/vault/vault.enc", probe2(join52(home2, ".switchroom", "vault", "vault.enc"), "/state/vault/vault.enc")),
30935
30938
  formatBindMountResult("vault-broker: vault-grants.db bind mount (#1737)", join52(home2, ".switchroom", "vault-grants.db"), "/root/.switchroom/vault-grants.db", probe2(join52(home2, ".switchroom", "vault-grants.db"), "/root/.switchroom/vault-grants.db")),
30936
- formatBindMountResult("vault-broker: vault-audit.log bind mount (#1025)", join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log", probe2(join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log"))
30939
+ formatBindMountResult("vault-broker: vault-audit.log bind mount (#1025)", join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log", probe2(join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log")),
30940
+ probeKernelDbDurability(home2, {
30941
+ statBroker: opts?.kernelStatBroker
30942
+ })
30937
30943
  ];
30938
30944
  }
30945
+ function probeKernelDbDurability(home2, opts) {
30946
+ const hostDir = join52(home2, ".switchroom", "approvals");
30947
+ const containerDir = "/state/approvals";
30948
+ const name = "approval-kernel: approvals bind mount (allow_always durability)";
30949
+ const kernelStat = opts?.statBroker ?? defaultKernelStatBroker;
30950
+ const result = probeBindMountInode(hostDir, containerDir, {
30951
+ statBroker: kernelStat,
30952
+ statHost: opts?.statHost
30953
+ });
30954
+ if (result.kind === "ok") {
30955
+ return {
30956
+ name,
30957
+ status: "ok",
30958
+ detail: `${hostDir} == ${containerDir} (same inode) \u2014 allow_always decisions persist across kernel recreate`
30959
+ };
30960
+ }
30961
+ if (result.kind === "host-missing") {
30962
+ return {
30963
+ name,
30964
+ status: "warn",
30965
+ detail: `host directory ${hostDir} missing \u2014 \`switchroom apply\` pre-creates it on greenfield`,
30966
+ fix: "Run `switchroom apply` to pre-create the host approvals directory"
30967
+ };
30968
+ }
30969
+ if (result.kind === "broker-unreachable") {
30970
+ return {
30971
+ name,
30972
+ status: "skip",
30973
+ detail: "approval-kernel container unreachable \u2014 bind mount unverified"
30974
+ };
30975
+ }
30976
+ if (result.kind === "broker-stat-failed") {
30977
+ return {
30978
+ name,
30979
+ status: "warn",
30980
+ detail: `approval-kernel stat failed: ${result.msg}`
30981
+ };
30982
+ }
30983
+ return {
30984
+ name,
30985
+ status: "fail",
30986
+ detail: `inode mismatch \u2014 approval-kernel \`/state/approvals\` is NOT backed by the host bind mount. ` + `host inode=${result.hostInode} size=${result.hostSize}; ` + `kernel inode=${result.brokerInode} size=${result.brokerSize}. ` + `The kernel is writing kernel.db to an ephemeral container-local directory; ` + `all allow_always decisions are lost on every container recreate (e.g. after \`switchroom update\`).`,
30987
+ fix: "Run `switchroom apply` to regenerate compose with the " + "`~/.switchroom/approvals:/state/approvals` bind mount, then " + "`docker compose -p switchroom up -d approval-kernel` to recreate the kernel container."
30988
+ };
30989
+ }
30990
+ function defaultKernelStatBroker(p) {
30991
+ const r = spawnDockerStatForContainer("switchroom-approval-kernel", p);
30992
+ if (r.error || r.status === null)
30993
+ return { kind: "broker-unreachable" };
30994
+ if (r.status !== 0) {
30995
+ if (r.status >= 125)
30996
+ return { kind: "broker-unreachable" };
30997
+ return {
30998
+ kind: "broker-stat-failed",
30999
+ msg: r.stderr?.trim() || `exit ${r.status}`
31000
+ };
31001
+ }
31002
+ const out = r.stdout.trim();
31003
+ const [inoStr, sizeStr] = out.split(/\s+/);
31004
+ const size = Number(sizeStr);
31005
+ if (!inoStr || !Number.isFinite(size)) {
31006
+ return {
31007
+ kind: "broker-stat-failed",
31008
+ msg: `unparseable stat output: ${out}`
31009
+ };
31010
+ }
31011
+ return { kind: "ok-with-stat", ino: inoStr, size };
31012
+ }
30939
31013
  function probeAutoUnlockBlob(home2) {
30940
31014
  const blobPath = join52(home2, ".switchroom", "vault-auto-unlock");
30941
31015
  if (!existsSync52(blobPath)) {
@@ -49278,8 +49352,8 @@ var {
49278
49352
  } = import__.default;
49279
49353
 
49280
49354
  // src/build-info.ts
49281
- var VERSION = "0.14.3";
49282
- var COMMIT_SHA = "b61cef7e";
49355
+ var VERSION = "0.14.5";
49356
+ var COMMIT_SHA = "c12d4240";
49283
49357
 
49284
49358
  // src/cli/agent.ts
49285
49359
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
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": {
@@ -23041,6 +23041,7 @@ import { join as join2 } from "node:path";
23041
23041
  function createToolLabelSidecar(opts) {
23042
23042
  const path = join2(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`);
23043
23043
  const labels = new Map;
23044
+ const seen = [];
23044
23045
  const subscribers = new Set;
23045
23046
  let offset = 0;
23046
23047
  let stopped = false;
@@ -23068,6 +23069,7 @@ function createToolLabelSidecar(opts) {
23068
23069
  if (labels.has(row.tool_use_id))
23069
23070
  continue;
23070
23071
  labels.set(row.tool_use_id, row.label);
23072
+ seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name });
23071
23073
  for (const cb of subscribers) {
23072
23074
  try {
23073
23075
  cb(row.tool_use_id, row.label, row.tool_name);
@@ -23109,6 +23111,11 @@ function createToolLabelSidecar(opts) {
23109
23111
  return labels.get(toolUseId);
23110
23112
  },
23111
23113
  onLabel(cb) {
23114
+ for (const r of seen) {
23115
+ try {
23116
+ cb(r.toolUseId, r.label, r.toolName);
23117
+ } catch {}
23118
+ }
23112
23119
  subscribers.add(cb);
23113
23120
  return () => subscribers.delete(cb);
23114
23121
  },
@@ -23415,6 +23422,46 @@ function extractAssistantText(obj) {
23415
23422
  }
23416
23423
  return parts.join(" ").trim();
23417
23424
  }
23425
+ function computeFirstAttachCursor(file, size) {
23426
+ const SCAN_CAP = 1024 * 1024;
23427
+ const scanStart = Math.max(0, size - SCAN_CAP);
23428
+ let buf;
23429
+ try {
23430
+ const fd = openSync(file, "r");
23431
+ try {
23432
+ buf = Buffer.allocUnsafe(size - scanStart);
23433
+ readSync(fd, buf, 0, buf.length, scanStart);
23434
+ } finally {
23435
+ closeSync(fd);
23436
+ }
23437
+ } catch {
23438
+ return size;
23439
+ }
23440
+ let lastEnqueueOffset = -1;
23441
+ let turnEndedAfterEnqueue = false;
23442
+ let lineStart = 0;
23443
+ let skipPartial = scanStart > 0;
23444
+ for (let i = 0;i <= buf.length; i++) {
23445
+ if (i !== buf.length && buf[i] !== 10)
23446
+ continue;
23447
+ if (skipPartial) {
23448
+ skipPartial = false;
23449
+ } else if (i > lineStart) {
23450
+ const line = buf.toString("utf8", lineStart, i);
23451
+ if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
23452
+ lastEnqueueOffset = scanStart + lineStart;
23453
+ turnEndedAfterEnqueue = false;
23454
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
23455
+ turnEndedAfterEnqueue = true;
23456
+ }
23457
+ }
23458
+ lineStart = i + 1;
23459
+ }
23460
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
23461
+ return lastEnqueueOffset;
23462
+ }
23463
+ return size;
23464
+ }
23418
23465
  function startSessionTail(config2) {
23419
23466
  const cwd = config2.cwd ?? process.cwd();
23420
23467
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join3(homedir2(), ".claude");
@@ -23551,11 +23598,16 @@ function startSessionTail(config2) {
23551
23598
  } else {
23552
23599
  pendingPartial = "";
23553
23600
  try {
23554
- cursor = statSync3(file).size;
23601
+ const size = statSync3(file).size;
23602
+ cursor = computeFirstAttachCursor(file, size);
23603
+ if (cursor < size) {
23604
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
23605
+ } else {
23606
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
23607
+ }
23555
23608
  } catch {
23556
23609
  cursor = 0;
23557
23610
  }
23558
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
23559
23611
  }
23560
23612
  const attachSid = sessionIdForFile(file);
23561
23613
  if (attachSid)
@@ -49745,6 +49745,9 @@ function skillBasenameFromPath2(input) {
49745
49745
  const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
49746
49746
  return basename6(trimmed) || null;
49747
49747
  }
49748
+ function isRulePersisted(resolvedAllow, ruleRule) {
49749
+ return resolvedAllow.includes(ruleRule);
49750
+ }
49748
49751
 
49749
49752
  // credits-watch.ts
49750
49753
  import { readFileSync as readFileSync29, writeFileSync as writeFileSync18, existsSync as existsSync30, mkdirSync as mkdirSync16 } from "fs";
@@ -50065,11 +50068,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
50065
50068
  }
50066
50069
 
50067
50070
  // ../src/build-info.ts
50068
- var VERSION = "0.14.3";
50069
- var COMMIT_SHA = "b61cef7e";
50070
- var COMMIT_DATE = "2026-05-28T09:56:51Z";
50071
- var LATEST_PR = 1964;
50072
- var COMMITS_AHEAD_OF_TAG = 0;
50071
+ var VERSION = "0.14.5";
50072
+ var COMMIT_SHA = "c12d4240";
50073
+ var COMMIT_DATE = "2026-05-28T21:57:39+10:00";
50074
+ var LATEST_PR = null;
50075
+ var COMMITS_AHEAD_OF_TAG = 2;
50073
50076
 
50074
50077
  // gateway/boot-version.ts
50075
50078
  function formatRelativeAgo(iso) {
@@ -59034,20 +59037,44 @@ ${prettyInput}`;
59034
59037
  return;
59035
59038
  }
59036
59039
  let grantOk = false;
59040
+ let grantFailReason = "";
59037
59041
  try {
59038
59042
  switchroomExec(["agent", "grant", agentName3, rule.rule, "--no-restart"]);
59039
- grantOk = true;
59040
- process.stderr.write(`telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName3} (request_id=${request_id})
59043
+ try {
59044
+ const cfg = loadConfig2();
59045
+ const rawAgent = cfg.agents?.[agentName3];
59046
+ if (rawAgent) {
59047
+ const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
59048
+ const allowList = resolved.tools?.allow ?? [];
59049
+ if (isRulePersisted(allowList, rule.rule)) {
59050
+ grantOk = true;
59051
+ process.stderr.write(`telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName3} (request_id=${request_id})
59052
+ `);
59053
+ } else {
59054
+ grantFailReason = `rule "${rule.rule}" not found in resolved tools.allow after write \u2014 config location may have drifted`;
59055
+ process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
59056
+ `);
59057
+ }
59058
+ } else {
59059
+ grantFailReason = `agent "${agentName3}" not found in config after write`;
59060
+ process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
59041
59061
  `);
59062
+ }
59063
+ } catch (verifyErr) {
59064
+ grantFailReason = `config re-read failed: ${verifyErr.message}`;
59065
+ process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
59066
+ `);
59067
+ }
59042
59068
  } catch (err) {
59043
- process.stderr.write(`telegram gateway: always-allow grant failed: ${err.message}
59069
+ grantFailReason = err.message;
59070
+ process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}
59044
59071
  `);
59045
59072
  }
59046
59073
  pendingPermissions.delete(request_id);
59047
- const ackText = grantOk ? `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3}` : `\u2705 Allowed (always-allow yaml edit failed; check gateway log)`;
59074
+ const ackText = grantOk ? `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3}` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
59048
59075
  const sourceMsg = ctx.callbackQuery?.message;
59049
59076
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
59050
- const editLabel = grantOk ? `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 restart agent for full effect` : `\u2705 <b>Allowed</b> (always-allow rule edit failed; see logs)`;
59077
+ const editLabel = grantOk ? `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 restart agent for full effect` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
59051
59078
  await finalizeCallback(ctx, {
59052
59079
  ackText: ackText.slice(0, 200),
59053
59080
  newText: baseText2 ? `${baseText2}
@@ -17069,6 +17069,7 @@ import { join as join3 } from "node:path";
17069
17069
  function createToolLabelSidecar(opts) {
17070
17070
  const path = join3(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`);
17071
17071
  const labels = new Map;
17072
+ const seen = [];
17072
17073
  const subscribers = new Set;
17073
17074
  let offset = 0;
17074
17075
  let stopped = false;
@@ -17096,6 +17097,7 @@ function createToolLabelSidecar(opts) {
17096
17097
  if (labels.has(row.tool_use_id))
17097
17098
  continue;
17098
17099
  labels.set(row.tool_use_id, row.label);
17100
+ seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name });
17099
17101
  for (const cb of subscribers) {
17100
17102
  try {
17101
17103
  cb(row.tool_use_id, row.label, row.tool_name);
@@ -17137,6 +17139,11 @@ function createToolLabelSidecar(opts) {
17137
17139
  return labels.get(toolUseId);
17138
17140
  },
17139
17141
  onLabel(cb) {
17142
+ for (const r of seen) {
17143
+ try {
17144
+ cb(r.toolUseId, r.label, r.toolName);
17145
+ } catch {}
17146
+ }
17140
17147
  subscribers.add(cb);
17141
17148
  return () => subscribers.delete(cb);
17142
17149
  },
@@ -17453,6 +17460,46 @@ function extractAssistantText(obj) {
17453
17460
  }
17454
17461
  return parts.join(" ").trim();
17455
17462
  }
17463
+ function computeFirstAttachCursor(file, size) {
17464
+ const SCAN_CAP = 1024 * 1024;
17465
+ const scanStart = Math.max(0, size - SCAN_CAP);
17466
+ let buf;
17467
+ try {
17468
+ const fd = openSync(file, "r");
17469
+ try {
17470
+ buf = Buffer.allocUnsafe(size - scanStart);
17471
+ readSync(fd, buf, 0, buf.length, scanStart);
17472
+ } finally {
17473
+ closeSync(fd);
17474
+ }
17475
+ } catch {
17476
+ return size;
17477
+ }
17478
+ let lastEnqueueOffset = -1;
17479
+ let turnEndedAfterEnqueue = false;
17480
+ let lineStart = 0;
17481
+ let skipPartial = scanStart > 0;
17482
+ for (let i = 0;i <= buf.length; i++) {
17483
+ if (i !== buf.length && buf[i] !== 10)
17484
+ continue;
17485
+ if (skipPartial) {
17486
+ skipPartial = false;
17487
+ } else if (i > lineStart) {
17488
+ const line = buf.toString("utf8", lineStart, i);
17489
+ if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
17490
+ lastEnqueueOffset = scanStart + lineStart;
17491
+ turnEndedAfterEnqueue = false;
17492
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
17493
+ turnEndedAfterEnqueue = true;
17494
+ }
17495
+ }
17496
+ lineStart = i + 1;
17497
+ }
17498
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
17499
+ return lastEnqueueOffset;
17500
+ }
17501
+ return size;
17502
+ }
17456
17503
  function startSessionTail(config2) {
17457
17504
  const cwd = config2.cwd ?? process.cwd();
17458
17505
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join4(homedir3(), ".claude");
@@ -17589,11 +17636,16 @@ function startSessionTail(config2) {
17589
17636
  } else {
17590
17637
  pendingPartial = "";
17591
17638
  try {
17592
- cursor = statSync4(file).size;
17639
+ const size = statSync4(file).size;
17640
+ cursor = computeFirstAttachCursor(file, size);
17641
+ if (cursor < size) {
17642
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
17643
+ } else {
17644
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
17645
+ }
17593
17646
  } catch {
17594
17647
  cursor = 0;
17595
17648
  }
17596
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
17597
17649
  }
17598
17650
  const attachSid = sessionIdForFile(file);
17599
17651
  if (attachSid)
@@ -368,7 +368,7 @@ import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js
368
368
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
369
369
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
370
370
  import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
371
- import { resolveAlwaysAllowRule } from '../permission-rule.js'
371
+ import { resolveAlwaysAllowRule, isRulePersisted } from '../permission-rule.js'
372
372
  import {
373
373
  readClaudeJsonOverage,
374
374
  evaluateCreditState,
@@ -15286,25 +15286,56 @@ bot.on('callback_query:data', async ctx => {
15286
15286
  return
15287
15287
  }
15288
15288
  let grantOk = false
15289
+ let grantFailReason = ''
15289
15290
  try {
15290
15291
  // --no-restart: settings.json gets the new entry on the next
15291
15292
  // reconcile but we don't bounce the agent mid-turn. Operator
15292
15293
  // can restart manually if they want this rule live in this
15293
15294
  // session; otherwise it kicks in next session.
15294
15295
  switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
15295
- grantOk = true
15296
- process.stderr.write(
15297
- `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15298
- )
15296
+ // Verify the rule actually landed in the resolved config — guards
15297
+ // against config-location-drift (gateway edited a yaml that isn't
15298
+ // the durable source-of-truth, or the grant was a no-op). One
15299
+ // fresh config read; cheap since this is a rare operator tap.
15300
+ try {
15301
+ const cfg = loadSwitchroomConfig()
15302
+ const rawAgent = cfg.agents?.[agentName]
15303
+ if (rawAgent) {
15304
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
15305
+ const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
15306
+ if (isRulePersisted(allowList, rule.rule)) {
15307
+ grantOk = true
15308
+ process.stderr.write(
15309
+ `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15310
+ )
15311
+ } else {
15312
+ grantFailReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
15313
+ process.stderr.write(
15314
+ `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15315
+ )
15316
+ }
15317
+ } else {
15318
+ grantFailReason = `agent "${agentName}" not found in config after write`
15319
+ process.stderr.write(
15320
+ `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15321
+ )
15322
+ }
15323
+ } catch (verifyErr) {
15324
+ grantFailReason = `config re-read failed: ${(verifyErr as Error).message}`
15325
+ process.stderr.write(
15326
+ `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15327
+ )
15328
+ }
15299
15329
  } catch (err) {
15300
- process.stderr.write(`telegram gateway: always-allow grant failed: ${(err as Error).message}\n`)
15330
+ grantFailReason = (err as Error).message
15331
+ process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}\n`)
15301
15332
  }
15302
15333
 
15303
15334
  pendingPermissions.delete(request_id)
15304
15335
 
15305
15336
  const ackText = grantOk
15306
15337
  ? `🔁 Always allow ${rule.label} for ${agentName}`
15307
- : `✅ Allowed (always-allow yaml edit failed; check gateway log)`
15338
+ : `⚠️ Allowed for now, but "always" did NOT save it will ask again after restart. Check gateway log.`
15308
15339
  // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
15309
15340
  // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
15310
15341
  // expanded permission card's `description` or `input_preview`
@@ -15317,7 +15348,7 @@ bot.on('callback_query:data', async ctx => {
15317
15348
  : ''
15318
15349
  const editLabel = grantOk
15319
15350
  ? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} — restart agent for full effect`
15320
- : `✅ <b>Allowed</b> (always-allow rule edit failed; see logs)`
15351
+ : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`
15321
15352
  // #1150 audit: route through finalizeCallback so the keyboard
15322
15353
  // strips alongside the status-line edit. Pre-fix this called
15323
15354
  // editMessageText without `reply_markup` so the Allow/Deny/Always
@@ -132,6 +132,28 @@ function skillBasenameFromPath(input: Record<string, unknown>): string | null {
132
132
  return basename(trimmed) || null;
133
133
  }
134
134
 
135
+ /**
136
+ * Verify that a grant actually landed in the resolved `tools.allow` list.
137
+ *
138
+ * Called by the `perm:always:*` handler after `switchroom agent grant`
139
+ * returns to guard against silently-failed or misdirected yaml writes.
140
+ * Extracted as a pure helper so it can be unit-tested without a full
141
+ * Grammy + switchroomExec harness.
142
+ *
143
+ * @param resolvedAllow The `tools.allow` array from `resolveAgentConfig`
144
+ * for the target agent (pass `[]` when absent/undefined).
145
+ * @param ruleRule The rule string produced by `resolveAlwaysAllowRule`
146
+ * (e.g. `"Skill(garmin)"`, `"Bash"`, `"mcp__x__y"`).
147
+ * @returns `true` when the rule is present (grant confirmed), `false` when
148
+ * absent (grant failed / config location drifted).
149
+ */
150
+ export function isRulePersisted(
151
+ resolvedAllow: readonly string[],
152
+ ruleRule: string,
153
+ ): boolean {
154
+ return resolvedAllow.includes(ruleRule);
155
+ }
156
+
135
157
  /**
136
158
  * Inverse of `resolveAlwaysAllowRule` — does a stored allow-rule cover a
137
159
  * fresh `permission_request`? Used by the bridge's session-scoped
@@ -603,6 +603,66 @@ export interface SessionTailHandle {
603
603
  getActiveFile(): string | null
604
604
  }
605
605
 
606
+ /**
607
+ * Byte offset to seek to on the FIRST attach to a session transcript.
608
+ *
609
+ * Normally EOF — we only want NEW events, not replayed history. But if the
610
+ * agent restarted MID-TURN (the bridge's session-tail starts only after
611
+ * claude has already written this turn's `queue-operation enqueue` line),
612
+ * a plain seek-to-EOF skips that enqueue. `enqueue` is the ONLY event that
613
+ * carries the chatId and that sets the gateway's `currentTurn`, so missing
614
+ * it leaves the first post-restart turn with no currentTurn — killing the
615
+ * progress card, draft-mirror, and silence-poke for that turn.
616
+ *
617
+ * Fix: in a bounded tail scan, find the last `enqueue` that has NO
618
+ * `turn_duration` (turn_end) after it — an in-flight turn — and return its
619
+ * line offset so it (and the turn's subsequent events) replay. A completed
620
+ * turn (a `turn_duration` follows the enqueue) returns EOF: no replay.
621
+ */
622
+ export function computeFirstAttachCursor(file: string, size: number): number {
623
+ const SCAN_CAP = 1024 * 1024 // bound the tail read at 1 MiB
624
+ const scanStart = Math.max(0, size - SCAN_CAP)
625
+ let buf: Buffer
626
+ try {
627
+ const fd = openSync(file, 'r')
628
+ try {
629
+ buf = Buffer.allocUnsafe(size - scanStart)
630
+ readSync(fd, buf, 0, buf.length, scanStart)
631
+ } finally {
632
+ closeSync(fd)
633
+ }
634
+ } catch {
635
+ return size
636
+ }
637
+ let lastEnqueueOffset = -1
638
+ let turnEndedAfterEnqueue = false
639
+ let lineStart = 0
640
+ // If the scan didn't start at byte 0, the first line is a partial — skip it.
641
+ let skipPartial = scanStart > 0
642
+ for (let i = 0; i <= buf.length; i++) {
643
+ if (i !== buf.length && buf[i] !== 0x0a) continue
644
+ if (skipPartial) {
645
+ skipPartial = false
646
+ } else if (i > lineStart) {
647
+ const line = buf.toString('utf8', lineStart, i)
648
+ if (
649
+ line.includes('"type":"queue-operation"') &&
650
+ line.includes('"operation":"enqueue"')
651
+ ) {
652
+ lastEnqueueOffset = scanStart + lineStart
653
+ turnEndedAfterEnqueue = false
654
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
655
+ turnEndedAfterEnqueue = true
656
+ }
657
+ }
658
+ lineStart = i + 1
659
+ }
660
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
661
+ return lastEnqueueOffset
662
+ }
663
+ return size
664
+ }
665
+
606
666
  /**
607
667
  * Start tailing the active Claude Code session file. The tailer:
608
668
  * 1. Polls the projects dir for the most recent .jsonl
@@ -778,14 +838,20 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
778
838
  log?.(`session-tail: re-attached to ${file} (cursor=${cursor}, restored)`)
779
839
  } else {
780
840
  // First attach to this file — seek to current end so we only see
781
- // new events, not history.
841
+ // new events, EXCEPT replay from an in-flight turn's enqueue if the
842
+ // agent restarted mid-turn (see firstAttachCursor).
782
843
  pendingPartial = ''
783
844
  try {
784
- cursor = statSync(file).size
845
+ const size = statSync(file).size
846
+ cursor = computeFirstAttachCursor(file, size)
847
+ if (cursor < size) {
848
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
849
+ } else {
850
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
851
+ }
785
852
  } catch {
786
853
  cursor = 0
787
854
  }
788
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
789
855
  }
790
856
  // Eagerly create + subscribe the PreToolUse sidecar for this session
791
857
  // NOW (on attach), not lazily on the first JSONL tool_use — otherwise
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Structural contract tests for the "🔁 Always allow" handler in
3
+ * gateway.ts (the `behavior === 'always'` branch of the perm: callback
4
+ * dispatcher).
5
+ *
6
+ * Why structural: the handler lives inside a Grammy callback closure
7
+ * that's not exported. Full-function invocation would require a complete
8
+ * Grammy + switchroomExec harness. Instead, we pin the source-level
9
+ * invariants that were introduced to fix the silent-failure bug:
10
+ *
11
+ * 1. Loud failure text — the failure path must NOT read like success
12
+ * (`✅ Allowed …`). After the fix, both the toast (ackText) and the
13
+ * chat edit (editLabel) use the `⚠️` marker.
14
+ * 2. Post-write verification — after `switchroomExec` returns success
15
+ * the handler MUST re-read the config and check that the rule is
16
+ * actually present in `tools.allow`. If the check fails it sets
17
+ * grantOk=false and surfaces the loud message.
18
+ * 3. Success path unchanged — when `grantOk` is true the success
19
+ * strings (`🔁 Always allow …`, `restart agent for full effect`)
20
+ * are still present.
21
+ * 4. Error reason capture — `grantFailReason` is declared and
22
+ * populated from `(err as Error).message` so the root cause can
23
+ * appear in logs; it is NOT silently swallowed into `message`-less
24
+ * stderr output.
25
+ *
26
+ * Slicing strategy: we extract the `if (behavior === 'always') {` block
27
+ * from gateway.ts and run string assertions against that slice only —
28
+ * so additions elsewhere in the 17k-line file don't produce false
29
+ * positives or negatives.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+ import { readFileSync } from 'node:fs'
34
+ import { resolve } from 'node:path'
35
+
36
+ const gatewaySrc = readFileSync(
37
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
38
+ 'utf-8',
39
+ )
40
+
41
+ /**
42
+ * Extract the `behavior === 'always'` block from the perm: callback
43
+ * dispatcher. The slice runs from the `if (behavior === 'always')` guard
44
+ * up to (but not including) the next top-level `// Forward permission`
45
+ * comment which opens the allow/deny branch.
46
+ */
47
+ function sliceAlwaysBlock(): string {
48
+ const start = gatewaySrc.indexOf("if (behavior === 'always')")
49
+ const end = gatewaySrc.indexOf('// Forward permission decision to connected bridges', start)
50
+ if (start === -1 || end === -1) return ''
51
+ return gatewaySrc.slice(start, end)
52
+ }
53
+
54
+ const alwaysBlock = sliceAlwaysBlock()
55
+
56
+ describe('always-allow handler — loud failure invariants', () => {
57
+ it('failure ackText uses the ⚠️ warning marker, not ✅', () => {
58
+ // The failure path must be unambiguous. Before the fix, the failure
59
+ // ackText started with "✅ Allowed …" which reads like success.
60
+ expect(alwaysBlock).toContain(
61
+ `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`,
62
+ )
63
+ // Confirm the old misleading text is gone.
64
+ expect(alwaysBlock).not.toContain('✅ Allowed (always-allow yaml edit failed')
65
+ })
66
+
67
+ it('failure editLabel uses the ⚠️ warning marker, not ✅', () => {
68
+ // The inline-keyboard collapse edit also must NOT look like success.
69
+ expect(alwaysBlock).toContain(
70
+ `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`,
71
+ )
72
+ // Confirm the old misleading text is gone.
73
+ expect(alwaysBlock).not.toContain('✅ <b>Allowed</b> (always-allow rule edit failed')
74
+ })
75
+ })
76
+
77
+ describe('always-allow handler — success path unchanged', () => {
78
+ it('success ackText still uses 🔁 and names the rule', () => {
79
+ expect(alwaysBlock).toContain('`🔁 Always allow ${rule.label} for ${agentName}`')
80
+ })
81
+
82
+ it('success editLabel still uses 🔁 bold + restart hint', () => {
83
+ expect(alwaysBlock).toContain('restart agent for full effect')
84
+ expect(alwaysBlock).toContain('🔁 <b>Always allow')
85
+ })
86
+ })
87
+
88
+ describe('always-allow handler — post-write verification', () => {
89
+ it('reloads config after switchroomExec returns', () => {
90
+ // The verification block must call loadSwitchroomConfig() AFTER
91
+ // the switchroomExec call to confirm the rule landed in the
92
+ // resolved tools.allow.
93
+ const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
94
+ const loadIdx = alwaysBlock.indexOf('loadSwitchroomConfig()', execIdx)
95
+ expect(execIdx).toBeGreaterThan(-1)
96
+ expect(loadIdx).toBeGreaterThan(execIdx)
97
+ })
98
+
99
+ it('calls resolveAgentConfig to obtain the merged tools.allow list', () => {
100
+ const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
101
+ const resolveIdx = alwaysBlock.indexOf('resolveAgentConfig(', execIdx)
102
+ expect(resolveIdx).toBeGreaterThan(execIdx)
103
+ })
104
+
105
+ it('calls isRulePersisted(allowList, rule.rule) after the reload', () => {
106
+ // The handler delegates the membership check to the extracted pure
107
+ // helper so the behavioral test in always-allow-persist.test.ts can
108
+ // cover the same code path.
109
+ expect(alwaysBlock).toContain('isRulePersisted(allowList, rule.rule)')
110
+ })
111
+
112
+ it('sets grantOk=true only when isRulePersisted returns true', () => {
113
+ // grantOk=true must be inside the `if (isRulePersisted(...))` branch,
114
+ // not unconditionally after switchroomExec.
115
+ const persistIdx = alwaysBlock.indexOf('isRulePersisted(allowList, rule.rule)')
116
+ const grantOkIdx = alwaysBlock.indexOf('grantOk = true', persistIdx)
117
+ expect(persistIdx).toBeGreaterThan(-1)
118
+ expect(grantOkIdx).toBeGreaterThan(persistIdx)
119
+ // Confirm grantOk=true does NOT appear before the persistence check
120
+ // (i.e., not unconditionally on switchroomExec success as in the old code).
121
+ const grantOkFirst = alwaysBlock.indexOf('grantOk = true')
122
+ expect(grantOkFirst).toBeGreaterThanOrEqual(persistIdx)
123
+ })
124
+
125
+ it('logs a VERIFY FAILED message when the rule is absent after the write', () => {
126
+ expect(alwaysBlock).toContain('always-allow VERIFY FAILED')
127
+ })
128
+
129
+ it('surfaces config-location drift as a failure reason', () => {
130
+ expect(alwaysBlock).toContain('config location may have drifted')
131
+ })
132
+ })
133
+
134
+ describe('always-allow handler — error reason capture', () => {
135
+ it('declares grantFailReason to capture the root cause', () => {
136
+ expect(alwaysBlock).toContain('let grantFailReason')
137
+ })
138
+
139
+ it('populates grantFailReason from the thrown error on switchroomExec failure', () => {
140
+ // After the catch for switchroomExec, grantFailReason must be set
141
+ // from the error object so log messages can show the actual cause.
142
+ const catchIdx = alwaysBlock.lastIndexOf('} catch (err) {')
143
+ const reasonIdx = alwaysBlock.indexOf('grantFailReason = (err as Error).message', catchIdx)
144
+ expect(catchIdx).toBeGreaterThan(-1)
145
+ expect(reasonIdx).toBeGreaterThan(catchIdx)
146
+ })
147
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Behavioral tests for the always-allow grant verification helper
3
+ * (`isRulePersisted` in `permission-rule.ts`).
4
+ *
5
+ * The `perm:always:*` handler in gateway.ts calls `isRulePersisted` after
6
+ * `switchroom agent grant` returns to confirm the rule actually landed in
7
+ * `tools.allow`. These tests drive the invariants that the structural test
8
+ * in `always-allow-grant.test.ts` can only pin by text-slicing:
9
+ *
10
+ * 1. exec "succeeds" but the reloaded allow-list does NOT contain the
11
+ * rule → isRulePersisted returns false (loud-failure path).
12
+ * 2. reloaded allow-list DOES contain the rule → returns true
13
+ * (success path).
14
+ * 3. Realistic rule values (`Skill(garmin)`, `Bash`, `mcp__x__y`) round-
15
+ * trip correctly — guards against normalization divergence where the
16
+ * value written by `agent grant` and the value read back from yaml
17
+ * diverge in shape.
18
+ *
19
+ * Because `isRulePersisted` is a pure function (takes the already-resolved
20
+ * allow-list directly), no mocking of `loadSwitchroomConfig` /
21
+ * `resolveAgentConfig` is required here. The handler's interaction with
22
+ * those config loaders is covered by the structural test.
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest'
26
+ import { isRulePersisted, resolveAlwaysAllowRule } from '../permission-rule.js'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Core behavioral invariants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('isRulePersisted — failure path', () => {
33
+ it('returns false when the allow-list is empty (exec succeeded but nothing was written)', () => {
34
+ expect(isRulePersisted([], 'Bash')).toBe(false)
35
+ })
36
+
37
+ it('returns false when the rule is absent from a non-empty allow-list', () => {
38
+ expect(isRulePersisted(['Read', 'Write', 'Edit'], 'Bash')).toBe(false)
39
+ })
40
+
41
+ it('returns false for a Skill rule when the list only contains the bare tool name', () => {
42
+ // `agent grant` for Skill(garmin) should write `Skill(garmin)`, not
43
+ // `Skill`. If the yaml ended up with the wrong shape, the verification
44
+ // must catch it.
45
+ expect(isRulePersisted(['Skill'], 'Skill(garmin)')).toBe(false)
46
+ })
47
+
48
+ it('returns false for a bare tool name when only the parameterized form is present', () => {
49
+ expect(isRulePersisted(['Skill(garmin)'], 'Bash')).toBe(false)
50
+ })
51
+
52
+ it('returns false when a similar-looking rule is present but not an exact match', () => {
53
+ expect(isRulePersisted(['mcp__garmin__read_activity'], 'mcp__garmin__list_activities')).toBe(false)
54
+ })
55
+ })
56
+
57
+ describe('isRulePersisted — success path', () => {
58
+ it('returns true when the exact rule is present', () => {
59
+ expect(isRulePersisted(['Read', 'Bash', 'Write'], 'Bash')).toBe(true)
60
+ })
61
+
62
+ it('returns true when the rule is the only entry', () => {
63
+ expect(isRulePersisted(['Skill(garmin)'], 'Skill(garmin)')).toBe(true)
64
+ })
65
+
66
+ it('returns true for a namespaced MCP tool rule', () => {
67
+ expect(isRulePersisted(['mcp__garmin__list_activities', 'Bash'], 'mcp__garmin__list_activities')).toBe(true)
68
+ })
69
+ })
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Round-trip: resolveAlwaysAllowRule → isRulePersisted
73
+ // Simulates the full handler flow: resolve the rule from a permission_request,
74
+ // "grant" it (allow-list contains the resolved rule.rule), then verify.
75
+ // Guards against normalization divergence between the value the handler
76
+ // resolves and the value `agent grant` writes + the config reader returns.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('rule round-trip through isRulePersisted', () => {
80
+ it('Skill tool: resolved rule persists correctly', () => {
81
+ const rule = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
82
+ expect(rule).not.toBeNull()
83
+ // Simulate: allow-list now contains the rule that `agent grant` wrote.
84
+ expect(isRulePersisted([rule!.rule], rule!.rule)).toBe(true)
85
+ // Confirm the written form is `Skill(garmin)` — not a bare `Skill`.
86
+ expect(rule!.rule).toBe('Skill(garmin)')
87
+ })
88
+
89
+ it('Skill tool: absent rule is detected', () => {
90
+ const rule = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
91
+ expect(rule).not.toBeNull()
92
+ // allow-list was not updated (silent grant failure).
93
+ expect(isRulePersisted([], rule!.rule)).toBe(false)
94
+ expect(isRulePersisted(['Skill'], rule!.rule)).toBe(false)
95
+ })
96
+
97
+ it('Bash tool: round-trips correctly', () => {
98
+ const rule = resolveAlwaysAllowRule('Bash', undefined)
99
+ expect(rule).not.toBeNull()
100
+ expect(rule!.rule).toBe('Bash')
101
+ expect(isRulePersisted(['Bash', 'Read'], rule!.rule)).toBe(true)
102
+ expect(isRulePersisted(['Read'], rule!.rule)).toBe(false)
103
+ })
104
+
105
+ it('MCP tool: round-trips with exact namespaced form', () => {
106
+ const toolName = 'mcp__garmin__list_activities'
107
+ const rule = resolveAlwaysAllowRule(toolName, undefined)
108
+ expect(rule).not.toBeNull()
109
+ expect(rule!.rule).toBe(toolName)
110
+ expect(isRulePersisted([toolName], rule!.rule)).toBe(true)
111
+ expect(isRulePersisted(['mcp__garmin__read_activity'], rule!.rule)).toBe(false)
112
+ })
113
+
114
+ it('multiple Skill tools do not cross-contaminate', () => {
115
+ const garmin = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
116
+ const mail = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail' }))
117
+ expect(garmin).not.toBeNull()
118
+ expect(mail).not.toBeNull()
119
+ // Allow-list only has garmin's rule.
120
+ const allowList = [garmin!.rule]
121
+ expect(isRulePersisted(allowList, garmin!.rule)).toBe(true)
122
+ expect(isRulePersisted(allowList, mail!.rule)).toBe(false)
123
+ })
124
+ })
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { computeFirstAttachCursor } from '../session-tail.js'
6
+
7
+ /**
8
+ * computeFirstAttachCursor: on first attach to a transcript, seek to EOF
9
+ * UNLESS the agent restarted mid-turn (an `enqueue` with no `turn_duration`
10
+ * after it). Missing that enqueue strands the first post-restart turn with
11
+ * no currentTurn (dead progress card / draft-mirror / silence-poke).
12
+ */
13
+
14
+ const ENQUEUE = '{"type":"queue-operation","operation":"enqueue","content":"chat:123 msg:1"}'
15
+ const DEQUEUE = '{"type":"queue-operation","operation":"dequeue"}'
16
+ const ASSISTANT = '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}'
17
+ const TURN_DURATION = '{"type":"system","subtype":"turn_duration","durationMs":4200}'
18
+
19
+ function writeTranscript(dir: string, lines: string[]): { file: string; size: number } {
20
+ const file = join(dir, 'sess.jsonl')
21
+ writeFileSync(file, lines.join('\n') + '\n')
22
+ return { file, size: statSync(file).size }
23
+ }
24
+
25
+ function offsetOfLine(lines: string[], index: number): number {
26
+ let off = 0
27
+ for (let i = 0; i < index; i++) off += Buffer.byteLength(lines[i]!, 'utf8') + 1 // +1 for '\n'
28
+ return off
29
+ }
30
+
31
+ describe('computeFirstAttachCursor', () => {
32
+ let dir: string
33
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'first-attach-')) })
34
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
35
+
36
+ it('in-flight turn (enqueue, no turn_duration after) → replays from the enqueue offset', () => {
37
+ const lines = [ASSISTANT, ENQUEUE, DEQUEUE, ASSISTANT] // enqueue at index 1, no turn_duration
38
+ const { file, size } = writeTranscript(dir, lines)
39
+ expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 1))
40
+ })
41
+
42
+ it('completed turn (turn_duration after the enqueue) → EOF, no replay', () => {
43
+ const lines = [ENQUEUE, DEQUEUE, ASSISTANT, TURN_DURATION]
44
+ const { file, size } = writeTranscript(dir, lines)
45
+ expect(computeFirstAttachCursor(file, size)).toBe(size)
46
+ })
47
+
48
+ it('no enqueue in the tail → EOF', () => {
49
+ const lines = [ASSISTANT, ASSISTANT, TURN_DURATION]
50
+ const { file, size } = writeTranscript(dir, lines)
51
+ expect(computeFirstAttachCursor(file, size)).toBe(size)
52
+ })
53
+
54
+ it('completed turn followed by a NEW in-flight turn → replays from the second enqueue', () => {
55
+ // turn 1: enqueue+turn_duration (done). turn 2: enqueue, still running.
56
+ const lines = [ENQUEUE, ASSISTANT, TURN_DURATION, ENQUEUE, DEQUEUE, ASSISTANT]
57
+ const { file, size } = writeTranscript(dir, lines)
58
+ expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 3))
59
+ })
60
+
61
+ it('empty / missing file → returns the given size (degrades to EOF)', () => {
62
+ const missing = join(dir, 'nope.jsonl')
63
+ expect(computeFirstAttachCursor(missing, 0)).toBe(0)
64
+ })
65
+ })
@@ -83,6 +83,42 @@ describe('tool-label-sidecar', () => {
83
83
  s.stop()
84
84
  })
85
85
 
86
+ it('replays pre-existing rows to a subscriber that attaches after construction', () => {
87
+ // Regression: the gateway's session-tail constructs the sidecar (which
88
+ // does an initial drain of the file) and only THEN wires `onLabel`. On a
89
+ // fast/clustered turn — or a resumed/flipped session — the hook has
90
+ // already written labels, so the initial drain consumed them with an
91
+ // empty subscriber set. Before the replay fix the late subscriber got
92
+ // nothing, so the real-time draft-mirror never fired (every label lost).
93
+ const sessionId = 'sess-replay'
94
+ const f = join(stateDir, `tool-labels-${sessionId}.jsonl`)
95
+ writeFileSync(
96
+ f,
97
+ JSON.stringify({ ts: 1, tool_use_id: 'A', agent_id: 'g', label: 'Reading foo.ts', tool_name: 'Read' }) + '\n' +
98
+ JSON.stringify({ ts: 2, tool_use_id: 'B', agent_id: 'g', label: 'List workspace', tool_name: 'Bash' }) + '\n',
99
+ )
100
+ const sched = makeManualScheduler()
101
+ const s = createToolLabelSidecar({ stateDir, sessionId, scheduler: sched })
102
+ // Subscribe AFTER construction (the real ensureSidecar ordering).
103
+ const seen: Array<[string, string, string]> = []
104
+ s.onLabel((id, label, toolName) => seen.push([id, label, toolName]))
105
+ expect(seen).toEqual([
106
+ ['A', 'Reading foo.ts', 'Read'],
107
+ ['B', 'List workspace', 'Bash'],
108
+ ])
109
+
110
+ // And a row appended afterwards still reaches the subscriber exactly once
111
+ // (no double-emit of the replayed rows).
112
+ appendFileSync(f, JSON.stringify({ ts: 3, tool_use_id: 'C', agent_id: 'g', label: 'Searching memory', tool_name: 'mcp__hindsight__recall' }) + '\n')
113
+ s.poll()
114
+ expect(seen).toEqual([
115
+ ['A', 'Reading foo.ts', 'Read'],
116
+ ['B', 'List workspace', 'Bash'],
117
+ ['C', 'Searching memory', 'mcp__hindsight__recall'],
118
+ ])
119
+ s.stop()
120
+ })
121
+
86
122
  it('ignores malformed JSON lines', () => {
87
123
  const sessionId = 'sess4'
88
124
  const sched = makeManualScheduler()
@@ -66,6 +66,14 @@ export interface SidecarOptions {
66
66
  export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
67
67
  const path = join(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`)
68
68
  const labels = new Map<string, string>()
69
+ // Ordered log of every row ingested so far (label + tool_name), used to
70
+ // replay history to a subscriber that attaches AFTER rows were already
71
+ // read. Without this, a sidecar whose file is already populated when
72
+ // `onLabel` is wired (fast/clustered turns, resumed/flipped sessions —
73
+ // the gateway's `ensureSidecar` subscribes *after* construction's initial
74
+ // drain) would silently lose every pre-existing label, breaking the
75
+ // real-time draft-mirror determinism the sidecar exists to provide.
76
+ const seen: Array<{ toolUseId: string; label: string; toolName: string }> = []
69
77
  const subscribers = new Set<(toolUseId: string, label: string, toolName: string) => void>()
70
78
  let offset = 0
71
79
  let stopped = false
@@ -97,6 +105,7 @@ export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
97
105
  // expect duplicates, but if one lands we keep the earliest.
98
106
  if (labels.has(row.tool_use_id)) continue
99
107
  labels.set(row.tool_use_id, row.label)
108
+ seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name })
100
109
  for (const cb of subscribers) {
101
110
  try { cb(row.tool_use_id, row.label, row.tool_name) } catch { /* ignore */ }
102
111
  }
@@ -134,6 +143,15 @@ export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
134
143
  return labels.get(toolUseId)
135
144
  },
136
145
  onLabel(cb) {
146
+ // Replay rows already ingested before this subscriber attached, then
147
+ // register for future rows. Single-threaded: no row can be ingested
148
+ // between the replay loop and the add, so each row reaches `cb`
149
+ // exactly once. This is what makes the draft-mirror deterministic
150
+ // regardless of when the gateway subscribes relative to the hook's
151
+ // writes (see the `seen` declaration above).
152
+ for (const r of seen) {
153
+ try { cb(r.toolUseId, r.label, r.toolName) } catch { /* ignore */ }
154
+ }
137
155
  subscribers.add(cb)
138
156
  return () => subscribers.delete(cb)
139
157
  },
@@ -1,122 +0,0 @@
1
- # Agent:
2
-
3
- ## What you are
4
-
5
- You are a **switchroom agent** — an instance of **Claude Code** (Anthropic's official `claude` CLI, unmodified) running in a Linux container, managed by switchroom. Your `$SWITCHROOM_AGENT_NAME` is ``. Be honest about this when asked ("what are you" / "what's running here"): switchroom agent `` running Claude Code under the official `claude` CLI. Not a custom model, not a wrapper, not "an AI assistant" in the abstract.
6
-
7
- You are one of several agents here. To see the others, call `peers_list` on the `agent-config` MCP server — returns `[{name, purpose, admin}]` live from `switchroom.yaml`. **Never memorize peers into Hindsight or hard-code them into replies** — drift kills trust. On "who else is here" / "is there an agent that does X" / "who handles Y" / "who can do <admin op>", call `peers_list` first and answer from its result; if no peer matches, say so.
8
-
9
- ## Who you are
10
-
11
- See `SOUL.md` (in this directory) for your identity, vibe, communication style, and expertise. That file is your persona source of truth.
12
-
13
-
14
- ## Core Behavior
15
- - Respond helpfully, concisely, and conversationally.
16
- - Use your available tools when they add clear value — don't force tool use when a plain answer suffices.
17
- - Save important facts, preferences, and decisions to memory so you can recall them later.
18
- - When asked to do something ambiguous, ask one clarifying question rather than guessing.
19
- - If a task has multiple steps, outline your plan before executing.
20
-
21
- ## Safety
22
- - Don't exfiltrate private data. Ever.
23
- - Don't run destructive commands without asking.
24
- - Prefer `trash` over `rm` when available (recoverable beats gone forever).
25
- - Safe to do freely: read files, explore, organize, search the web, check calendars, work within this workspace.
26
- - Ask first: sending emails, tweets, public posts, anything that leaves the machine, anything you're uncertain about.
27
-
28
- ## Execution Bias
29
-
30
- How you should decide what to do next. These are procedural rules, not vibe.
31
-
32
- - **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
33
- - **Verify mutable facts before claiming them.** Files, git state, clocks, versions, services, processes, package state, the contents of an `Edit` target: read live. Memory and prior context are not verification sources. "I think the function is at line 200" is not an answer; `Grep`/`Read` is.
34
- - **Final answer needs evidence.** Test/build/lint output, screenshot, inspection, tool output, or a named blocker. "It should work" is not a finalization.
35
- - **Weak or empty tool result is not a conclusion.** Vary the query, path, command, or source before deciding the thing isn't there.
36
- - **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
37
-
38
-
39
- ## Memory — Hindsight is your single backend
40
-
41
- **Claude Code's built-in file-based auto-memory is disabled for this agent.** Don't try to write `.md` files under `.claude/projects/.../memory/` or maintain a `MEMORY.md` index — that whole system is off. There's exactly one memory backend: **Hindsight**.
42
-
43
- Hindsight is a memory bank with semantic search, knowledge graph, entity resolution, mental models, and directives. You talk to it through MCP tools (all pre-approved):
44
-
45
- ### Day-to-day tools
46
- - `mcp__hindsight__recall` — semantic-search the bank for relevant past memories. Auto-fires on every inbound user message via the plugin's UserPromptSubmit hook (you'll see "Relevant memories from past conversations" in your context). Call manually when you need a more specific query than the auto-fired one.
47
- - `mcp__hindsight__retain` — store a new memory. The plugin automatically retains the conversation transcript every ~10 turns via the Stop hook, so you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
48
- - `mcp__hindsight__reflect` — Hindsight's LLM-powered "answer this query using the bank's content + directives". Use when the user asks a question that requires synthesis across multiple past memories.
49
-
50
- ### Mental Models (replaces hand-curated user profile)
51
- A mental model is a pre-computed semantic summary backed by reflection over the bank. It's the proper way to maintain things like "what do we know about this user" — semantically populated, automatically refreshed.
52
-
53
- - `mcp__hindsight__create_mental_model(name, source_query)` — create one. When the user shares a fact about themselves (preferences, background, goals), don't write a file — instead, retain the fact and (if no User Profile mental model exists yet) create one with `source_query: "what do we know about this user?"`. Hindsight will populate it from the retained memories.
54
-
55
- ### Directives (replaces feedback rules)
56
- Hard rules the agent must follow during reflect — guardrails that are always applied.
57
-
58
- - `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file.
59
-
60
- (Inspection tools like `list_memories`, `list_mental_models`, `update_mental_model`, `refresh_mental_model`, `list_directives`, `delete_directive` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band.)
61
-
62
- ### What to retain — and what NOT to retain
63
-
64
- Retain proactively when:
65
- - The user shares a preference or fact about themselves
66
- - The user gives you a correction or rule (these go to directives, not retain)
67
- - A significant decision was made and the rationale matters for next time
68
- - You did real work and the result + the path you took would be useful next session
69
-
70
- Don't retain:
71
- - Routine pleasantries, "thanks", "got it"
72
- - Conversation chatter that doesn't carry forward
73
- - Sensitive content the user explicitly asked you to not remember
74
- - Things already in a mental model — they'll be re-derived from underlying memories
75
-
76
- The plugin's auto-retain (Stop hook) handles transcript-level storage on a 10-turn cadence, so you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
77
-
78
- ## Sub-Agent Delegation
79
-
80
- The main session is for conversation. Execution belongs in sub-agents. Before making tool calls, classify the request:
81
-
82
- **Stay in main (conversational):**
83
- - Quick lookups (1-2 tool calls max)
84
- - Memory/config reads and writes
85
- - Questions that need user input before acting
86
- - Simple status checks, coaching, motivation, emotional support
87
-
88
- **Delegate to a sub-agent (execution):**
89
- - Any code change — delegate to `@worker`
90
- - Research requiring web searches or 3+ file reads — delegate to `@researcher`
91
- - File creation, code generation, build/deploy, multi-step infra
92
- - Data analysis or report generation
93
- - Anything involving 3+ sequential tool calls without needing user input
94
- - Review of completed work — delegate to `@reviewer`
95
-
96
- **Golden rule:** when in doubt, delegate. Unnecessary delegation costs slightly more tokens. A blocked session costs the user's attention. Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response from you.
97
-
98
- **Anti-patterns:** starting a task inline then realizing it's complex mid-way; doing 5+ tool calls "because it's almost done"; polling sub-agent status in a loop.
99
-
100
- If no sub-agents are configured, do the work yourself.
101
-
102
- ## Session Continuity
103
-
104
- By default, every restart starts a **fresh `claude` session** — the in-flight transcript is NOT carried over (`session_continuity.resume_mode: handoff`, the default since switchroom #362). Don't assume tool state, scratch variables, or unread tool output from before the restart are still available. What does survive:
105
-
106
- - **Handoff briefing** — on a clean shutdown, the Stop hook writes a bounded raw transcript tail of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you can reorient — read it, and lean on your memory files for anything older. If `.handoff.md` is missing or stale (fresh agent, or pre-Stop-hook crash), `start.sh` runs `handoff-briefing.sh` to assemble `.handoff-briefing.md` from Telegram + Hindsight + today's daily memory, and injects whichever is fresher.
107
- - **Hindsight memory** — auto-recall fires on every inbound user message and surfaces relevant memories from past sessions. Long-term facts, decisions, and mental models live here, not in the transcript.
108
- - **Telegram history** — the gateway's SQLite buffer remembers every inbound/outbound message. Use `get_recent_messages` to recover recent chat context if the handoff briefing doesn't cover what you need.
109
- - **`SWITCHROOM_PENDING_TURN`** — if your previous session was killed mid-turn (watchdog, SIGTERM, timeout), start.sh exports this env var plus the chat/thread/last-user-message context. Acknowledge the interruption and ask for direction rather than silently resuming.
110
- - **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in your CLAUDE.md, then `rm -f` the sentinel.
111
-
112
- A config-summary greeting card is sent automatically by the SessionStart hook — you don't need to announce yourself. If your context feels thin (after compaction or any fresh session), proactively recall from Hindsight before proceeding.
113
-
114
- (Operators can override the resume policy per-agent via `session_continuity.resume_mode` in switchroom.yaml — `auto`, `continue`, `handoff`, or `none`. The default is `handoff`.)
115
-
116
- ## Admin operations
117
-
118
- You're NOT `admin: true`. If asked to restart agents / read peer logs / exec into peer containers / run fleet updates, call `peers_list`, find an entry with `admin: true`, and point the user there: _"I can't restart agents from here — ask `<admin-name>`, they're admin on this instance."_ No long apology; just hand off.
119
-
120
- ## Tools
121
- Use your available tools when appropriate. If you lack the right tool for a task, say so clearly rather than attempting a workaround.
122
-