switchroom 0.12.23 → 0.12.24

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.
@@ -47247,8 +47247,8 @@ var {
47247
47247
  } = import__.default;
47248
47248
 
47249
47249
  // src/build-info.ts
47250
- var VERSION = "0.12.23";
47251
- var COMMIT_SHA = "6c99950";
47250
+ var VERSION = "0.12.24";
47251
+ var COMMIT_SHA = "7ab1329";
47252
47252
 
47253
47253
  // src/cli/agent.ts
47254
47254
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.23",
3
+ "version": "0.12.24",
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": {
@@ -27253,32 +27253,32 @@ function formatAuthQuotaLine(acc, now = Date.now()) {
27253
27253
  }
27254
27254
  return null;
27255
27255
  }
27256
- function renderAuthLine(state3, agentName3, now = Date.now()) {
27257
- if (!state3 || state3.accounts.length === 0)
27256
+ function renderAuthLine(state4, agentName3, now = Date.now()) {
27257
+ if (!state4 || state4.accounts.length === 0)
27258
27258
  return [];
27259
- const agentEntry = state3.agents.find((a) => a.name === agentName3);
27260
- const activeLabel = agentEntry?.override ?? agentEntry?.account ?? state3.active;
27259
+ const agentEntry = state4.agents.find((a) => a.name === agentName3);
27260
+ const activeLabel = agentEntry?.override ?? agentEntry?.account ?? state4.active;
27261
27261
  const seen = new Set;
27262
27262
  const order = [];
27263
27263
  if (activeLabel) {
27264
27264
  order.push(activeLabel);
27265
27265
  seen.add(activeLabel);
27266
27266
  }
27267
- for (const label of state3.fallback_order) {
27267
+ for (const label of state4.fallback_order) {
27268
27268
  if (!seen.has(label)) {
27269
27269
  order.push(label);
27270
27270
  seen.add(label);
27271
27271
  }
27272
27272
  }
27273
- for (const acc of state3.accounts) {
27273
+ for (const acc of state4.accounts) {
27274
27274
  if (!seen.has(acc.label)) {
27275
27275
  order.push(acc.label);
27276
27276
  seen.add(acc.label);
27277
27277
  }
27278
27278
  }
27279
- const byLabel = new Map(state3.accounts.map((a) => [a.label, a]));
27279
+ const byLabel = new Map(state4.accounts.map((a) => [a.label, a]));
27280
27280
  const rows = [];
27281
- rows.push(`<b>Accounts (${state3.accounts.length})</b>`);
27281
+ rows.push(`<b>Accounts (${state4.accounts.length})</b>`);
27282
27282
  for (const label of order) {
27283
27283
  const acc = byLabel.get(label);
27284
27284
  if (!acc)
@@ -27540,15 +27540,15 @@ function uptimeMsForStarttime(starttimeTicks, fs2 = realProcFs) {
27540
27540
  return null;
27541
27541
  }
27542
27542
  }
27543
- function nextStepForAgentState(agentName3, state3) {
27544
- if (state3 === "failed") {
27543
+ function nextStepForAgentState(agentName3, state4) {
27544
+ if (state4 === "failed") {
27545
27545
  return `Service failed \u2014 inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\` then \`switchroom agent restart ${agentName3}\``;
27546
27546
  }
27547
- if (state3 === "inactive") {
27547
+ if (state4 === "inactive") {
27548
27548
  return `Service inactive \u2014 start with \`switchroom agent start ${agentName3}\` (or \`systemctl --user start switchroom-${agentName3}\`)`;
27549
27549
  }
27550
- if (state3 === "deactivating" || state3 === "activating" || state3 === "auto-restart") {
27551
- return `Service is in a transient \`${state3}\` state \u2014 re-check with \`switchroom agent status ${agentName3}\` in a few seconds`;
27550
+ if (state4 === "deactivating" || state4 === "activating" || state4 === "auto-restart") {
27551
+ return `Service is in a transient \`${state4}\` state \u2014 re-check with \`switchroom agent status ${agentName3}\` in a few seconds`;
27552
27552
  }
27553
27553
  return `Inspect with \`journalctl --user -u switchroom-${agentName3} -n 100\``;
27554
27554
  }
@@ -27670,8 +27670,8 @@ async function probeAgentProcess(agentName3, opts = {}) {
27670
27670
  if ("error" in snapshot) {
27671
27671
  return { status: "fail", label: "Agent", detail: snapshot.error };
27672
27672
  }
27673
- const { state: state3, kv } = snapshot;
27674
- if (state3 === "active") {
27673
+ const { state: state4, kv } = snapshot;
27674
+ if (state4 === "active") {
27675
27675
  let pid = kv["MainPID"] ?? "?";
27676
27676
  if (opts.tmuxSupervisor) {
27677
27677
  const resolved = await resolveTmuxSupervisorPid(agentName3, execFileFn);
@@ -27685,10 +27685,10 @@ async function probeAgentProcess(agentName3, opts = {}) {
27685
27685
  }
27686
27686
  const elapsedMs = Date.now() - startMs;
27687
27687
  if (elapsedMs >= retryMaxMs) {
27688
- const isTransient = state3 === "deactivating" || state3 === "activating" || state3 === "auto-restart";
27688
+ const isTransient = state4 === "deactivating" || state4 === "activating" || state4 === "auto-restart";
27689
27689
  const status = isTransient ? "degraded" : "fail";
27690
- const nextStep = nextStepForAgentState(agentName3, state3);
27691
- return { status, label: "Agent", detail: `service ${state3}`, ...nextStep ? { nextStep } : {} };
27690
+ const nextStep = nextStepForAgentState(agentName3, state4);
27691
+ return { status, label: "Agent", detail: `service ${state4}`, ...nextStep ? { nextStep } : {} };
27692
27692
  }
27693
27693
  await sleep2(retryIntervalMs);
27694
27694
  }
@@ -27708,8 +27708,8 @@ async function* watchAgentProcess(agentName3, opts = {}) {
27708
27708
  const now = opts.nowImpl ?? (() => Date.now());
27709
27709
  const startMs = now();
27710
27710
  let lastYieldedDetail = null;
27711
- async function toProbeResult(state3, kv, withinWindow) {
27712
- if (state3 === "active") {
27711
+ async function toProbeResult(state4, kv, withinWindow) {
27712
+ if (state4 === "active") {
27713
27713
  let pid = kv["MainPID"] ?? "?";
27714
27714
  if (opts.tmuxSupervisor) {
27715
27715
  const resolved = await resolveTmuxSupervisorPid(agentName3, execFileFn);
@@ -27722,15 +27722,15 @@ async function* watchAgentProcess(agentName3, opts = {}) {
27722
27722
  return { status: "ok", label: "Agent", detail: parts.join(" \u00b7 ") };
27723
27723
  }
27724
27724
  if (withinWindow) {
27725
- if (state3 === "failed") {
27725
+ if (state4 === "failed") {
27726
27726
  return { status: "fail", label: "Agent", detail: "service failed" };
27727
27727
  }
27728
27728
  return { status: "degraded", label: "Agent", detail: "service starting" };
27729
27729
  }
27730
- const isTransient = state3 === "deactivating" || state3 === "activating" || state3 === "auto-restart" || state3 === "inactive";
27730
+ const isTransient = state4 === "deactivating" || state4 === "activating" || state4 === "auto-restart" || state4 === "inactive";
27731
27731
  const status = isTransient ? "degraded" : "fail";
27732
- const nextStep = nextStepForAgentState(agentName3, state3);
27733
- return { status, label: "Agent", detail: `service ${state3}`, ...nextStep ? { nextStep } : {} };
27732
+ const nextStep = nextStepForAgentState(agentName3, state4);
27733
+ return { status, label: "Agent", detail: `service ${state4}`, ...nextStep ? { nextStep } : {} };
27734
27734
  }
27735
27735
  while (true) {
27736
27736
  const elapsedMs = now() - startMs;
@@ -29553,14 +29553,14 @@ function switchPriority2(s) {
29553
29553
  function escapeHtml12(s) {
29554
29554
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29555
29555
  }
29556
- function buildSnapshotsFromState2(state3, quotas) {
29556
+ function buildSnapshotsFromState2(state4, quotas) {
29557
29557
  const out = [];
29558
- for (let i = 0;i < state3.accounts.length; i++) {
29559
- const acc = state3.accounts[i];
29558
+ for (let i = 0;i < state4.accounts.length; i++) {
29559
+ const acc = state4.accounts[i];
29560
29560
  const q = quotas[i];
29561
29561
  out.push({
29562
29562
  label: acc.label,
29563
- isActive: acc.label === state3.active,
29563
+ isActive: acc.label === state4.active,
29564
29564
  quota: q && q.ok ? q.data : null,
29565
29565
  quotaError: q && !q.ok ? q.reason : undefined,
29566
29566
  expiresAtMs: acc.expiresAt
@@ -43814,6 +43814,237 @@ function chatKeyWithSuffix(chatId, threadId, suffix) {
43814
43814
  return `${chatKey(chatId, threadId)}:${suffix}`;
43815
43815
  }
43816
43816
 
43817
+ // gateway/inbound-delivery-machine.ts
43818
+ function initialState() {
43819
+ return {
43820
+ global: { kind: "bridge_dead" },
43821
+ perKey: new Map
43822
+ };
43823
+ }
43824
+ var TURN_TTL_MS = 300000;
43825
+ var OUTBOUND_RECENT_MS = 60000;
43826
+ function emptyPerKey() {
43827
+ return { turnStartedAt: null, lastOutboundAt: null };
43828
+ }
43829
+ function updatePerKey(state3, key, update) {
43830
+ const prior = state3.perKey.get(key) ?? emptyPerKey();
43831
+ const next = update(prior);
43832
+ const m = new Map(state3.perKey);
43833
+ if (next.turnStartedAt == null && next.lastOutboundAt == null) {
43834
+ m.delete(key);
43835
+ } else {
43836
+ m.set(key, next);
43837
+ }
43838
+ return { ...state3, perKey: m };
43839
+ }
43840
+ function chatIdOfKey(key) {
43841
+ const idx = key.indexOf(":");
43842
+ return idx === -1 ? key : key.slice(0, idx);
43843
+ }
43844
+ function sweepSiblings(state3, chatId, exceptKey) {
43845
+ const effects = [];
43846
+ let next = state3;
43847
+ for (const [k, v] of state3.perKey) {
43848
+ if (k === exceptKey)
43849
+ continue;
43850
+ if (chatIdOfKey(k) !== chatId)
43851
+ continue;
43852
+ if (v.turnStartedAt == null)
43853
+ continue;
43854
+ effects.push({ kind: "clearTurnStarted", key: k });
43855
+ next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }));
43856
+ }
43857
+ return { state: next, effects };
43858
+ }
43859
+ function transition(state3, event) {
43860
+ switch (event.kind) {
43861
+ case "bridgeUp": {
43862
+ if (state3.global.kind !== "bridge_dead") {
43863
+ return { state: state3, effects: [{ kind: "logTrace", stage: "bridgeUp_redundant" }] };
43864
+ }
43865
+ return {
43866
+ state: { ...state3, global: { kind: "bridge_alive_idle" } },
43867
+ effects: [
43868
+ { kind: "redeliverPersistedPermVerdicts" },
43869
+ { kind: "drainBuffer" },
43870
+ { kind: "logTrace", stage: "bridge_recover" }
43871
+ ]
43872
+ };
43873
+ }
43874
+ case "bridgeDown": {
43875
+ return {
43876
+ state: { ...state3, global: { kind: "bridge_dead" } },
43877
+ effects: [{ kind: "logTrace", stage: "bridge_flap" }]
43878
+ };
43879
+ }
43880
+ case "inbound": {
43881
+ const isSteering = event.msg.isSteering;
43882
+ const inTurn = state3.global.kind === "bridge_alive_in_turn";
43883
+ const alive = state3.global.kind !== "bridge_dead";
43884
+ if (!alive) {
43885
+ return {
43886
+ state: state3,
43887
+ effects: [
43888
+ { kind: "bufferInbound", key: event.key, msg: event.msg },
43889
+ { kind: "persistInbound", key: event.key, msg: event.msg },
43890
+ { kind: "logTrace", stage: "inbound_bridge_dead_buffer", key: event.key }
43891
+ ]
43892
+ };
43893
+ }
43894
+ if (inTurn && !isSteering) {
43895
+ return {
43896
+ state: state3,
43897
+ effects: [
43898
+ { kind: "bufferInbound", key: event.key, msg: event.msg },
43899
+ { kind: "persistInbound", key: event.key, msg: event.msg },
43900
+ { kind: "logTrace", stage: "inbound_held_mid_turn", key: event.key, metadata: { msgId: event.msg.msgId } }
43901
+ ]
43902
+ };
43903
+ }
43904
+ if (isSteering) {
43905
+ return {
43906
+ state: state3,
43907
+ effects: [
43908
+ { kind: "deliverToBridge", key: event.key, msg: event.msg },
43909
+ { kind: "logTrace", stage: "steer_delivered_mid_turn", key: event.key }
43910
+ ]
43911
+ };
43912
+ }
43913
+ const next = {
43914
+ global: { kind: "bridge_alive_in_turn", activeTurn: event.key },
43915
+ perKey: state3.perKey
43916
+ };
43917
+ return {
43918
+ state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
43919
+ effects: [
43920
+ { kind: "setTurnStarted", key: event.key, at: event.at },
43921
+ { kind: "deliverToBridge", key: event.key, msg: event.msg },
43922
+ { kind: "logTrace", stage: "fresh_turn_deliver", key: event.key, metadata: { msgId: event.msg.msgId } }
43923
+ ]
43924
+ };
43925
+ }
43926
+ case "turnStart": {
43927
+ const next = state3.global.kind === "bridge_alive_in_turn" ? state3 : { ...state3, global: { kind: "bridge_alive_in_turn", activeTurn: event.key } };
43928
+ return {
43929
+ state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
43930
+ effects: [
43931
+ { kind: "setTurnStarted", key: event.key, at: event.at },
43932
+ { kind: "logTrace", stage: "turn_start", key: event.key }
43933
+ ]
43934
+ };
43935
+ }
43936
+ case "turnEnd": {
43937
+ const chatId = chatIdOfKey(event.key);
43938
+ const stateAfterClear = updatePerKey(state3, event.key, (p) => ({
43939
+ turnStartedAt: null,
43940
+ lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt
43941
+ }));
43942
+ const sweep = sweepSiblings(stateAfterClear, chatId, event.key);
43943
+ const wasActive = state3.global.kind === "bridge_alive_in_turn" && state3.global.activeTurn === event.key;
43944
+ const next = wasActive ? { ...sweep.state, global: { kind: "bridge_alive_idle" } } : sweep.state;
43945
+ const effects = [
43946
+ { kind: "clearTurnStarted", key: event.key },
43947
+ ...sweep.effects
43948
+ ];
43949
+ if (event.outboundEmitted) {
43950
+ effects.push({ kind: "noteOutbound", key: event.key, at: event.at });
43951
+ }
43952
+ effects.push({ kind: "drainBuffer" });
43953
+ effects.push({ kind: "logTrace", stage: "turn_complete", key: event.key, metadata: { outboundEmitted: event.outboundEmitted } });
43954
+ return { state: next, effects };
43955
+ }
43956
+ case "modelOutbound": {
43957
+ return {
43958
+ state: updatePerKey(state3, event.key, (p) => ({ ...p, lastOutboundAt: event.at })),
43959
+ effects: [
43960
+ { kind: "noteOutbound", key: event.key, at: event.at }
43961
+ ]
43962
+ };
43963
+ }
43964
+ case "permVerdict": {
43965
+ const alive = state3.global.kind !== "bridge_dead";
43966
+ if (alive) {
43967
+ return {
43968
+ state: state3,
43969
+ effects: [
43970
+ { kind: "deliverPermVerdict", verdict: event.verdict },
43971
+ { kind: "logTrace", stage: "perm_verdict_delivered" }
43972
+ ]
43973
+ };
43974
+ }
43975
+ return {
43976
+ state: state3,
43977
+ effects: [
43978
+ { kind: "persistPermVerdict", verdict: event.verdict },
43979
+ { kind: "logTrace", stage: "perm_verdict_persisted" }
43980
+ ]
43981
+ };
43982
+ }
43983
+ case "tick": {
43984
+ const effects = [];
43985
+ let next = state3;
43986
+ for (const [k, v] of state3.perKey) {
43987
+ if (v.turnStartedAt == null)
43988
+ continue;
43989
+ const age = event.now - v.turnStartedAt;
43990
+ if (age <= TURN_TTL_MS) {
43991
+ continue;
43992
+ }
43993
+ const recentOutbound = v.lastOutboundAt != null && event.now - v.lastOutboundAt < OUTBOUND_RECENT_MS;
43994
+ if (recentOutbound) {
43995
+ effects.push({ kind: "logTrace", stage: "fallback_suppressed", key: k, metadata: { recentOutboundMs: event.now - (v.lastOutboundAt ?? 0) } });
43996
+ continue;
43997
+ }
43998
+ effects.push({ kind: "firePoke", key: k, level: "fallback" });
43999
+ effects.push({ kind: "clearTurnStarted", key: k });
44000
+ next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }));
44001
+ if (next.global.kind === "bridge_alive_in_turn" && next.global.activeTurn === k) {
44002
+ next = { ...next, global: { kind: "bridge_alive_idle" } };
44003
+ }
44004
+ }
44005
+ return { state: next, effects };
44006
+ }
44007
+ }
44008
+ }
44009
+
44010
+ // gateway/inbound-delivery-machine-shadow.ts
44011
+ var state3 = initialState();
44012
+ var enabled2 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
44013
+ function shadowEmit(event) {
44014
+ if (!enabled2)
44015
+ return [];
44016
+ try {
44017
+ const result = transition(state3, event);
44018
+ state3 = result.state;
44019
+ const effectKinds = result.effects.map((e) => e.kind).join(",");
44020
+ const eventDetail = formatEventDetail(event);
44021
+ process.stderr.write(`gw-trace shadow event=${event.kind}${eventDetail} effects=[${effectKinds}] global=${state3.global.kind} perKeySize=${state3.perKey.size}
44022
+ `);
44023
+ return result.effects;
44024
+ } catch (err) {
44025
+ process.stderr.write(`gw-trace shadow ERROR event=${event.kind} err=${err instanceof Error ? err.message : String(err)}
44026
+ `);
44027
+ return [];
44028
+ }
44029
+ }
44030
+ function formatEventDetail(event) {
44031
+ switch (event.kind) {
44032
+ case "inbound":
44033
+ return ` key=${event.key} msg=${event.msg.msgId} steer=${event.msg.isSteering}`;
44034
+ case "turnStart":
44035
+ case "turnEnd":
44036
+ return ` key=${event.key}${event.kind === "turnEnd" ? ` outbound=${event.outboundEmitted}` : ""}`;
44037
+ case "permVerdict":
44038
+ return ` req=${event.verdict.requestId} behavior=${event.verdict.behavior}`;
44039
+ case "modelOutbound":
44040
+ return ` key=${event.key}`;
44041
+ case "bridgeUp":
44042
+ case "bridgeDown":
44043
+ case "tick":
44044
+ return "";
44045
+ }
44046
+ }
44047
+
43817
44048
  // gateway/vault-grant-inbound-builders.ts
43818
44049
  function buildVaultGrantApprovedInbound(opts) {
43819
44050
  const ts = opts.nowMs ?? Date.now();
@@ -45248,7 +45479,7 @@ function extractToolResultErrorText(content) {
45248
45479
  }
45249
45480
  return "";
45250
45481
  }
45251
- function projectSubagentLine(line, agentId, state3) {
45482
+ function projectSubagentLine(line, agentId, state4) {
45252
45483
  let obj;
45253
45484
  try {
45254
45485
  obj = JSON.parse(line);
@@ -45261,8 +45492,8 @@ function projectSubagentLine(line, agentId, state3) {
45261
45492
  if (type === "user") {
45262
45493
  const message = obj.message;
45263
45494
  const content = message?.content;
45264
- if (!state3.hasEmittedStart) {
45265
- state3.hasEmittedStart = true;
45495
+ if (!state4.hasEmittedStart) {
45496
+ state4.hasEmittedStart = true;
45266
45497
  let promptText = "";
45267
45498
  if (typeof content === "string") {
45268
45499
  promptText = content;
@@ -47046,10 +47277,10 @@ function loadCreditState(stateDir) {
47046
47277
  } catch {}
47047
47278
  return emptyCreditState();
47048
47279
  }
47049
- function saveCreditState(stateDir, state3) {
47280
+ function saveCreditState(stateDir, state4) {
47050
47281
  mkdirSync15(stateDir, { recursive: true });
47051
47282
  const path = join27(stateDir, STATE_FILE);
47052
- writeFileSync17(path, JSON.stringify(state3, null, 2) + `
47283
+ writeFileSync17(path, JSON.stringify(state4, null, 2) + `
47053
47284
  `, { mode: 384 });
47054
47285
  }
47055
47286
 
@@ -47126,11 +47357,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47126
47357
  }
47127
47358
 
47128
47359
  // ../src/build-info.ts
47129
- var VERSION = "0.12.23";
47130
- var COMMIT_SHA = "6c99950";
47131
- var COMMIT_DATE = "2026-05-20T04:16:33Z";
47132
- var LATEST_PR = 1580;
47133
- var COMMITS_AHEAD_OF_TAG = 1;
47360
+ var VERSION = "0.12.24";
47361
+ var COMMIT_SHA = "7ab1329";
47362
+ var COMMIT_DATE = "2026-05-20T14:48:10+10:00";
47363
+ var LATEST_PR = 1582;
47364
+ var COMMITS_AHEAD_OF_TAG = 3;
47134
47365
 
47135
47366
  // gateway/boot-version.ts
47136
47367
  function formatRelativeAgo(iso) {
@@ -48079,6 +48310,7 @@ function streamKey3(chatId, threadId) {
48079
48310
  return chatKey(chatId, threadId);
48080
48311
  }
48081
48312
  function purgeReactionTracking(key) {
48313
+ shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
48082
48314
  const msgInfo = activeReactionMsgIds.get(key);
48083
48315
  activeStatusReactions.delete(key);
48084
48316
  activeReactionMsgIds.delete(key);
@@ -49002,6 +49234,7 @@ var ipcServer = createIpcServer({
49002
49234
  onClientRegistered(client3) {
49003
49235
  process.stderr.write(`telegram gateway: bridge registered \u2014 agent=${client3.agentName}
49004
49236
  `);
49237
+ shadowEmit({ kind: "bridgeUp", at: Date.now() });
49005
49238
  client3.send({ type: "status", status: "agent_connected" });
49006
49239
  if (client3.agentName != null) {
49007
49240
  const pending = pendingInboundBuffer.drain(client3.agentName);
@@ -49095,6 +49328,7 @@ var ipcServer = createIpcServer({
49095
49328
  onClientDisconnected(client3) {
49096
49329
  process.stderr.write(`telegram gateway: bridge disconnected \u2014 agent=${client3.agentName}
49097
49330
  `);
49331
+ shadowEmit({ kind: "bridgeDown", at: Date.now() });
49098
49332
  flushOnAgentDisconnect({
49099
49333
  agentName: client3.agentName,
49100
49334
  activeStatusReactions,
@@ -51057,7 +51291,7 @@ function handleSessionEvent(ev) {
51057
51291
  }
51058
51292
  function handlePtyPartial(text) {
51059
51293
  const turn = currentTurn;
51060
- const state3 = {
51294
+ const state4 = {
51061
51295
  currentSessionChatId: turn?.sessionChatId ?? null,
51062
51296
  currentSessionThreadId: turn?.sessionThreadId,
51063
51297
  pendingPtyPartial: pendingPtyPartial != null ? { text: pendingPtyPartial } : null,
@@ -51066,7 +51300,7 @@ function handlePtyPartial(text) {
51066
51300
  suppressPtyPreview,
51067
51301
  lastPtyPreviewByChat
51068
51302
  };
51069
- handlePtyPartialPure(text, state3, {
51303
+ handlePtyPartialPure(text, state4, {
51070
51304
  bot,
51071
51305
  retry: robustApiCall,
51072
51306
  renderText: markdownToHtml,
@@ -51082,7 +51316,7 @@ function handlePtyPartial(text) {
51082
51316
  },
51083
51317
  writeError: (line) => process.stderr.write(line)
51084
51318
  });
51085
- pendingPtyPartial = state3.pendingPtyPartial?.text ?? null;
51319
+ pendingPtyPartial = state4.pendingPtyPartial?.text ?? null;
51086
51320
  }
51087
51321
  var gateDenyLastLoggedAt = new Map;
51088
51322
  var GATE_DENY_LOG_WINDOW_MS = 60000;
@@ -51275,6 +51509,17 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
51275
51509
  return;
51276
51510
  }
51277
51511
  const inboundReceivedAt = Date.now();
51512
+ const _shadowKey = statusKey(ctx.chat?.id != null ? String(ctx.chat.id) : "0", ctx.message?.message_thread_id);
51513
+ shadowEmit({
51514
+ kind: "inbound",
51515
+ key: _shadowKey,
51516
+ msg: {
51517
+ msgId: ctx.message?.message_id ?? 0,
51518
+ isSteering: false,
51519
+ payload: null
51520
+ },
51521
+ at: Date.now()
51522
+ });
51278
51523
  const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
51279
51524
  const access = result.access;
51280
51525
  const from = ctx.from;
@@ -53140,15 +53385,15 @@ async function doFireFleetAutoFallback(triggerAgent) {
53140
53385
  `);
53141
53386
  return false;
53142
53387
  }
53143
- const state3 = await client3.listState();
53144
- const probeResp = state3.accounts.length > 0 ? await client3.probeQuota(state3.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
53145
- const quotas = state3.accounts.map((a) => {
53388
+ const state4 = await client3.listState();
53389
+ const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
53390
+ const quotas = state4.accounts.map((a) => {
53146
53391
  const hit = probeResp.results.find((r) => r.label === a.label);
53147
53392
  return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
53148
53393
  });
53149
53394
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
53150
53395
  const outcome = await runFleetAutoFallback({
53151
- state: state3,
53396
+ state: state4,
53152
53397
  quotas,
53153
53398
  setActive: (label) => client3.setActive(label),
53154
53399
  triggerAgent,
@@ -53334,9 +53579,9 @@ async function probeQuotaForBootCard(agent, timeoutMs) {
53334
53579
  const client3 = await getAuthBrokerClient(agent);
53335
53580
  if (!client3)
53336
53581
  return null;
53337
- const state3 = await client3.listState();
53338
- const entry = state3.agents.find((a) => a.name === agent);
53339
- const label = entry?.override ?? entry?.account ?? state3.active;
53582
+ const state4 = await client3.listState();
53583
+ const entry = state4.agents.find((a) => a.name === agent);
53584
+ const label = entry?.override ?? entry?.account ?? state4.active;
53340
53585
  if (!label)
53341
53586
  return null;
53342
53587
  const { results } = await client3.probeQuota([label], timeoutMs);
@@ -53853,34 +54098,34 @@ Which keys for <code>${escapeHtmlForTg(agent)}</code>?
53853
54098
  startedAt: Date.now()
53854
54099
  });
53855
54100
  }
53856
- async function grantWizardStep3(ctx, chatId, state3) {
54101
+ async function grantWizardStep3(ctx, chatId, state4) {
53857
54102
  const kb = buildGrantDurationKeyboard();
53858
- const keyList = state3.selectedKeys.map((k) => `\u2022 <code>${escapeHtmlForTg(k)}</code>`).join(`
54103
+ const keyList = state4.selectedKeys.map((k) => `\u2022 <code>${escapeHtmlForTg(k)}</code>`).join(`
53859
54104
  `);
53860
54105
  const text = `<b>Grant capability token \u2014 Step 3/3</b>
53861
54106
 
53862
- Keys for <code>${escapeHtmlForTg(state3.agent)}</code>:
54107
+ Keys for <code>${escapeHtmlForTg(state4.agent)}</code>:
53863
54108
  ${keyList}
53864
54109
 
53865
54110
  How long should this grant be valid?`;
53866
- const msgId = state3.wizardMsgId;
54111
+ const msgId = state4.wizardMsgId;
53867
54112
  if (msgId != null) {
53868
54113
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: "HTML", reply_markup: kb }).catch(() => {});
53869
54114
  } else {
53870
54115
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb });
53871
- state3.wizardMsgId = sent?.message_id;
54116
+ state4.wizardMsgId = sent?.message_id;
53872
54117
  }
53873
- pendingVaultOps.set(chatId, { ...state3, step: "duration" });
54118
+ pendingVaultOps.set(chatId, { ...state4, step: "duration" });
53874
54119
  }
53875
- async function grantWizardConfirm(ctx, chatId, state3) {
54120
+ async function grantWizardConfirm(ctx, chatId, state4) {
53876
54121
  const kb = buildGrantConfirmKeyboard();
53877
- const expiresLabel = formatGrantExpiry(state3.ttlSeconds);
53878
- const keyList = state3.selectedKeys.map((k) => `\u2022 <code>${escapeHtmlForTg(k)}</code>`).join(`
54122
+ const expiresLabel = formatGrantExpiry(state4.ttlSeconds);
54123
+ const keyList = state4.selectedKeys.map((k) => `\u2022 <code>${escapeHtmlForTg(k)}</code>`).join(`
53879
54124
  `);
53880
54125
  const text = [
53881
54126
  "<b>Confirm grant</b>",
53882
54127
  "",
53883
- `Agent: <code>${escapeHtmlForTg(state3.agent)}</code>`,
54128
+ `Agent: <code>${escapeHtmlForTg(state4.agent)}</code>`,
53884
54129
  `Keys:
53885
54130
  ${keyList}`,
53886
54131
  `Expires: <b>${escapeHtmlForTg(expiresLabel)}</b>`,
@@ -53888,34 +54133,34 @@ ${keyList}`,
53888
54133
  "Tap <b>Generate</b> to mint the token."
53889
54134
  ].join(`
53890
54135
  `);
53891
- const msgId = state3.wizardMsgId;
54136
+ const msgId = state4.wizardMsgId;
53892
54137
  if (msgId != null) {
53893
54138
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: "HTML", reply_markup: kb }).catch(() => {});
53894
54139
  } else {
53895
54140
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb });
53896
- state3.wizardMsgId = sent?.message_id;
54141
+ state4.wizardMsgId = sent?.message_id;
53897
54142
  }
53898
- const kernelRequestId = await mintGrantWizardKernelRequest(state3.agent, loadAccess().allowFrom, state3.selectedKeys, state3.ttlSeconds ?? null);
54143
+ const kernelRequestId = await mintGrantWizardKernelRequest(state4.agent, loadAccess().allowFrom, state4.selectedKeys, state4.ttlSeconds ?? null);
53899
54144
  pendingVaultOps.set(chatId, {
53900
- ...state3,
54145
+ ...state4,
53901
54146
  step: "confirm",
53902
54147
  expiresLabel,
53903
- kernel_request_id: kernelRequestId ?? state3.kernel_request_id
54148
+ kernel_request_id: kernelRequestId ?? state4.kernel_request_id
53904
54149
  });
53905
54150
  }
53906
- async function executeGrantWizard(ctx, chatId, state3) {
54151
+ async function executeGrantWizard(ctx, chatId, state4) {
53907
54152
  pendingVaultOps.delete(chatId);
53908
- await recordGrantWizardKernelDecision(state3.kernel_request_id, "allow_once", ctx.from?.id ?? 0, loadAccess().allowFrom);
54153
+ await recordGrantWizardKernelDecision(state4.kernel_request_id, "allow_once", ctx.from?.id ?? 0, loadAccess().allowFrom);
53909
54154
  try {
53910
- assertSafeAgentName(state3.agent);
54155
+ assertSafeAgentName(state4.agent);
53911
54156
  } catch {
53912
54157
  return;
53913
54158
  }
53914
54159
  const result = await mintGrantViaBroker({
53915
- agent: state3.agent,
53916
- keys: state3.selectedKeys,
53917
- ttl_seconds: state3.ttlSeconds ?? null,
53918
- description: state3.description
54160
+ agent: state4.agent,
54161
+ keys: state4.selectedKeys,
54162
+ ttl_seconds: state4.ttlSeconds ?? null,
54163
+ description: state4.description
53919
54164
  });
53920
54165
  if (result.kind === "unreachable") {
53921
54166
  await switchroomReply(ctx, `\uD83D\uDD34 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true });
@@ -53926,16 +54171,16 @@ async function executeGrantWizard(ctx, chatId, state3) {
53926
54171
  return;
53927
54172
  }
53928
54173
  const { token, id } = result;
53929
- const tokenPath = join32(homedir12(), ".switchroom", "agents", state3.agent, ".vault-token");
54174
+ const tokenPath = join32(homedir12(), ".switchroom", "agents", state4.agent, ".vault-token");
53930
54175
  try {
53931
- mkdirSync21(join32(homedir12(), ".switchroom", "agents", state3.agent), { recursive: true });
54176
+ mkdirSync21(join32(homedir12(), ".switchroom", "agents", state4.agent), { recursive: true });
53932
54177
  writeFileSync21(tokenPath, token, { mode: 384 });
53933
54178
  } catch (err) {
53934
54179
  await switchroomReply(ctx, `<b>Grant created but token write failed:</b> ${escapeHtmlForTg(String(err))}`, { html: true });
53935
54180
  return;
53936
54181
  }
53937
- const msgId = state3.wizardMsgId;
53938
- const successText = `\u2705 Grant <code>${escapeHtmlForTg(id)}</code> created. Written to <code>~/.switchroom/agents/${escapeHtmlForTg(state3.agent)}/.vault-token</code>`;
54182
+ const msgId = state4.wizardMsgId;
54183
+ const successText = `\u2705 Grant <code>${escapeHtmlForTg(id)}</code> created. Written to <code>~/.switchroom/agents/${escapeHtmlForTg(state4.agent)}/.vault-token</code>`;
53939
54184
  if (msgId != null) {
53940
54185
  await finalizeCallback(ctx, {
53941
54186
  ackText: "\u2705 Grant created",
@@ -54029,59 +54274,59 @@ Keys: <code>${escapeHtmlForTg(grant.key_allow.join(", "))}</code>`;
54029
54274
  await ackSilently();
54030
54275
  return;
54031
54276
  }
54032
- const state3 = pendingVaultOps.get(chatId);
54033
- if (!state3 || state3.kind !== "grant-wizard") {
54277
+ const state4 = pendingVaultOps.get(chatId);
54278
+ if (!state4 || state4.kind !== "grant-wizard") {
54034
54279
  await ctx.editMessageText("\u26A0\uFE0F Wizard session expired. Run /vault grant to start again.").catch(() => {});
54035
54280
  await ackSilently();
54036
54281
  return;
54037
54282
  }
54038
54283
  if (data.startsWith("vg:agent:")) {
54039
54284
  const agent = data.slice("vg:agent:".length);
54040
- const msgId = ctx.callbackQuery?.message?.message_id ?? state3.wizardMsgId;
54285
+ const msgId = ctx.callbackQuery?.message?.message_id ?? state4.wizardMsgId;
54041
54286
  await grantWizardStep2(ctx, chatId, agent, msgId);
54042
54287
  await ackSilently();
54043
54288
  return;
54044
54289
  }
54045
54290
  if (data.startsWith("vg:key:")) {
54046
54291
  const key = data.slice("vg:key:".length);
54047
- if (state3.step !== "keys") {
54292
+ if (state4.step !== "keys") {
54048
54293
  await ackSilently();
54049
54294
  return;
54050
54295
  }
54051
- const selectedSet = new Set(state3.selectedKeys ?? []);
54296
+ const selectedSet = new Set(state4.selectedKeys ?? []);
54052
54297
  if (selectedSet.has(key)) {
54053
54298
  selectedSet.delete(key);
54054
54299
  } else {
54055
54300
  selectedSet.add(key);
54056
54301
  }
54057
- const updatedState = { ...state3, selectedKeys: [...selectedSet] };
54302
+ const updatedState = { ...state4, selectedKeys: [...selectedSet] };
54058
54303
  pendingVaultOps.set(chatId, updatedState);
54059
- const kb = buildGrantKeysKeyboard(state3.availableKeys ?? [], selectedSet);
54304
+ const kb = buildGrantKeysKeyboard(state4.availableKeys ?? [], selectedSet);
54060
54305
  await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {});
54061
54306
  await ackSilently();
54062
54307
  return;
54063
54308
  }
54064
54309
  if (data === "vg:keys-continue") {
54065
- if (state3.step !== "keys") {
54310
+ if (state4.step !== "keys") {
54066
54311
  await ackSilently();
54067
54312
  return;
54068
54313
  }
54069
- if (!state3.selectedKeys || state3.selectedKeys.length === 0) {
54314
+ if (!state4.selectedKeys || state4.selectedKeys.length === 0) {
54070
54315
  await ctx.answerCallbackQuery({ text: "Select at least one key." }).catch(() => {});
54071
54316
  return;
54072
54317
  }
54073
- await grantWizardStep3(ctx, chatId, state3);
54318
+ await grantWizardStep3(ctx, chatId, state4);
54074
54319
  await ackSilently();
54075
54320
  return;
54076
54321
  }
54077
54322
  if (data.startsWith("vg:dur:")) {
54078
- if (state3.step !== "duration") {
54323
+ if (state4.step !== "duration") {
54079
54324
  await ackSilently();
54080
54325
  return;
54081
54326
  }
54082
54327
  const dur = data.slice("vg:dur:".length);
54083
54328
  if (dur === "custom") {
54084
- pendingVaultOps.set(chatId, { ...state3, awaitingCustomDuration: true });
54329
+ pendingVaultOps.set(chatId, { ...state4, awaitingCustomDuration: true });
54085
54330
  const msg = ctx.callbackQuery?.message;
54086
54331
  if (msg && "text" in msg && msg.text) {
54087
54332
  await ctx.editMessageText(escapeHtmlForTg(msg.text) + `
@@ -54103,27 +54348,27 @@ Keys: <code>${escapeHtmlForTg(grant.key_allow.join(", "))}</code>`;
54103
54348
  return;
54104
54349
  }
54105
54350
  }
54106
- const newState = { ...state3, ttlSeconds, awaitingCustomDuration: false };
54351
+ const newState = { ...state4, ttlSeconds, awaitingCustomDuration: false };
54107
54352
  await grantWizardConfirm(ctx, chatId, newState);
54108
54353
  await ackSilently();
54109
54354
  return;
54110
54355
  }
54111
54356
  if (data === "vg:back:duration") {
54112
- if (state3.step !== "duration") {
54357
+ if (state4.step !== "duration") {
54113
54358
  await ackSilently();
54114
54359
  return;
54115
54360
  }
54116
- const msgId = state3.wizardMsgId;
54117
- await grantWizardStep2(ctx, chatId, state3.agent, msgId);
54361
+ const msgId = state4.wizardMsgId;
54362
+ await grantWizardStep2(ctx, chatId, state4.agent, msgId);
54118
54363
  await ackSilently();
54119
54364
  return;
54120
54365
  }
54121
54366
  if (data === "vg:generate") {
54122
- if (state3.step !== "confirm") {
54367
+ if (state4.step !== "confirm") {
54123
54368
  await ackSilently();
54124
54369
  return;
54125
54370
  }
54126
- await executeGrantWizard(ctx, chatId, state3);
54371
+ await executeGrantWizard(ctx, chatId, state4);
54127
54372
  await ackSilently();
54128
54373
  return;
54129
54374
  }
@@ -54361,15 +54606,15 @@ async function handleAuthDashboardCallback(ctx) {
54361
54606
  } catch {}
54362
54607
  return;
54363
54608
  }
54364
- const state3 = await client3.listState();
54365
- const probeResp = state3.accounts.length > 0 ? await client3.probeQuota(state3.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
54366
- const quotas = state3.accounts.map((a) => {
54609
+ const state4 = await client3.listState();
54610
+ const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
54611
+ const quotas = state4.accounts.map((a) => {
54367
54612
  const hit = probeResp.results.find((r) => r.label === a.label);
54368
54613
  return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
54369
54614
  });
54370
54615
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
54371
54616
  const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState3, buildSnapshotKeyboard: buildSnapshotKeyboard3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
54372
- const snapshots = buildSnapshotsFromState3(state3, quotas);
54617
+ const snapshots = buildSnapshotsFromState3(state4, quotas);
54373
54618
  const text = renderAuthSnapshotFormat23(snapshots, {
54374
54619
  tz,
54375
54620
  now: new Date,
@@ -54757,16 +55002,16 @@ bot.command("usage", async (ctx) => {
54757
55002
  try {
54758
55003
  const client3 = await getAuthBrokerClient(currentAgent);
54759
55004
  if (client3) {
54760
- const state3 = await client3.listState();
54761
- if (state3.accounts.length > 0) {
54762
- const probeResp = await client3.probeQuota(state3.accounts.map((a) => a.label)).catch(() => ({ results: [] }));
54763
- const quotas = state3.accounts.map((a) => {
55005
+ const state4 = await client3.listState();
55006
+ if (state4.accounts.length > 0) {
55007
+ const probeResp = await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] }));
55008
+ const quotas = state4.accounts.map((a) => {
54764
55009
  const hit = probeResp.results.find((r) => r.label === a.label);
54765
55010
  return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
54766
55011
  });
54767
55012
  const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
54768
55013
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
54769
- const snapshots = buildSnapshotsFromState3(state3, quotas);
55014
+ const snapshots = buildSnapshotsFromState3(state4, quotas);
54770
55015
  const text = renderAuthSnapshotFormat23(snapshots, {
54771
55016
  tz,
54772
55017
  now: new Date,
@@ -55606,7 +55851,7 @@ function handleChecklistUpdate(ctx, kind) {
55606
55851
  const taskId = task.id != null ? String(task.id) : "?";
55607
55852
  const user = task.user;
55608
55853
  const userName = user?.username ?? (user?.id != null ? String(user.id) : "unknown");
55609
- const state3 = kind === "checklist_tasks_done" ? task.done === false ? "undone" : "done" : "added";
55854
+ const state4 = kind === "checklist_tasks_done" ? task.done === false ? "undone" : "done" : "added";
55610
55855
  const inboundMsg = {
55611
55856
  type: "inbound",
55612
55857
  chatId: chat_id,
@@ -55614,20 +55859,20 @@ function handleChecklistUpdate(ctx, kind) {
55614
55859
  user: userName,
55615
55860
  userId: user?.id ?? 0,
55616
55861
  ts,
55617
- text: `(checklist task ${state3}: id=${taskId})`,
55862
+ text: `(checklist task ${state4}: id=${taskId})`,
55618
55863
  meta: {
55619
55864
  chat_id,
55620
55865
  message_id,
55621
55866
  kind: "checklist_task_changed",
55622
55867
  task_id: taskId,
55623
- state: state3,
55868
+ state: state4,
55624
55869
  user: userName,
55625
55870
  user_id: user?.id != null ? String(user.id) : "0",
55626
55871
  ts: new Date(ts * 1000).toISOString()
55627
55872
  }
55628
55873
  };
55629
55874
  ipcServer.broadcast(inboundMsg);
55630
- process.stderr.write(`telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state3} user=${userName}
55875
+ process.stderr.write(`telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state4} user=${userName}
55631
55876
  `);
55632
55877
  }
55633
55878
  } catch (err) {
@@ -254,6 +254,13 @@ import { purgeStaleTurnsForChat } from './turn-state-purge.js'
254
254
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
255
255
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
256
256
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
257
+ // Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
258
+ // to record what the InboundDeliveryStateMachine PREDICTS the gateway
259
+ // should do. Behavior unchanged in this PR — the imperative code below
260
+ // still runs everything. PR 3 will cut over to executing the machine's
261
+ // effects.
262
+ import { shadowEmit } from './inbound-delivery-machine-shadow.js'
263
+ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
257
264
  import {
258
265
  buildVaultGrantApprovedInbound,
259
266
  buildVaultGrantDeniedInbound,
@@ -1265,6 +1272,13 @@ function streamKey(chatId: string, threadId?: number | null): string {
1265
1272
  }
1266
1273
 
1267
1274
  function purgeReactionTracking(key: string): void {
1275
+ // Phase 2b shadow: turn end. The key was registered via setTurnStarted
1276
+ // when the inbound arrived; purge is the canonical turn-end signal.
1277
+ // outboundEmitted is approximated `true` here — refined in PR 3 to read
1278
+ // from the per-turn `replyCalled` flag on `currentTurn`. Conservative
1279
+ // shadow approximation is safe (only affects machine's lastOutboundAt
1280
+ // tracking; can't drive incorrect behavior in shadow mode).
1281
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
1268
1282
  const msgInfo = activeReactionMsgIds.get(key)
1269
1283
  activeStatusReactions.delete(key)
1270
1284
  activeReactionMsgIds.delete(key)
@@ -3182,6 +3196,8 @@ const ipcServer: IpcServer = createIpcServer({
3182
3196
 
3183
3197
  onClientRegistered(client: IpcClient) {
3184
3198
  process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
3199
+ // Phase 2b shadow: bridge up.
3200
+ shadowEmit({ kind: 'bridgeUp', at: Date.now() })
3185
3201
  client.send({ type: 'status', status: 'agent_connected' })
3186
3202
 
3187
3203
  // #1150: drain any synthetic inbounds queued for this agent while
@@ -3307,6 +3323,8 @@ const ipcServer: IpcServer = createIpcServer({
3307
3323
 
3308
3324
  onClientDisconnected(client: IpcClient) {
3309
3325
  process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
3326
+ // Phase 2b shadow: bridge down.
3327
+ shadowEmit({ kind: 'bridgeDown', at: Date.now() })
3310
3328
 
3311
3329
  // Scope the flush to clients that actually registered as an agent.
3312
3330
  // Anonymous one-shot connections (e.g. recall.py's legacy
@@ -6637,6 +6655,23 @@ async function handleInbound(
6637
6655
  // network RTT) but not a user-perceived end-to-end measurement.
6638
6656
  const inboundReceivedAt = Date.now()
6639
6657
 
6658
+ // Phase 2b shadow: inbound arrival. Emit BEFORE the snapshot/gate
6659
+ // logic so the machine sees the event at the same point in time the
6660
+ // imperative code would. The machine internally handles fresh-turn
6661
+ // vs mid-turn — its decision will be visible in the gw-trace shadow
6662
+ // line emitted to stderr.
6663
+ const _shadowKey = statusKey(ctx.chat?.id != null ? String(ctx.chat.id) : '0', ctx.message?.message_thread_id) as _ChatKey
6664
+ shadowEmit({
6665
+ kind: 'inbound',
6666
+ key: _shadowKey,
6667
+ msg: {
6668
+ msgId: ctx.message?.message_id ?? 0,
6669
+ isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
6670
+ payload: null,
6671
+ },
6672
+ at: Date.now(),
6673
+ })
6674
+
6640
6675
  // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
6641
6676
  // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
6642
6677
  // for THIS inbound. The #1556 delivery gate further down asks "is a
@@ -0,0 +1,117 @@
1
+ /**
2
+ * InboundDeliveryStateMachine — SHADOW MODE wiring (Phase 2b PR 2).
3
+ *
4
+ * Per RFC `docs/rfcs/inbound-delivery-state-machine.md` Phase 2b PR 2:
5
+ * the state machine runs ALONGSIDE the existing imperative gateway
6
+ * code, recording predicted effects to a structured trace. Behavior
7
+ * is unchanged — every existing code path still executes the actual
8
+ * I/O. This module's job:
9
+ *
10
+ * 1. Own the module-scope machine state.
11
+ * 2. Expose `shadowEmit(event)` that runs `transition()` + logs the
12
+ * predicted effects via `gw-trace shadow ...` stderr lines.
13
+ * 3. Provide test hooks for resetting + inspecting state.
14
+ *
15
+ * After PR 2 bakes on the fleet for 24+ hours, PR 3 will wire the
16
+ * effects to drive ACTUAL behavior (the cutover), at which point the
17
+ * imperative paths get deleted in PR 4.
18
+ *
19
+ * Telemetry approach: each `shadowEmit` writes a single stderr line
20
+ * with the event kind + emitted effect kinds. Operators can then:
21
+ *
22
+ * docker exec switchroom-<agent> sh -lc 'grep "gw-trace shadow" \
23
+ * /var/log/switchroom/gateway-supervisor.log | tail -50'
24
+ *
25
+ * to see what the state machine PREDICTS the gateway should do for
26
+ * each event. Comparing against the actual log lines (`pending-inbound-
27
+ * buffer: agent=X buffered ...` etc.) is the validation that the
28
+ * machine is bit-identical with reality.
29
+ *
30
+ * Toggle off via `SWITCHROOM_DELIVERY_MACHINE_SHADOW=0` — a kill
31
+ * switch for the case where the shadow emits prove problematic
32
+ * (e.g., trace volume too high). The default is ON.
33
+ */
34
+
35
+ import {
36
+ type Effect,
37
+ type Event,
38
+ type State,
39
+ initialState,
40
+ transition,
41
+ } from './inbound-delivery-machine.js'
42
+
43
+ let state: State = initialState()
44
+ const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== '0'
45
+
46
+ /**
47
+ * Run an event through the state machine in shadow mode. The machine
48
+ * state advances, the predicted effects are LOGGED, but no I/O fires.
49
+ *
50
+ * Returns the effects for callers that want to inspect them inline
51
+ * (e.g., the eventual PR 3 cutover will replace the return-and-ignore
52
+ * pattern here with return-and-execute).
53
+ */
54
+ export function shadowEmit(event: Event): readonly Effect[] {
55
+ if (!enabled) return []
56
+ // Shadow mode MUST NEVER break the gateway. The state machine is
57
+ // pure (no I/O, no async) and property-tested over 5000 schedules,
58
+ // so transition() won't throw on well-formed input. But event
59
+ // construction at the call site could mis-shape inputs; the
60
+ // try/catch is belt-and-braces so a shadow bug never wedges a real
61
+ // turn. The catch logs and bails — the imperative gateway code
62
+ // below the emit point still runs.
63
+ try {
64
+ const result = transition(state, event)
65
+ state = result.state
66
+
67
+ // Single structured stderr line per event — grep-friendly and
68
+ // low volume (one line per gateway event, not per effect). The
69
+ // format matches gateway.ts's existing `tg-post method=...` shape
70
+ // so log aggregation can pick it up without a new parser.
71
+ const effectKinds = result.effects.map((e) => e.kind).join(',')
72
+ const eventDetail = formatEventDetail(event)
73
+ process.stderr.write(
74
+ `gw-trace shadow event=${event.kind}${eventDetail} effects=[${effectKinds}] global=${state.global.kind} perKeySize=${state.perKey.size}\n`,
75
+ )
76
+ return result.effects
77
+ } catch (err) {
78
+ process.stderr.write(
79
+ `gw-trace shadow ERROR event=${event.kind} err=${err instanceof Error ? err.message : String(err)}\n`,
80
+ )
81
+ return []
82
+ }
83
+ }
84
+
85
+ /** Compact event detail for the trace line — keeps the line one-line. */
86
+ function formatEventDetail(event: Event): string {
87
+ switch (event.kind) {
88
+ case 'inbound':
89
+ return ` key=${event.key} msg=${event.msg.msgId} steer=${event.msg.isSteering}`
90
+ case 'turnStart':
91
+ case 'turnEnd':
92
+ return ` key=${event.key}${event.kind === 'turnEnd' ? ` outbound=${event.outboundEmitted}` : ''}`
93
+ case 'permVerdict':
94
+ return ` req=${event.verdict.requestId} behavior=${event.verdict.behavior}`
95
+ case 'modelOutbound':
96
+ return ` key=${event.key}`
97
+ case 'bridgeUp':
98
+ case 'bridgeDown':
99
+ case 'tick':
100
+ return ''
101
+ }
102
+ }
103
+
104
+ /** Test hook: reset state to the initial empty machine. */
105
+ export function __shadowResetForTests(): void {
106
+ state = initialState()
107
+ }
108
+
109
+ /** Test hook: read the current shadow state. */
110
+ export function __shadowGetStateForTests(): State {
111
+ return state
112
+ }
113
+
114
+ /** Test hook: check if shadow mode is enabled (mirrors the env-var). */
115
+ export function __shadowEnabledForTests(): boolean {
116
+ return enabled
117
+ }