switchroom 0.19.46 → 0.19.47

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.
@@ -21547,7 +21547,7 @@ var init_observation_scopes = __esm(() => {
21547
21547
  });
21548
21548
 
21549
21549
  // ../src/config/schema.ts
21550
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
21550
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
21551
21551
  var init_schema = __esm(() => {
21552
21552
  init_zod();
21553
21553
  init_observation_scopes();
@@ -21934,11 +21934,18 @@ var init_schema = __esm(() => {
21934
21934
  "extended",
21935
21935
  "complete"
21936
21936
  ]);
21937
+ GoogleServiceTokenSchema = exports_external.enum([
21938
+ "cal",
21939
+ "drive",
21940
+ "docs",
21941
+ "sheets",
21942
+ "slides"
21943
+ ]);
21937
21944
  GoogleWorkspaceConfigSchema = exports_external.object({
21938
21945
  google_client_id: exports_external.string().min(1).describe("Google OAuth client ID (literal string or vault reference e.g. 'vault:google-oauth-client-id')"),
21939
21946
  google_client_secret: exports_external.string().min(1).describe("Google OAuth client secret (literal string or vault reference e.g. 'vault:google-oauth-client-secret')"),
21940
21947
  approvers: exports_external.array(ApproverIdSchema).min(1).describe("Array of numeric Telegram user IDs authorized to approve drive onboarding. " + "At least one must be specified."),
21941
- tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
21948
+ tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar tools; the " + "Calendar tools only authenticate when the account holds the opt-in " + "calendar scope \u2014 no tier mints it). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
21942
21949
  }).optional();
21943
21950
  LiteLLMConfigSchema = exports_external.object({
21944
21951
  enabled: exports_external.boolean().optional().describe("Opt-in toggle. When true, `switchroom apply` provisions a per-agent " + "LiteLLM virtual key and injects routing env into the container. " + "Default OFF."),
@@ -21971,7 +21978,7 @@ var init_schema = __esm(() => {
21971
21978
  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."),
21972
21979
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
21973
21980
  }).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."),
21974
- 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), 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).")
21981
+ 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), 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).")
21975
21982
  });
21976
21983
  MicrosoftWorkspaceConfigSchema = exports_external.object({
21977
21984
  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)."),
@@ -22445,7 +22452,9 @@ var init_schema = __esm(() => {
22445
22452
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
22446
22453
  enabled_for: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
22447
22454
  message: "Agent name must match the standard agent-name pattern"
22448
- })).describe("Agent slugs that may read this account's vault slots " + "(`google:<account>:refresh_token` etc). Per-agent ACL is " + "enforced at the broker, not at the agent identity layer \u2014 " + "the agent still authenticates via socket-path-as-identity " + "per RFC D \u00a74.1, broker just gates the cross-agent token share.")
22455
+ })).describe("Agent slugs that may read this account's vault slots " + "(`google:<account>:refresh_token` etc). Per-agent ACL is " + "enforced at the broker, not at the agent identity layer \u2014 " + "the agent still authenticates via socket-path-as-identity " + "per RFC D \u00a74.1, broker just gates the cross-agent token share."),
22456
+ readonly: exports_external.boolean().optional().describe("Per-account read-only selection (v1 scope model). true \u2192 the " + "token minted by `auth google account add` carries ONLY " + ".readonly scope variants (zero write scopes), and the gdrive " + "MCP launcher passes upstream `--read-only` so write tools are " + "not exposed. Written by `account add --readonly`; re-read on " + "`--replace` so a re-consent cannot silently re-widen. Omitted " + "= legacy behaviour (tier-tied read-write document scopes)."),
22457
+ services: exports_external.array(GoogleServiceTokenSchema).min(1).optional().describe("Per-account service selection (v1 scope model). Which Google " + "services the minted token covers AND the gdrive MCP exposes " + "(upstream `--tools`): cal, drive, docs, sheets, slides. " + "Written by `account add --services`; re-read on `--replace`. " + "Omitted = the tier's default services (drive,docs,sheets; " + "+slides at extended/complete).")
22449
22458
  })).optional().describe("RFC G Phase 2: per-Google-account ACL for vault slots holding " + "OAuth refresh tokens. Maps account email \u2192 list of agents " + "permitted to read that account's slots. Written by `switchroom " + "auth google enable|disable` (Phase 3); read by the broker on " + "every Google slot access. Replaces RFC D's per-agent vault slot " + "scope (which can't express 'two agents share one Google account')."),
22450
22459
  microsoft_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
22451
22460
  message: "Account key must be a Microsoft account email like 'alice@outlook.com' or 'alice@contoso.com' (colons not allowed)"
@@ -54129,8 +54138,9 @@ function createHandbackPreturnSignal(deps) {
54129
54138
  entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
54130
54139
  return;
54131
54140
  }
54141
+ const consumedElsewhere = deps.sessionActivitySince?.(entry.deliveredAt) === true;
54132
54142
  entry.consumed = true;
54133
- stopTypingUnlessTurnLive(entry, "orphan reap");
54143
+ stopTypingUnlessTurnLive(entry, consumedElsewhere ? "consumed reap" : "orphan reap");
54134
54144
  if (entry.activityMessageId != null) {
54135
54145
  const record = {
54136
54146
  turnKey: entry.syntheticTurnKey,
@@ -54147,6 +54157,11 @@ function createHandbackPreturnSignal(deps) {
54147
54157
  });
54148
54158
  }
54149
54159
  dropEntry(entry);
54160
+ if (consumedElsewhere) {
54161
+ log(`handback-preturn-signal: reap resolved key=${entry.statusKey} ` + `turnId=${entry.adoptTurnId} \u2014 session consumed input after delivery ` + `(no adopting turn observed); cleaned up without re-injection
54162
+ `);
54163
+ return;
54164
+ }
54150
54165
  if (entry.reinjectCount < maxReinjects) {
54151
54166
  const nextCount = entry.reinjectCount + 1;
54152
54167
  entry.inbound.meta[HANDBACK_REINJECT_COUNT_META_KEY] = String(nextCount);
@@ -54296,6 +54311,28 @@ function createHandbackPreturnSignal(deps) {
54296
54311
  };
54297
54312
  }
54298
54313
 
54314
+ // gateway/session-consume-signal.ts
54315
+ class SessionConsumeSignal {
54316
+ lastTurnMintAt = 0;
54317
+ lastMainToolCallAt = 0;
54318
+ noteTurnMint(now = Date.now()) {
54319
+ if (now > this.lastTurnMintAt)
54320
+ this.lastTurnMintAt = now;
54321
+ }
54322
+ noteMainToolCall(now = Date.now()) {
54323
+ if (now > this.lastMainToolCallAt)
54324
+ this.lastMainToolCallAt = now;
54325
+ }
54326
+ activitySince(sinceTs) {
54327
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs;
54328
+ }
54329
+ reset() {
54330
+ this.lastTurnMintAt = 0;
54331
+ this.lastMainToolCallAt = 0;
54332
+ }
54333
+ }
54334
+ var sessionConsumeSignal = new SessionConsumeSignal;
54335
+
54299
54336
  // gateway/handback-orphan-recovery.ts
54300
54337
  function formatOrphanEscalation(esc) {
54301
54338
  return `telegram gateway: handback orphan escalation key=${esc.statusKey} ` + `turnId=${esc.adoptTurnId} reinjects=${esc.reinjectCount} ageMs=${esc.ageMs} ` + `\u2014 deterministic recovery exhausted, no operator nudge issued
@@ -80374,6 +80411,28 @@ class SubagentReplyAuthority2 {
80374
80411
  }
80375
80412
  var subagentReplyAuthority2 = new SubagentReplyAuthority2;
80376
80413
 
80414
+ // gateway/session-consume-signal.ts
80415
+ class SessionConsumeSignal2 {
80416
+ lastTurnMintAt = 0;
80417
+ lastMainToolCallAt = 0;
80418
+ noteTurnMint(now = Date.now()) {
80419
+ if (now > this.lastTurnMintAt)
80420
+ this.lastTurnMintAt = now;
80421
+ }
80422
+ noteMainToolCall(now = Date.now()) {
80423
+ if (now > this.lastMainToolCallAt)
80424
+ this.lastMainToolCallAt = now;
80425
+ }
80426
+ activitySince(sinceTs) {
80427
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs;
80428
+ }
80429
+ reset() {
80430
+ this.lastTurnMintAt = 0;
80431
+ this.lastMainToolCallAt = 0;
80432
+ }
80433
+ }
80434
+ var sessionConsumeSignal2 = new SessionConsumeSignal2;
80435
+
80377
80436
  // gateway/feed-open-gate.ts
80378
80437
  function shouldEarlyOpenLiveness(input) {
80379
80438
  if (!input.enabled)
@@ -81301,6 +81360,7 @@ function beginTurn(deps, ev) {
81301
81360
  scheduleEarlyLivenessOpen(next);
81302
81361
  process.stderr.write(`telegram gateway: ${formatTurnLifecycle("set", "enqueue", next, startedAt)}
81303
81362
  `);
81363
+ sessionConsumeSignal2.noteTurnMint(startedAt);
81304
81364
  rememberRecentTurn(next);
81305
81365
  promoteQueuedStatus(ev.chatId, enqThreadIdNum);
81306
81366
  shadowEmit({
@@ -83013,6 +83073,18 @@ class SubagentHandbackMarker2 {
83013
83073
 
83014
83074
  // answer-ready-flush.ts
83015
83075
  var ANSWER_READY_FLUSH_MS = 1000;
83076
+ var ANSWER_STAGE_MS = 30000;
83077
+ function resolveAnswerStageMs(env) {
83078
+ const raw = env.SWITCHROOM_ANSWER_STAGE_MS;
83079
+ if (raw == null || raw.trim() === "")
83080
+ return ANSWER_STAGE_MS;
83081
+ const n = Number(raw);
83082
+ if (!Number.isFinite(n))
83083
+ return ANSWER_STAGE_MS;
83084
+ if (n <= 0)
83085
+ return 0;
83086
+ return Math.floor(n);
83087
+ }
83016
83088
  function resolveAnswerReadyFlushMs(env) {
83017
83089
  const raw = env.SWITCHROOM_ANSWER_READY_FLUSH_MS;
83018
83090
  if (raw == null || raw.trim() === "")
@@ -83039,6 +83111,7 @@ class AnswerReadyFlushController {
83039
83111
  constructor(deps) {
83040
83112
  this.deps = deps;
83041
83113
  }
83114
+ staged = new WeakSet;
83042
83115
  get setTimeoutFn() {
83043
83116
  return this.deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms));
83044
83117
  }
@@ -83048,12 +83121,16 @@ class AnswerReadyFlushController {
83048
83121
  clear(turn) {
83049
83122
  if (turn == null)
83050
83123
  return;
83124
+ this.staged.delete(turn);
83051
83125
  const handle = this.deps.getTimerHandle(turn);
83052
83126
  if (handle != null) {
83053
83127
  this.clearTimeoutFn(handle);
83054
83128
  this.deps.setTimerHandle(turn, null);
83055
83129
  }
83056
83130
  }
83131
+ isStaged(turn) {
83132
+ return this.staged.has(turn);
83133
+ }
83057
83134
  reset() {
83058
83135
  const turn = this.deps.getCurrentTurn();
83059
83136
  this.clear(turn);
@@ -83070,9 +83147,25 @@ class AnswerReadyFlushController {
83070
83147
  if (live == null || live !== armedTurn)
83071
83148
  return;
83072
83149
  this.deps.setTimerHandle(live, null);
83073
- if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live)))
83150
+ if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live))) {
83151
+ if (this.staged.delete(live)) {
83152
+ this.deps.log?.("answer-ready stage discarded \u2014 reply/tool activity landed inside the completion window");
83153
+ }
83154
+ return;
83155
+ }
83156
+ const stageMs = this.deps.stageWindowMs ?? 0;
83157
+ if (stageMs > 0 && !this.staged.has(live)) {
83158
+ this.staged.add(live);
83159
+ this.deps.log?.(`answer-ready quiescence \u2014 staging composed terminal answer ` + `(promotes in ${stageMs}ms unless the reply lands or the turn completes)`);
83160
+ const handle = this.setTimeoutFn(() => this.onExpiry(live), stageMs);
83161
+ this.deps.setTimerHandle(live, handle);
83074
83162
  return;
83075
- this.deps.log?.("answer-ready quiescence flush \u2014 delivering composed terminal answer");
83163
+ }
83164
+ if (this.staged.delete(live)) {
83165
+ this.deps.log?.("answer-ready stage promoted \u2014 completion window closed with no reply; delivering composed terminal answer");
83166
+ } else {
83167
+ this.deps.log?.("answer-ready quiescence flush \u2014 delivering composed terminal answer");
83168
+ }
83076
83169
  this.deps.onFlush(live);
83077
83170
  }
83078
83171
  }
@@ -86171,7 +86264,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
86171
86264
  "HINDSIGHT_MCP_RECALL_BUDGET_MODE",
86172
86265
  "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT",
86173
86266
  "HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
86174
- "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT"
86267
+ "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
86268
+ "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S"
86175
86269
  ]);
86176
86270
  var HINDSIGHT_WORKER_SLOT_TYPES = [
86177
86271
  "consolidation",
@@ -101046,10 +101140,10 @@ function startOutboxSweep(deps) {
101046
101140
  }
101047
101141
 
101048
101142
  // ../src/build-info.ts
101049
- var VERSION2 = "0.19.46";
101050
- var COMMIT_SHA = "306c3ed1";
101051
- var COMMIT_DATE = "2026-08-02T00:29:39Z";
101052
- var LATEST_PR = 4194;
101143
+ var VERSION2 = "0.19.47";
101144
+ var COMMIT_SHA = "9693493e";
101145
+ var COMMIT_DATE = "2026-08-02T01:20:29Z";
101146
+ var LATEST_PR = 4201;
101053
101147
  var COMMITS_AHEAD_OF_TAG = 0;
101054
101148
 
101055
101149
  // gateway/boot-version.ts
@@ -107081,6 +107175,8 @@ if (isGatewayMain)
107081
107175
  async onToolCall(client3, msg) {
107082
107176
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client3.agentName ?? "-"} clientId=${client3.id ?? "-"} callId=${msg.id}
107083
107177
  `);
107178
+ if (!isCronIdentity(client3.agentName))
107179
+ sessionConsumeSignal.noteMainToolCall();
107084
107180
  try {
107085
107181
  const result = await executeToolCall(msg.tool, msg.args, client3.agentName);
107086
107182
  return { type: "tool_call_result", id: msg.id, success: true, result };
@@ -108769,6 +108865,7 @@ var answerReadyFlush = new AnswerReadyFlushController({
108769
108865
  turn.answerReadyFlushTimeoutId = handle;
108770
108866
  },
108771
108867
  onFlush: () => handleSessionEvent2({ kind: "turn_end", durationMs: -1, reason: "answer-ready-quiescence" }),
108868
+ stageWindowMs: resolveAnswerStageMs(process.env),
108772
108869
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
108773
108870
  `)
108774
108871
  });
@@ -108907,7 +109004,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
108907
109004
  return false;
108908
109005
  return statusKey(live.sessionChatId, live.sessionThreadId) === key;
108909
109006
  },
108910
- isClaudeBusy: () => isMachineInTurn()
109007
+ isClaudeBusy: () => isMachineInTurn(),
109008
+ sessionActivitySince: (sinceTs) => sessionConsumeSignal.activitySince(sinceTs)
108911
109009
  });
108912
109010
  var memoryLegibilityStager = new MemoryLegibilityStager;
108913
109011
  function sendMemoryLegibilityLine(event, chatId, threadId) {
@@ -283,6 +283,7 @@ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrati
283
283
  import { createTypingWrapper } from '../typing-wrap.js'
284
284
  import { createTurnTypingLoop } from './turn-typing-loop.js'
285
285
  import { createHandbackPreturnSignal } from './handback-preturn-signal.js'
286
+ import { sessionConsumeSignal } from './session-consume-signal.js'
286
287
  import { createHandbackOrphanRecovery } from './handback-orphan-recovery.js'
287
288
  import { deriveTurnId } from './derive-turn-id.js'
288
289
  import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
@@ -495,10 +496,7 @@ import {
495
496
  } from '../reply-owner-resolve.js'
496
497
  import { SubagentHandbackMarker } from './subagent-handback-marker.js'
497
498
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
498
- import {
499
- AnswerReadyFlushController,
500
- resolveAnswerReadyFlushMs,
501
- } from '../answer-ready-flush.js'
499
+ import { AnswerReadyFlushController, resolveAnswerReadyFlushMs, resolveAnswerStageMs } from '../answer-ready-flush.js'
502
500
  // #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
503
501
  import { decideTurnEndGate } from './turn-end-gate.js'
504
502
  // #1122 PR3: turn-flush-prose-recovery removed with the progress card.
@@ -10748,6 +10746,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
10748
10746
 
10749
10747
  async onToolCall(client: IpcClient, msg: ToolCallMessage): Promise<ToolCallResult> {
10750
10748
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client.agentName ?? '-'} clientId=${client.id ?? '-'} callId=${msg.id}\n`)
10749
+ if (!isCronIdentity(client.agentName)) sessionConsumeSignal.noteMainToolCall() // consumption proxy (session-consume-signal.ts)
10751
10750
  try {
10752
10751
  // #4172 — the calling client's identity gates reply supersede authority
10753
10752
  // (see replyCallerIsForeignSession, cron-session.ts).
@@ -13547,29 +13546,29 @@ function resetOrphanedReplyTimeout(): void {
13547
13546
  * PR A — DETERMINISTIC answer-ready quiescence flush controller.
13548
13547
  *
13549
13548
  * The orchestration (arm / debounce / rollover-guard / fire-time re-verify /
13550
- * disarm) lives in the extracted, unit-tested `AnswerReadyFlushController`
13551
- * (answer-ready-flush.ts) — the gateway only supplies the thin deps below and
13552
- * calls `.reset()` / `.clear(turn)`. Behaviour:
13549
+ * stage-don't-send / disarm) lives in the extracted, unit-tested
13550
+ * `AnswerReadyFlushController` (answer-ready-flush.ts) — the gateway only
13551
+ * supplies the thin deps below and calls `.reset()` / `.clear(turn)`.
13553
13552
  *
13554
13553
  * - `reset()` (from `case 'text'`): (re)arm iff the turn has a genuine composed
13555
13554
  * terminal answer (the SAME `decideTurnFlush` classifier the turn-flush branch
13556
13555
  * uses) AND is quiescent. Each text chunk re-arms → the debounce.
13557
13556
  * - `clear(turn)` (from tool activity + `endCurrentTurnAtomic`): the DISARM.
13558
- * - on fire: re-pin `currentTurn === turn`, re-verify quiescence, then dispatch
13559
- * a positive `answer-ready-quiescence` synthetic turn_end that routes through
13560
- * the IDENTICAL turn-flush send path (endCurrentTurnAtomic send-gated IIFE
13561
- * honest PR-B record). Replaces the ~150 s dead wait with ~1 s, in code.
13557
+ * - on fire: re-pin `currentTurn === turn`, re-verify quiescence, then STAGE the
13558
+ * answer on the still-live turn (`stageWindowMs` see ANSWER_STAGE_MS) and
13559
+ * promote only when the completion window closes with no reply, dispatching
13560
+ * the positive `answer-ready-quiescence` synthetic turn_end through the
13561
+ * IDENTICAL turn-flush send path. A reply landing in the window discards the
13562
+ * stage (msgs 25843/25844); a real turn_end delivers via the turn-flush branch.
13562
13563
  *
13563
13564
  * Exactly-once: the synthetic turn_end's `endCurrentTurnAtomic` nulls the atom,
13564
13565
  * so a later REAL turn_end (or the orphaned backstop) short-circuits at its
13565
13566
  * `turn != null` guard; `endCurrentTurnAtomic` also calls `.clear(turn)` so a
13566
- * real turn_end that lands FIRST cancels a pending flush. `outboundDedup` is the
13567
- * second layer.
13567
+ * real turn_end that lands FIRST cancels a pending flush/stage.
13568
13568
  *
13569
13569
  * The `answer-ready-quiescence` reason bypasses the `durationMs===-1`
13570
- * recently-streaming SUPPRESSION guard (the terminal answer text itself stamps
13571
- * recentlyStreaming): quiescence IS the positive "streaming has settled" signal,
13572
- * the opposite of the hung-turn backstop that guard protects.
13570
+ * recently-streaming SUPPRESSION guard: quiescence IS the positive "streaming
13571
+ * has settled" signal, the opposite of the hung-turn backstop that guard covers.
13573
13572
  */
13574
13573
  const answerReadyFlush = new AnswerReadyFlushController<CurrentTurn>({
13575
13574
  getCurrentTurn: () => currentTurn,
@@ -13593,6 +13592,7 @@ const answerReadyFlush = new AnswerReadyFlushController<CurrentTurn>({
13593
13592
  },
13594
13593
  onFlush: () =>
13595
13594
  handleSessionEvent({ kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' }),
13595
+ stageWindowMs: resolveAnswerStageMs(process.env), // stage-don't-send — see ANSWER_STAGE_MS
13596
13596
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
13597
13597
  })
13598
13598
 
@@ -13831,9 +13831,10 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
13831
13831
  return statusKey(live.sessionChatId, live.sessionThreadId) === key
13832
13832
  },
13833
13833
  // Queue-state gate for the orphan reap (see the signal's `isClaudeBusy` doc):
13834
- // while a turn is in flight the handback is enqueued behind it, not orphaned.
13835
- // False for both `bridge_alive_idle` and `bridge_dead` → genuine orphan reaps.
13834
+ // a handback queued behind an in-flight turn is not orphaned.
13836
13835
  isClaudeBusy: () => isMachineInTurn(),
13836
+ // 2026-08-02 invisible-consumption gate — see session-consume-signal.ts.
13837
+ sessionActivitySince: (sinceTs) => sessionConsumeSignal.activitySince(sinceTs),
13837
13838
  })
13838
13839
 
13839
13840
  /**
@@ -238,6 +238,18 @@ export interface HandbackPreturnSignalDeps {
238
238
  * is the GLOBAL busy signal because the handback queues behind whatever turn
239
239
  * is running, regardless of topic. Optional; defaults to "not busy". */
240
240
  isClaudeBusy?: () => boolean
241
+ /** True IFF the gateway observed the SESSION consume input after `sinceTs` —
242
+ * a turn minting at the enqueue seam, or a main-session MCP tool call over
243
+ * the IPC bridge (`session-consume-signal.ts`). Consulted at the moment the
244
+ * reap would otherwise declare a genuine orphan: claude holds ONE FIFO input
245
+ * queue, so post-delivery consumption evidence means the delivered handback
246
+ * entered the model's context even though no adopting turn was ever minted
247
+ * (the 2026-08-02 invisible-consumption incident — the session answered the
248
+ * handback via a `reply` from idle with zero session-tail events, then the
249
+ * reap re-injected the already-answered report twice). When true the reap
250
+ * CLEANS UP (card + typing + entry) without re-injecting and without
251
+ * escalating. Optional; defaults to "no evidence" (legacy re-inject). */
252
+ sessionActivitySince?: (sinceTs: number) => boolean
241
253
  now?: () => number
242
254
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
243
255
  debounceMs?: number
@@ -487,13 +499,27 @@ export function createHandbackPreturnSignal(
487
499
  entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
488
500
  return
489
501
  }
502
+ // INVISIBLE-CONSUMPTION GATE (2026-08-02 klanker incident). We are at the
503
+ // point the reap would declare a genuine orphan: claude is idle, the
504
+ // delivered TTL elapsed, and no adopting turn ever minted. But "no adopting
505
+ // turn" is NOT proof the handback went un-consumed — the session can
506
+ // consume injected input WITHOUT the gateway minting a turn (zero
507
+ // session-tail events; the only trace was a `reply` tool call from idle).
508
+ // If the gateway observed the session consume input after this handback
509
+ // was delivered (a later turn minting, or a main-session tool call —
510
+ // claude's single FIFO queue means either proves the earlier delivery
511
+ // entered the model's context), the handback was already processed:
512
+ // re-injecting it re-runs an answered report (observed: the same worker
513
+ // report processed three times). Clean up WITHOUT re-injection.
514
+ const consumedElsewhere = deps.sessionActivitySince?.(entry.deliveredAt) === true
490
515
  entry.consumed = true
491
- // GENUINE ORPHAN. Recover DETERMINISTICALLY — never ask the operator to
492
- // nudge. Stop the forever-running typing loop, DELETE the frozen card (do
493
- // not leave a stale "needs a nudge" message), then re-inject the handback so
494
- // the machine re-processes it itself. Cap the re-injections; past the cap
495
- // escalate to fleet-health telemetry (NOT a chat card).
496
- stopTypingUnlessTurnLive(entry, 'orphan reap')
516
+ // GENUINE ORPHAN (or consumed-elsewhere cleanup). Recover DETERMINISTICALLY
517
+ // — never ask the operator to nudge. Stop the forever-running typing loop,
518
+ // DELETE the frozen card (do not leave a stale "needs a nudge" message),
519
+ // then for a genuine orphan only re-inject the handback so the machine
520
+ // re-processes it itself. Cap the re-injections; past the cap escalate to
521
+ // fleet-health telemetry (NOT a chat card).
522
+ stopTypingUnlessTurnLive(entry, consumedElsewhere ? 'consumed reap' : 'orphan reap')
497
523
  if (entry.activityMessageId != null) {
498
524
  const record: PreTurnCardRecord = {
499
525
  turnKey: entry.syntheticTurnKey,
@@ -518,6 +544,18 @@ export function createHandbackPreturnSignal(
518
544
  // synchronously) is not swallowed by the per-topic dedupe guard. The entry
519
545
  // object stays valid for the captured re-inject below.
520
546
  dropEntry(entry)
547
+ if (consumedElsewhere) {
548
+ // Distinct structured line so fleet-health can track how often the
549
+ // session consumes a handback without a gateway-visible adopting turn
550
+ // (the underlying session-tail anomaly stays observable even though the
551
+ // false re-injection is gone).
552
+ log(
553
+ `handback-preturn-signal: reap resolved key=${entry.statusKey} ` +
554
+ `turnId=${entry.adoptTurnId} — session consumed input after delivery ` +
555
+ `(no adopting turn observed); cleaned up without re-injection\n`,
556
+ )
557
+ return
558
+ }
521
559
  if (entry.reinjectCount < maxReinjects) {
522
560
  const nextCount = entry.reinjectCount + 1
523
561
  // Stamp the counter on the inbound so it survives the buffer round trip
@@ -0,0 +1,72 @@
1
+ /**
2
+ * session-consume-signal.ts — "did the claude session consume input after T?"
3
+ *
4
+ * Built for the 2026-08-02 klanker false-orphan incident (handback turnId
5
+ * `#1785628912223`). A background worker's `subagent_handback` was released and
6
+ * DELIVERED to the bridge at 00:01:53Z; the session consumed it and answered it
7
+ * (a `reply` tool call at 00:02:58Z, IPC-observed, while the gateway's shadow
8
+ * state was `bridge_alive_idle`) — but NO gateway turn ever minted for it: the
9
+ * session-tail produced no `enqueue`/stream events for that consumption, so
10
+ * `handback-preturn-signal`'s entry was never adopted. After the delivered TTL
11
+ * the reap declared a genuine orphan and RE-INJECTED the already-answered
12
+ * handback — twice — re-processing the same worker report three times.
13
+ *
14
+ * The orphan reap's existing gates (`isClaudeBusy`, the delivered TTL) key off
15
+ * gateway-minted TURN state, which is exactly the signal that was absent. This
16
+ * module tracks the two consumption proxies the gateway CAN always observe,
17
+ * independent of the session-tail:
18
+ *
19
+ * - a TURN MINT (the `enqueue` seam in stream-render): claude holds a single
20
+ * FIFO input queue, so a turn minting for an inbound delivered AFTER the
21
+ * handback proves everything delivered before it entered the model's
22
+ * context (2026-08-02: real turn `#25821` minted 00:04:13Z and completed,
23
+ * while the reap still declared the 00:01:53Z handback orphaned);
24
+ * - a MAIN-SESSION MCP TOOL CALL over the IPC bridge (reply, progress_update,
25
+ * …): the session can only produce a tool call by processing input, so a
26
+ * tool call arriving after the delivery is direct evidence the session is
27
+ * alive and consuming — the 00:02:58Z reply was the ONLY observable trace
28
+ * of the invisible consumption. Cron/foreign sessions are excluded at the
29
+ * call site (their activity says nothing about the main session's queue).
30
+ *
31
+ * Consumers ask `activitySince(deliveredAt)`. The deliberate trade: when this
32
+ * returns true for a handback that was in fact DROPPED before reaching claude
33
+ * (bridge death in the release→write window) while unrelated session activity
34
+ * happened to occur, the reap now cleans up instead of re-injecting — a lost
35
+ * recovery for a rare double-fault, logged distinctly for fleet-health. The
36
+ * previous behaviour re-injected an already-answered handback on every
37
+ * invisible consumption, which is the incident that actually occurs.
38
+ *
39
+ * Pure state, no I/O; module singleton mirrors `subagentReplyAuthority`.
40
+ */
41
+
42
+ export class SessionConsumeSignal {
43
+ private lastTurnMintAt = 0
44
+ private lastMainToolCallAt = 0
45
+
46
+ /** A gateway turn minted at the `enqueue` seam (stream-render). */
47
+ noteTurnMint(now: number = Date.now()): void {
48
+ if (now > this.lastTurnMintAt) this.lastTurnMintAt = now
49
+ }
50
+
51
+ /** A NON-CRON client's MCP tool call arrived over the IPC bridge. The call
52
+ * site excludes cron identities — a Tier-1 cheap-cron session consuming its
53
+ * own input proves nothing about the main session's queue. */
54
+ noteMainToolCall(now: number = Date.now()): void {
55
+ if (now > this.lastMainToolCallAt) this.lastMainToolCallAt = now
56
+ }
57
+
58
+ /** True IFF either consumption proxy was observed strictly after `sinceTs`. */
59
+ activitySince(sinceTs: number): boolean {
60
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs
61
+ }
62
+
63
+ /** Test-only: forget all observations. */
64
+ reset(): void {
65
+ this.lastTurnMintAt = 0
66
+ this.lastMainToolCallAt = 0
67
+ }
68
+ }
69
+
70
+ /** The ONE live instance (one CLI session per gateway process — the same
71
+ * module-singleton shape as `subagentReplyAuthority`). */
72
+ export const sessionConsumeSignal = new SessionConsumeSignal()
@@ -75,6 +75,7 @@ import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
75
75
  import { decideTurnFlush } from '../turn-flush-safety.js'
76
76
  import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
77
77
  import { subagentReplyAuthority } from './subagent-reply-authority.js'
78
+ import { sessionConsumeSignal } from './session-consume-signal.js'
78
79
  import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
79
80
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
80
81
  import { deriveTurnId } from './derive-turn-id.js'
@@ -747,6 +748,10 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
747
748
  process.stderr.write(
748
749
  `telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
749
750
  )
751
+ // Consumption proxy for the handback orphan reap (session-consume-signal):
752
+ // claude holds ONE FIFO input queue, so this turn minting proves every
753
+ // inbound delivered before its own entered the model's context.
754
+ sessionConsumeSignal.noteTurnMint(startedAt)
750
755
  // Component 3 — retain in the bounded recently-ended registry so a
751
756
  // LATE reply (landing after currentTurn flips to a successor) can
752
757
  // still resolve THIS turn's origin thread by its turnId.