switchroom 0.20.5 → 0.20.7

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.
@@ -21997,7 +21997,7 @@ var init_schema = __esm(() => {
21997
21997
  reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
21998
21998
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
21999
21999
  }).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
22000
- env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22000
+ env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "MAX_OBSERVATIONS_PER_SCOPE, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT, " + "RERANKER_LOCAL_BUCKET_BATCHING, RERANKER_MAX_CANDIDATES, " + "RERANKER_LOCAL_MAX_CONCURRENT, RECALL_MAX_CONCURRENT, " + "REFLECT_WALL_TIMEOUT, WORKER_CONSOLIDATION_RESERVED_SLOTS, " + "WORKER_CONSOLIDATION_SLOT_LIMIT, " + "CONSOLIDATION_MAX_MEMORIES_PER_ROUND, GRAPH_SEED_MIN_SIMILARITY, " + "LLM_SUPPORTS_MAX_ITEMS, RECENCY_DECAY_FUNCTION, " + "RECENCY_DECAY_HALFLIFE_DAYS \u2014 switchroom defaults recall's recency " + "curve to `exponential` with a 30-day half-life so a fact retained " + "today outranks a stale one, instead of upstream's near-flat " + "linear/365-day window), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks; and " + "HINDSIGHT_CE_DECISIVE_RELATIVE_GAP \u2014 the rollback knob for " + "switchroom's CE-saturation damping patch, a float; >= ~0.65 backs the " + "damping out entirely, unset means the patch's own derived gap; and " + "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS \u2014 only read when the " + "decay function is `linear`, so switchroom ships no default for it but " + "still honours an operator who flips the function back; and " + "HINDSIGHT_API_WORKER_MAX_SLOTS \u2014 the worker poller's TOTAL in-flight " + "task budget, the pool WORKER_CONSOLIDATION_RESERVED_SLOTS reserves out of; " + "unset means upstream's own default; and " + "HINDSIGHT_API_WORKER_RETAIN_RESERVED_SLOTS \u2014 the reserved slot FLOOR for the " + "retain (memory write) lane, carved from that same total; unset means " + "upstream's own 0, i.e. no floor and retain competes for the shared " + "pool; and " + "the pre-0.8.6 name HINDSIGHT_API_WORKER_<TYPE>_MAX_SLOTS is still " + "accepted for every operation type and normalised to " + "..._RESERVED_SLOTS on the way in, because setting both names for " + "one type is a hard boot failure in the engine and switchroom " + "therefore emits only the canonical name; and " + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY \u2014 the cosine-similarity floor a " + "candidate must reach to be returned by the semantic retrieval arm at " + "all (0..1); unset means upstream's own 0.3, and where it should sit " + "depends on the bank's embedding model and phrasing diversity, so " + "switchroom ships no opinion; and " + "HINDSIGHT_MCP_RECALL_BUDGET_MODE \u2014 the rollback knob for switchroom's " + "mcp-recall-token-budget image patch; `legacy` restores upstream's " + "exact pre-patch recall returns, anything else or unset is the " + "honest-envelope mode; and " + "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT \u2014 upstream's own per-op reflect " + "temperature knob, made live by switchroom's reflect-temperature image " + "patch; a float, or `none` to omit the kwarg (provider default, " + "upstream's accidental pre-patch behaviour); unset means the image's " + "baked default 0.1; and " + "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT \u2014 the background " + "half of switchroom's recall-admission split (#3660): of the " + "RECALL_MAX_CONCURRENT admission slots, at most this many may be held " + "by background consolidation recalls at once, so foreground per-turn " + "recall always keeps the remainder; must be >= 1 and strictly less " + "than RECALL_MAX_CONCURRENT or the engine refuses to boot; unset " + "means the image's derived default min(2, RECALL_MAX_CONCURRENT - 1); " + "1 biases hard toward the interactive lane while a consolidation " + "backlog drains; and " + "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S \u2014 the rollback/tuning knob for " + "switchroom's MM-refresh-debounce image patch: the minimum seconds " + "between consolidation-triggered refreshes of one mental model; unset " + "means the image's baked 3600, 0 restores upstream's " + "refresh-every-round behaviour; explicit and cron-scheduled refreshes " + "are never debounced; and " + "HINDSIGHT_API_TEMPORAL_LANGUAGES \u2014 the language set dateparser is " + "restricted to during temporal query analysis, made live by switchroom's " + "temporal-language image patch (which ended a 200+-locale auto-detection " + "pass that blocked the shared asyncio loop on every recall); " + "comma-separated, unset means the image's baked `en`, set e.g. `en,es` to " + "restore i18n parsing; and " + "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS \u2014 the max chars of query text " + "dateparser.search_dates() is handed during temporal analysis (0 = " + "unlimited), made live by switchroom's temporal-offload image patch " + "(which moved the synchronous extraction off the shared asyncio loop onto " + "a single-worker thread and bounds its input, ending multi-second loop " + "stalls on multi-KB consolidation queries); unset means the image's baked " + "2000, set 0 to restore an unbounded full scan), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
22001
22001
  });
22002
22002
  MicrosoftWorkspaceConfigSchema = exports_external.object({
22003
22003
  microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
@@ -82242,6 +82242,9 @@ function closeFlushCompletionWindows(registry, now) {
82242
82242
  registry?.completeAll?.(now);
82243
82243
  return closed;
82244
82244
  }
82245
+ function parkedTurnStartCount() {
82246
+ return parkedTurnStarts.length;
82247
+ }
82245
82248
  function drainParkedTurnStartsForChat(deps, chatId, threadId) {
82246
82249
  const wantThread = envThreadIdNum(threadId);
82247
82250
  const drained = [];
@@ -87376,7 +87379,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
87376
87379
  "HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
87377
87380
  "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
87378
87381
  "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S",
87379
- "HINDSIGHT_API_TEMPORAL_LANGUAGES"
87382
+ "HINDSIGHT_API_TEMPORAL_LANGUAGES",
87383
+ "HINDSIGHT_API_TEMPORAL_MAX_QUERY_CHARS"
87380
87384
  ]);
87381
87385
  var HINDSIGHT_WORKER_SLOT_TYPES = [
87382
87386
  "consolidation",
@@ -89491,6 +89495,7 @@ async function runBootPinSweepSteps(deps) {
89491
89495
  await step("status-pin-cleanup", deps.statusPinCleanup, log);
89492
89496
  await step("activity-card-reaper", deps.activityCardReaper, log);
89493
89497
  await step("queued-card-reaper", deps.queuedCardReaper, log);
89498
+ await step("seed-prune-sweep-cursors", async () => deps.seedPruneSweepCursors(), log);
89494
89499
  await step("enable-sweep", async () => deps.enableSweep(), log);
89495
89500
  for (const t of targets) {
89496
89501
  await step(`stale-pin-sweep:${sweepTargetKey(t.chatId, t.threadId)}`, () => deps.sweepTarget(t), log);
@@ -90303,6 +90308,56 @@ function createGatewayStalePinSweeper(w) {
90303
90308
  });
90304
90309
  }
90305
90310
 
90311
+ // gateway/stale-pin-sweep-store.ts
90312
+ var SWEEP_ENVELOPE_VERSION2 = 1;
90313
+ function isCursorRow2(x) {
90314
+ if (x == null || typeof x !== "object")
90315
+ return false;
90316
+ const o = x;
90317
+ return typeof o.chatId === "string" && o.chatId.length > 0 && (o.threadId === undefined || typeof o.threadId === "number") && (o.kind === "dm" || o.kind === "forum-topic" || o.kind === "supergroup") && typeof o.popped === "number" && typeof o.done === "boolean" && typeof o.attempts === "number" && (o.doneIds === undefined || Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === "number")) && (o.lastStatus === undefined || typeof o.lastStatus === "string") && typeof o.updatedAt === "number";
90318
+ }
90319
+ function loadSweepCursors2(path2, fs2) {
90320
+ if (!fs2.existsSync(path2))
90321
+ return [];
90322
+ let raw = "";
90323
+ try {
90324
+ raw = fs2.readFileSync(path2);
90325
+ } catch {
90326
+ return [];
90327
+ }
90328
+ let parsed;
90329
+ try {
90330
+ parsed = JSON.parse(raw);
90331
+ } catch {
90332
+ return [];
90333
+ }
90334
+ if (parsed == null || typeof parsed !== "object")
90335
+ return [];
90336
+ const env = parsed;
90337
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.cursors)) {
90338
+ return [];
90339
+ }
90340
+ return env.cursors.filter(isCursorRow2);
90341
+ }
90342
+ function persistSweepCursors2(path2, fs2, cursors, log = (l) => process.stderr.write(l)) {
90343
+ const env = { v: SWEEP_ENVELOPE_VERSION2, cursors: [...cursors] };
90344
+ try {
90345
+ fs2.writeFileSync(path2, JSON.stringify(env));
90346
+ } catch (err) {
90347
+ log(`stale-pin-sweep-store: persist FAILED path=${path2}: ${err.message} \u2014 ` + `durability degraded; a restart mid-sweep will re-seed instead of resume
90348
+ `);
90349
+ }
90350
+ }
90351
+ function pruneSweepCursors(cursors) {
90352
+ return cursors.filter((c) => !c.done);
90353
+ }
90354
+ function reseedSweepLedger(path2, fs2, log = (l) => process.stderr.write(l)) {
90355
+ const rows = loadSweepCursors2(path2, fs2);
90356
+ const pruned = pruneSweepCursors(rows);
90357
+ if (pruned.length !== rows.length)
90358
+ persistSweepCursors2(path2, fs2, pruned, log);
90359
+ }
90360
+
90306
90361
  // gateway/webhook-ingest-server.ts
90307
90362
  import net4 from "node:net";
90308
90363
  import { chmodSync as chmodSync10, existsSync as existsSync39, unlinkSync as unlinkSync18 } from "node:fs";
@@ -92729,7 +92784,24 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
92729
92784
  const pending = buffer.drain(agent);
92730
92785
  let redelivered = 0;
92731
92786
  let rebuffered = 0;
92787
+ let retracted = 0;
92732
92788
  for (const { merged, originals } of planBufferedRedelivery(pending)) {
92789
+ let proceed = true;
92790
+ if (buffer.beforeRedeliver != null) {
92791
+ try {
92792
+ proceed = buffer.beforeRedeliver(merged);
92793
+ } catch (e) {
92794
+ proceed = true;
92795
+ process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
92796
+ `);
92797
+ }
92798
+ }
92799
+ if (!proceed) {
92800
+ for (const o of originals)
92801
+ spool?.ack(o);
92802
+ retracted += originals.length;
92803
+ continue;
92804
+ }
92733
92805
  let delivered = false;
92734
92806
  try {
92735
92807
  delivered = send(merged);
@@ -92747,7 +92819,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
92747
92819
  rebuffered += originals.length;
92748
92820
  }
92749
92821
  }
92750
- return { drained: pending.length, redelivered, rebuffered };
92822
+ return { drained: pending.length, redelivered, rebuffered, retracted };
92751
92823
  }
92752
92824
  function isMergeableUserInbound(msg) {
92753
92825
  return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
@@ -92864,7 +92936,62 @@ function createPendingInboundBuffer(opts = {}) {
92864
92936
  for (const q of queues.values())
92865
92937
  n += q.length;
92866
92938
  return n;
92939
+ },
92940
+ beforeRedeliver: opts.beforeRedeliver
92941
+ };
92942
+ }
92943
+
92944
+ // gateway/represent-guard.ts
92945
+ function shouldSuppressRepresent(o, deps) {
92946
+ if (!deps.historyEnabled)
92947
+ return false;
92948
+ if (o.lastRepresentedAt == null) {
92949
+ if (o.openedAt == null)
92950
+ return false;
92951
+ return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
92952
+ }
92953
+ return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
92954
+ }
92955
+
92956
+ // gateway/represent-delivery-guard.ts
92957
+ function makeRepresentRedeliveryGuard(deps) {
92958
+ return (msg) => {
92959
+ if (!deps.enabled)
92960
+ return true;
92961
+ if (msg.meta?.source !== "obligation_represent")
92962
+ return true;
92963
+ const originTurnId = msg.meta?.origin_turn_id;
92964
+ if (originTurnId == null)
92965
+ return true;
92966
+ const o = deps.ledger.get(originTurnId);
92967
+ if (o == null) {
92968
+ deps.log(`telegram gateway: represent retracted at delivery \u2014 obligation already ` + `closed since decision (no re-fire) origin=${originTurnId}
92969
+ `);
92970
+ return false;
92971
+ }
92972
+ const suppress = shouldSuppressRepresent(o, {
92973
+ historyEnabled: deps.historyEnabled,
92974
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) => deps.hasOutboundDeliveredSince(chatId, sinceMs, threadId, deps.minReplyChars)
92975
+ });
92976
+ if (suppress) {
92977
+ deps.ledger.close(originTurnId);
92978
+ deps.log(`telegram gateway: represent retracted at delivery \u2014 reply landed since ` + `decision (no re-fire) origin=${originTurnId}
92979
+ `);
92980
+ return false;
92981
+ }
92982
+ return true;
92983
+ };
92984
+ }
92985
+ function makeSessionBusyDrainDeferral(boundMs) {
92986
+ let deferringSince = null;
92987
+ return (busy, now) => {
92988
+ if (!busy || boundMs <= 0) {
92989
+ deferringSince = null;
92990
+ return false;
92867
92991
  }
92992
+ if (deferringSince == null)
92993
+ deferringSince = now;
92994
+ return now - deferringSince < boundMs;
92868
92995
  };
92869
92996
  }
92870
92997
 
@@ -92936,6 +93063,9 @@ class ObligationLedger {
92936
93063
  isOpen(originTurnId) {
92937
93064
  return this.open.has(originTurnId);
92938
93065
  }
93066
+ get(originTurnId) {
93067
+ return this.open.get(originTurnId);
93068
+ }
92939
93069
  hasOpen() {
92940
93070
  return this.open.size > 0;
92941
93071
  }
@@ -94461,7 +94591,24 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
94461
94591
  const pending = buffer.drain(agent);
94462
94592
  let redelivered = 0;
94463
94593
  let rebuffered = 0;
94594
+ let retracted = 0;
94464
94595
  for (const { merged, originals } of planBufferedRedelivery2(pending)) {
94596
+ let proceed = true;
94597
+ if (buffer.beforeRedeliver != null) {
94598
+ try {
94599
+ proceed = buffer.beforeRedeliver(merged);
94600
+ } catch (e) {
94601
+ proceed = true;
94602
+ process.stderr.write(`redeliver beforeRedeliver threw \u2014 failing open (deliver): ${String(e)}
94603
+ `);
94604
+ }
94605
+ }
94606
+ if (!proceed) {
94607
+ for (const o of originals)
94608
+ spool?.ack(o);
94609
+ retracted += originals.length;
94610
+ continue;
94611
+ }
94465
94612
  let delivered = false;
94466
94613
  try {
94467
94614
  delivered = send(merged);
@@ -94479,7 +94626,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
94479
94626
  rebuffered += originals.length;
94480
94627
  }
94481
94628
  }
94482
- return { drained: pending.length, redelivered, rebuffered };
94629
+ return { drained: pending.length, redelivered, rebuffered, retracted };
94483
94630
  }
94484
94631
  function isMergeableUserInbound2(msg) {
94485
94632
  return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null && msg.meta.button_callback == null);
@@ -95374,6 +95521,9 @@ class ObligationLedger2 {
95374
95521
  isOpen(originTurnId) {
95375
95522
  return this.open.has(originTurnId);
95376
95523
  }
95524
+ get(originTurnId) {
95525
+ return this.open.get(originTurnId);
95526
+ }
95377
95527
  hasOpen() {
95378
95528
  return this.open.size > 0;
95379
95529
  }
@@ -95536,23 +95686,24 @@ function driveEscalation(args) {
95536
95686
  });
95537
95687
  }
95538
95688
 
95539
- // gateway/represent-guard.ts
95540
- function shouldSuppressRepresent(o, deps) {
95541
- if (!deps.historyEnabled)
95542
- return false;
95543
- if (o.lastRepresentedAt == null) {
95544
- if (o.openedAt == null)
95545
- return false;
95546
- return deps.hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId);
95547
- }
95548
- return deps.hasOutboundDeliveredSince(o.chatId, o.lastRepresentedAt, o.threadId);
95549
- }
95550
-
95551
95689
  // gateway/escalation-bridge-gate.ts
95552
95690
  function shouldDeferEscalationForBridge(deps) {
95553
95691
  return !deps.bridgeAlive;
95554
95692
  }
95555
95693
 
95694
+ // gateway/stream-render.ts
95695
+ init_history();
95696
+ init_rich_send();
95697
+ var PARKED_TURN_START_TTL_MS2 = 30 * 60000;
95698
+ var QUEUED_CARD_ENABLED2 = process.env.SWITCHROOM_QUEUED_CARD !== "0";
95699
+ var BUZZ_ENABLED2 = process.env.BUZZ_ENABLED === "1" || process.env.BUZZ_ENABLED === "true";
95700
+ var BUZZ_ORIGIN_STAMP_ACTIVE2 = BUZZ_ENABLED2 && isBuzzTurnRoutingEnabled();
95701
+ var parkedTurnStarts2 = [];
95702
+ var flushCompletionTracker2 = new FlushCompletionTracker;
95703
+ function parkedTurnStartCount2() {
95704
+ return parkedTurnStarts2.length;
95705
+ }
95706
+
95556
95707
  // gateway/obligation-wiring.ts
95557
95708
  function createObligationWiring(deps) {
95558
95709
  const {
@@ -95631,7 +95782,9 @@ function createObligationWiring(deps) {
95631
95782
  return;
95632
95783
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
95633
95784
  const now = Date.now();
95634
- const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
95785
+ const liveTurn = getCurrentTurn();
95786
+ const sessionBusy = liveTurn != null && liveTurn.endedAt == null || parkedTurnStartCount2() > 0;
95787
+ const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && (agentHasInFlightBackgroundWork(now) || sessionBusy);
95635
95788
  const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
95636
95789
  now,
95637
95790
  graceMs: OBLIGATION_ESCALATE_GRACE_MS,
@@ -95643,7 +95796,7 @@ function createObligationWiring(deps) {
95643
95796
  if (decision.action === "none" || o == null) {
95644
95797
  if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
95645
95798
  lastBgWorkDeferLogMs = now;
95646
- process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
95799
+ process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `or busy CLI session behind a poke-cleared turn ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
95647
95800
  `);
95648
95801
  }
95649
95802
  return;
@@ -102428,10 +102581,10 @@ function startOutboxSweep(deps) {
102428
102581
  }
102429
102582
 
102430
102583
  // ../src/build-info.ts
102431
- var VERSION2 = "0.20.5";
102432
- var COMMIT_SHA = "fc40cc8e";
102433
- var COMMIT_DATE = "2026-08-04T04:03:52Z";
102434
- var LATEST_PR = 4338;
102584
+ var VERSION2 = "0.20.7";
102585
+ var COMMIT_SHA = "bc107185";
102586
+ var COMMIT_DATE = "2026-08-04T10:20:28Z";
102587
+ var LATEST_PR = 4343;
102435
102588
  var COMMITS_AHEAD_OF_TAG = 0;
102436
102589
 
102437
102590
  // gateway/boot-version.ts
@@ -108140,19 +108293,17 @@ async function unpinAllStatusPins() {
108140
108293
  }
108141
108294
  var stalePinSweepEligible = false;
108142
108295
  var STALE_PIN_SWEEP_STORE_PATH = join71(STATE_DIR, "stale-pin-sweep.json");
108296
+ var sweepStoreFs = {
108297
+ readFileSync: (p) => readFileSync64(p, "utf-8"),
108298
+ writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108299
+ existsSync: (p) => existsSync61(p)
108300
+ };
108143
108301
  var stalePinSweeper = createGatewayStalePinSweeper({
108144
108302
  telegram: { handle: () => lockedBot, call: robustApiCall },
108145
108303
  claims: () => statusPinClaims.values(),
108146
108304
  loadPinRows: () => statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled ? loadStatusPins2(STATUS_PIN_STORE_PATH, statusPinStoreFs) : [],
108147
108305
  eligible: () => stalePinSweepEligible,
108148
- store: {
108149
- path: STALE_PIN_SWEEP_STORE_PATH,
108150
- fs: {
108151
- readFileSync: (p) => readFileSync64(p, "utf-8"),
108152
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108153
- existsSync: (p) => existsSync61(p)
108154
- }
108155
- },
108306
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
108156
108307
  allowUnpinAllForumTopic: process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC == null ? undefined : process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC === "1"
108157
108308
  });
108158
108309
  function runBootPinCleanupAndStalePinSweep() {
@@ -108168,6 +108319,7 @@ function runBootPinCleanupAndStalePinSweep() {
108168
108319
  enableSweep: () => {
108169
108320
  stalePinSweepEligible = true;
108170
108321
  },
108322
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
108171
108323
  sweepTarget: (t) => stalePinSweeper.sweepTarget(t),
108172
108324
  log: (line) => process.stderr.write(line)
108173
108325
  });
@@ -108451,7 +108603,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
108451
108603
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
108452
108604
  swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
108453
108605
  },
108454
- onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
108606
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
108607
+ beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) })
108455
108608
  });
108456
108609
  function agentHasInFlightBackgroundWork(now) {
108457
108610
  if (countRunningWorkers() > 0)
@@ -109482,12 +109635,16 @@ if (isGatewayMain)
109482
109635
  }
109483
109636
  })();
109484
109637
  var IDLE_DRAIN_INTERVAL_MS = 5000;
109638
+ var idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS);
109485
109639
  if (isGatewayMain && !STATIC) {
109486
109640
  setInterval(() => {
109487
109641
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
109488
109642
  const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
109489
109643
  if (turnInFlightForGate())
109490
109644
  return false;
109645
+ const sessionBusy = currentTurn != null && currentTurn.endedAt == null || parkedTurnStartCount() > 0;
109646
+ if (idleDrainBusyDefer(sessionBusy, Date.now()))
109647
+ return false;
109491
109648
  const c = ipcServer.getClient(selfAgent);
109492
109649
  return c != null && c.isAlive();
109493
109650
  }, (m) => ipcServer.sendToAgent(selfAgent, m), inboundSpool, trackRedeliveredInbound);
@@ -103,6 +103,21 @@ export interface BootPinSweepSteps {
103
103
  /** Flips the flag authorising the stale-pin drain — for this sweep AND for
104
104
  * later lazy first-inbound sweeps. */
105
105
  enableSweep: () => void
106
+ /**
107
+ * BOOT-SEED PRUNE (#3953 regression fix). Drops discharged (`done: true`)
108
+ * cursor rows from the sweep ledger so a `(chat, thread)` that drained in a
109
+ * PRIOR session is re-evaluated live this boot, instead of short-circuiting
110
+ * forever on a stale `done`. Kind-agnostic — clears DM, forum-topic and
111
+ * supergroup rows alike. Forfeited rows (`attempts >= SWEEP_MAX_ATTEMPTS`)
112
+ * are RETAINED by the underlying `pruneSweepCursors`, so a chat the bot
113
+ * cannot pin does not re-burn its attempt budget every boot.
114
+ *
115
+ * MUST stay BOOT-scoped. The lazy first-inbound sweep fires on EVERY message;
116
+ * pruning there would re-arm a full re-drain per message = a Telegram flood.
117
+ * Runs once here, before the `sweepTarget` loop, so the loop re-evaluates the
118
+ * freshly-reseeded ledger.
119
+ */
120
+ seedPruneSweepCursors: () => void
106
121
  sweepTarget: (target: SweepTarget) => Promise<unknown>
107
122
  log?: (line: string) => void
108
123
  }
@@ -161,6 +176,15 @@ export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<voi
161
176
  await step('activity-card-reaper', deps.activityCardReaper, log)
162
177
  await step('queued-card-reaper', deps.queuedCardReaper, log)
163
178
 
179
+ // Boot-seed prune (#3953): reseed the cursor ledger so a chat that drained in
180
+ // a prior session is re-evaluated live below, rather than short-circuiting on
181
+ // a stale `done`. Boot-scoped ONLY — never on the per-inbound path. Runs
182
+ // BEFORE enableSweep so eligibility (which also gates the lazy first-inbound
183
+ // sweep) is granted only once the ledger is reseeded — no window where an
184
+ // inbound observes a stale `done`. Isolated: a throw here must not strand the
185
+ // drain, and enableSweep below still runs regardless.
186
+ await step('seed-prune-sweep-cursors', async () => deps.seedPruneSweepCursors(), log)
187
+
164
188
  // Unconditional: reached even when every step above threw. See the docblock.
165
189
  await step('enable-sweep', async () => deps.enableSweep(), log)
166
190
  for (const t of targets) {
@@ -461,7 +461,7 @@ import {
461
461
  type SendReplyGatewayDeps,
462
462
  type DeliverCapturedProseDeps,
463
463
  } from './outbound-send-path.js'
464
- import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows } from './stream-render.js'
464
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat, closeFlushCompletionWindows, parkedTurnStartCount } from './stream-render.js'
465
465
  import { createNarrativeLane } from './narrative-lane.js'
466
466
  import {
467
467
  parseAgentCallback,
@@ -648,6 +648,7 @@ import { withDeadline } from './with-deadline.js'
648
648
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
649
649
  import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
650
650
  import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
651
+ import { reseedSweepLedger } from './stale-pin-sweep-store.js'
651
652
  import { atomicWriteFileSync } from '../../src/util/atomic.js'
652
653
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
653
654
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
@@ -658,6 +659,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
658
659
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
659
660
  import { buildDiffPreviewCard } from './diff-preview-card.js'
660
661
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
662
+ import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
661
663
  import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
662
664
  import {
663
665
  ObligationLedger,
@@ -9041,6 +9043,11 @@ let stalePinSweepEligible = false
9041
9043
  // the previous complete ledger or the new one — never a truncated file that
9042
9044
  // forgets an in-flight drain.
9043
9045
  const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
9046
+ const sweepStoreFs = {
9047
+ readFileSync: (p: string) => readFileSync(p, 'utf-8'),
9048
+ writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
9049
+ existsSync: (p: string) => existsSync(p),
9050
+ }
9044
9051
  const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9045
9052
  telegram: { handle: () => lockedBot, call: robustApiCall },
9046
9053
  claims: () => statusPinClaims.values(),
@@ -9049,14 +9056,7 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9049
9056
  ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9050
9057
  : [],
9051
9058
  eligible: () => stalePinSweepEligible,
9052
- store: {
9053
- path: STALE_PIN_SWEEP_STORE_PATH,
9054
- fs: {
9055
- readFileSync: (p) => readFileSync(p, 'utf-8'),
9056
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 0o600),
9057
- existsSync: (p) => existsSync(p),
9058
- },
9059
- },
9059
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
9060
9060
  // Per-deployment override only. UNSET (the normal case) means "take the
9061
9061
  // standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
9062
9062
  // i.e. the WHOLESALE topic drain stays off because it also removes pins this
@@ -9099,6 +9099,7 @@ function runBootPinCleanupAndStalePinSweep(): Promise<void> {
9099
9099
  enableSweep: () => {
9100
9100
  stalePinSweepEligible = true
9101
9101
  },
9102
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
9102
9103
  sweepTarget: (t: SweepTarget) => stalePinSweeper.sweepTarget(t),
9103
9104
  log: (line) => process.stderr.write(line),
9104
9105
  })
@@ -9788,6 +9789,11 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9788
9789
  // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9789
9790
  // push) populates it. Every `subagent_handback` push funnels through here.
9790
9791
  onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9792
+ // F1 (fix/represent-double-send-delivery-recheck) — delivery-time represent
9793
+ // retract. Every drain path hands its buffered inbounds through this gate; a
9794
+ // stale `obligation_represent` (its reply landed since the sweep decided it) is
9795
+ // dropped here rather than duplicated into the CLI queue. See represent-delivery-guard.ts.
9796
+ beforeRedeliver: makeRepresentRedeliveryGuard({ enabled: OBLIGATION_LEDGER_ENABLED, historyEnabled: HISTORY_ENABLED, ledger: obligationLedger, hasOutboundDeliveredSince, minReplyChars: OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS, log: (l) => process.stderr.write(l) }),
9791
9797
  })
9792
9798
 
9793
9799
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -11742,30 +11748,19 @@ if (isGatewayMain) (() => { // #2996 P0c: gated — starts webhook-ingest UDS s
11742
11748
  })()
11743
11749
 
11744
11750
  // ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
11745
- // pendingInboundBuffer otherwise drains only on (a) bridge re-register
11746
- // (onClientRegistered) or (b) the silence-poke framework fallback
11747
- // clearing a wedged turn (#1546). NEITHER fires when a message is
11748
- // buffered during a bridge-IPC flap that then settles with no
11749
- // subsequent clean re-register AND claude is idle (no active turn
11750
- // silence-poke never arms). The message orphans until a manual restart
11751
- // (finn, 2026-05-19 buffered "verify with mff-query.py cashflow"
11752
- // while idle; last `bridge registered` predated the buffer push, so
11753
- // onClientRegistered's drain never ran for it).
11754
- //
11755
- // This is the third drain trigger. It's gated to be zero-cost and
11756
- // zero-churn: skip entirely when nothing is buffered (one Map.get, no
11757
- // log), when the bridge isn't alive (exactly sendToAgent's own guard —
11758
- // so we never drain into a dead bridge and re-buffer/log-spin), OR
11759
- // when a turn is in flight. The turn gate is #1556: a message
11760
- // delivered while a turn is active is NOT safely queued by the bridge
11761
- // — claude types it into its TUI composer and the auto-submit races
11762
- // turn-completion, stranding it (the lawgpt wedge). Draining only at
11763
- // `activeTurnStartedAt.size === 0` guarantees the channel notification
11764
- // lands at an idle prompt and submits as a fresh turn. Only when there
11765
- // IS a buffered message AND a live bridge AND no active turn do we
11766
- // reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
11767
- // per-message miss).
11751
+ // The THIRD drain trigger, beside bridge re-register (onClientRegistered) and
11752
+ // the silence-poke framework fallback (#1546): neither fires when a message is
11753
+ // buffered during a bridge-IPC flap that settles with no clean re-register while
11754
+ // claude is idle, so it orphans until a manual restart (finn, 2026-05-19). Gated
11755
+ // zero-cost/zero-churn: skip when nothing is buffered, when the bridge is dead,
11756
+ // or when a turn is in flight (#1556 — a message delivered mid-turn is typed into
11757
+ // claude's TUI composer and the auto-submit races turn-completion, stranding it).
11768
11758
  const IDLE_DRAIN_INTERVAL_MS = 5000
11759
+ // F2 (drain half) — bounded busy-defer. A ~5-min poke can clear the machine turn
11760
+ // while the CLI is still producing the answer; draining a represent into that busy
11761
+ // session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
11762
+ // bounded so a wedged session still drains (F1 then retracts it post-reply).
11763
+ const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS)
11769
11764
  if (isGatewayMain && !STATIC) {
11770
11765
  setInterval(() => {
11771
11766
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
@@ -11775,8 +11770,11 @@ if (isGatewayMain && !STATIC) {
11775
11770
  () => {
11776
11771
  // #1556: never drain mid-turn — that re-creates the composer
11777
11772
  // wedge this buffer exists to prevent. Gate reads the delivery
11778
- // machine (turnInFlightForGate).
11773
+ // machine (turnInFlightForGate) AND, per F2, stream-render's live-turn
11774
+ // mirror (a poke can clear the former while the latter is still busy).
11779
11775
  if (turnInFlightForGate()) return false
11776
+ const sessionBusy = (currentTurn != null && currentTurn.endedAt == null) || parkedTurnStartCount() > 0
11777
+ if (idleDrainBusyDefer(sessionBusy, Date.now())) return false
11780
11778
  const c = ipcServer.getClient(selfAgent)
11781
11779
  return c != null && c.isAlive()
11782
11780
  },
@@ -178,6 +178,15 @@ export class ObligationLedger {
178
178
  return this.open.has(originTurnId)
179
179
  }
180
180
 
181
+ /** The open obligation for `originTurnId`, or undefined if none is open. Read-
182
+ * only accessor for the delivery-time represent re-check (represent-delivery-
183
+ * guard.ts): a represent buffered while the obligation was open must be re-
184
+ * evaluated against the obligation's CURRENT cutoff at the moment it is handed
185
+ * to the CLI bridge, since a reply may have landed between decision and drain. */
186
+ get(originTurnId: string): Obligation | undefined {
187
+ return this.open.get(originTurnId)
188
+ }
189
+
181
190
  hasOpen(): boolean {
182
191
  return this.open.size > 0
183
192
  }
@@ -23,6 +23,7 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
23
23
  import { driveEscalation } from './escalation-drive.js'
24
24
  import { shouldSuppressRepresent } from './represent-guard.js'
25
25
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+ import { parkedTurnStartCount } from './stream-render.js'
26
27
 
27
28
  export function createObligationWiring(deps: ObligationWiringDeps) {
28
29
  const {
@@ -161,13 +162,26 @@ function obligationSweep(): void {
161
162
  if (turnInFlightForGate()) return // a turn is running — let it finish/answer
162
163
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
163
164
  const now = Date.now()
165
+ // Session-busy signal (F2, decision half). A ~5-min silence poke can clear the
166
+ // machine turn (`turnInFlightForGate()` above → false) while the CLI session is
167
+ // STILL producing the answer — stream-render's live-turn mirror (`currentTurn`
168
+ // whose endedAt is null, or a parked turn-start) still knows it is busy. Without
169
+ // this, the sweep decides + buffers a represent mid-answer; the real reply lands
170
+ // seconds later and the model consumes the queued represent → a duplicate answer
171
+ // (the two-authorities-disagree defect). We treat a busy mirror the SAME as
172
+ // in-flight background work: defer the represent, BOUNDED by the same grace, so a
173
+ // wedged/hung session cannot silence a genuinely-unanswered turn forever.
174
+ const liveTurn = getCurrentTurn()
175
+ const sessionBusy = (liveTurn != null && liveTurn.endedAt == null) || parkedTurnStartCount() > 0
164
176
  // Background-work grace: while genuine autonomous sub-agent work is in flight
165
177
  // (a running worker, or an orphaned foreground sub-agent — neither visible to
166
- // the turn machine), an obligation younger than the ceiling is NOT re-presented
167
- // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
168
- // always eventually fires. =0 disables it.
178
+ // the turn machine), OR the CLI session is still busy behind a poke-cleared
179
+ // machine turn, an obligation younger than the ceiling is NOT re-presented
180
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS (measured from
181
+ // openedAt in decideAtIdle) so escalation always eventually fires. =0 disables it.
169
182
  const backgroundWorkActive =
170
- OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
183
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 &&
184
+ (agentHasInFlightBackgroundWork(now) || sessionBusy)
171
185
  // Grace window: skip an obligation whose handling turn ended < grace ago — its
172
186
  // trailing slow/worker answer may still be landing (over-escalation fix).
173
187
  // Per-represent grace: skip an obligation re-presented < grace ago — prevents
@@ -189,6 +203,7 @@ function obligationSweep(): void {
189
203
  lastBgWorkDeferLogMs = now
190
204
  process.stderr.write(
191
205
  `telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
206
+ `or busy CLI session behind a poke-cleared turn ` +
192
207
  `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
193
208
  )
194
209
  }