switchroom 0.19.46 → 0.19.48

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();
@@ -21673,6 +21673,8 @@ var init_schema = __esm(() => {
21673
21673
  profile: exports_external.string().optional().describe("Memory profile bank this agent's curated memory defaults key off \u2014 " + "the built-in disposition + observations_mission in PROFILE_MEMORY_DEFAULTS. " + "Decouples the memory profile from `extends` (the filesystem persona " + "profile), so an agent on `extends: default` can opt into the `coding` " + "memory bundle via `memory.profile: coding` without inheriting the coding " + "persona. Resolution: memory.profile \u2192 extends \u2192 DEFAULT_PROFILE (see " + "resolveMemoryProfile). Unset \u21d2 byte-identical to keying off `extends`."),
21674
21674
  bank_mission: exports_external.string().optional().describe("Bank-level mission statement used during recall to contextualize " + "results. NOTE: this is an alias for the Hindsight engine's " + "`reflect_mission` field (verified live: switchroom's bank_mission " + "lands in `config.reflect_mission`). Prefer `reflect_mission` going " + "forward; `bank_mission` is retained for back-compat. If both are " + "set, `reflect_mission` wins. Cascade: override."),
21675
21675
  reflect_mission: exports_external.string().optional().describe("Mission/context steering Hindsight Reflect operations (the bank's " + "'who am I / what matters' framing applied during recall). The " + "engine-accurate name for what `bank_mission` sets. Cascade: override."),
21676
+ reflect_budget: exports_external.enum(["low", "mid", "high"]).optional().describe("Thinking/retrieval budget injected into reflect MCP calls when the " + "caller omits budget. Unset \u21d2 shim default (mid). Explicit per-call " + "budget always wins. Higher = better recall on fuzzy queries, more " + "backend latency/compute. stdio-shim transport only (ignored under " + "memory.config.mcp_transport: http). Cascade: override (per-agent " + "wins over default)."),
21677
+ reflect_max_tokens: exports_external.number().int().positive().max(8192).optional().describe("Token cap injected into reflect MCP calls when caller omits " + "max_tokens. Unset \u21d2 shim default 1024. Values much above ~2048 risk " + "exceeding Claude Code's MCP output cap, silently dropping the " + "payload \u2014 raise deliberately. Explicit per-call values always win. " + "stdio-shim transport only. Cascade: override."),
21676
21678
  retain_mission: exports_external.string().optional().describe("Instructions for the fact extraction LLM during retain. Cascade: override."),
21677
21679
  mental_models: exports_external.array(exports_external.object({
21678
21680
  name: exports_external.string().min(1).describe("Stable model name (identity key for idempotent ensure). Two " + "declarations with the same name in one agent are rejected."),
@@ -21934,11 +21936,18 @@ var init_schema = __esm(() => {
21934
21936
  "extended",
21935
21937
  "complete"
21936
21938
  ]);
21939
+ GoogleServiceTokenSchema = exports_external.enum([
21940
+ "cal",
21941
+ "drive",
21942
+ "docs",
21943
+ "sheets",
21944
+ "slides"
21945
+ ]);
21937
21946
  GoogleWorkspaceConfigSchema = exports_external.object({
21938
21947
  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
21948
  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
21949
  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).")
21950
+ 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
21951
  }).optional();
21943
21952
  LiteLLMConfigSchema = exports_external.object({
21944
21953
  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 +21980,7 @@ var init_schema = __esm(() => {
21971
21980
  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
21981
  consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
21973
21982
  }).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).")
21983
+ 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
21984
  });
21976
21985
  MicrosoftWorkspaceConfigSchema = exports_external.object({
21977
21986
  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)."),
@@ -22157,6 +22166,8 @@ var init_schema = __esm(() => {
22157
22166
  }).optional(),
22158
22167
  bank_mission: exports_external.string().optional(),
22159
22168
  reflect_mission: exports_external.string().optional(),
22169
+ reflect_budget: exports_external.enum(["low", "mid", "high"]).optional(),
22170
+ reflect_max_tokens: exports_external.number().int().positive().max(8192).optional(),
22160
22171
  retain_mission: exports_external.string().optional(),
22161
22172
  observations_mission: exports_external.string().optional(),
22162
22173
  disposition: exports_external.object({
@@ -22445,7 +22456,9 @@ var init_schema = __esm(() => {
22445
22456
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
22446
22457
  enabled_for: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
22447
22458
  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.")
22459
+ })).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."),
22460
+ 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)."),
22461
+ 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
22462
  })).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
22463
  microsoft_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
22451
22464
  message: "Account key must be a Microsoft account email like 'alice@outlook.com' or 'alice@contoso.com' (colons not allowed)"
@@ -54129,8 +54142,9 @@ function createHandbackPreturnSignal(deps) {
54129
54142
  entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
54130
54143
  return;
54131
54144
  }
54145
+ const consumedElsewhere = deps.sessionActivitySince?.(entry.deliveredAt) === true;
54132
54146
  entry.consumed = true;
54133
- stopTypingUnlessTurnLive(entry, "orphan reap");
54147
+ stopTypingUnlessTurnLive(entry, consumedElsewhere ? "consumed reap" : "orphan reap");
54134
54148
  if (entry.activityMessageId != null) {
54135
54149
  const record = {
54136
54150
  turnKey: entry.syntheticTurnKey,
@@ -54147,6 +54161,11 @@ function createHandbackPreturnSignal(deps) {
54147
54161
  });
54148
54162
  }
54149
54163
  dropEntry(entry);
54164
+ if (consumedElsewhere) {
54165
+ 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
54166
+ `);
54167
+ return;
54168
+ }
54150
54169
  if (entry.reinjectCount < maxReinjects) {
54151
54170
  const nextCount = entry.reinjectCount + 1;
54152
54171
  entry.inbound.meta[HANDBACK_REINJECT_COUNT_META_KEY] = String(nextCount);
@@ -54296,6 +54315,28 @@ function createHandbackPreturnSignal(deps) {
54296
54315
  };
54297
54316
  }
54298
54317
 
54318
+ // gateway/session-consume-signal.ts
54319
+ class SessionConsumeSignal {
54320
+ lastTurnMintAt = 0;
54321
+ lastMainToolCallAt = 0;
54322
+ noteTurnMint(now = Date.now()) {
54323
+ if (now > this.lastTurnMintAt)
54324
+ this.lastTurnMintAt = now;
54325
+ }
54326
+ noteMainToolCall(now = Date.now()) {
54327
+ if (now > this.lastMainToolCallAt)
54328
+ this.lastMainToolCallAt = now;
54329
+ }
54330
+ activitySince(sinceTs) {
54331
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs;
54332
+ }
54333
+ reset() {
54334
+ this.lastTurnMintAt = 0;
54335
+ this.lastMainToolCallAt = 0;
54336
+ }
54337
+ }
54338
+ var sessionConsumeSignal = new SessionConsumeSignal;
54339
+
54299
54340
  // gateway/handback-orphan-recovery.ts
54300
54341
  function formatOrphanEscalation(esc) {
54301
54342
  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 +80415,28 @@ class SubagentReplyAuthority2 {
80374
80415
  }
80375
80416
  var subagentReplyAuthority2 = new SubagentReplyAuthority2;
80376
80417
 
80418
+ // gateway/session-consume-signal.ts
80419
+ class SessionConsumeSignal2 {
80420
+ lastTurnMintAt = 0;
80421
+ lastMainToolCallAt = 0;
80422
+ noteTurnMint(now = Date.now()) {
80423
+ if (now > this.lastTurnMintAt)
80424
+ this.lastTurnMintAt = now;
80425
+ }
80426
+ noteMainToolCall(now = Date.now()) {
80427
+ if (now > this.lastMainToolCallAt)
80428
+ this.lastMainToolCallAt = now;
80429
+ }
80430
+ activitySince(sinceTs) {
80431
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs;
80432
+ }
80433
+ reset() {
80434
+ this.lastTurnMintAt = 0;
80435
+ this.lastMainToolCallAt = 0;
80436
+ }
80437
+ }
80438
+ var sessionConsumeSignal2 = new SessionConsumeSignal2;
80439
+
80377
80440
  // gateway/feed-open-gate.ts
80378
80441
  function shouldEarlyOpenLiveness(input) {
80379
80442
  if (!input.enabled)
@@ -81301,6 +81364,7 @@ function beginTurn(deps, ev) {
81301
81364
  scheduleEarlyLivenessOpen(next);
81302
81365
  process.stderr.write(`telegram gateway: ${formatTurnLifecycle("set", "enqueue", next, startedAt)}
81303
81366
  `);
81367
+ sessionConsumeSignal2.noteTurnMint(startedAt);
81304
81368
  rememberRecentTurn(next);
81305
81369
  promoteQueuedStatus(ev.chatId, enqThreadIdNum);
81306
81370
  shadowEmit({
@@ -83013,6 +83077,18 @@ class SubagentHandbackMarker2 {
83013
83077
 
83014
83078
  // answer-ready-flush.ts
83015
83079
  var ANSWER_READY_FLUSH_MS = 1000;
83080
+ var ANSWER_STAGE_MS = 30000;
83081
+ function resolveAnswerStageMs(env) {
83082
+ const raw = env.SWITCHROOM_ANSWER_STAGE_MS;
83083
+ if (raw == null || raw.trim() === "")
83084
+ return ANSWER_STAGE_MS;
83085
+ const n = Number(raw);
83086
+ if (!Number.isFinite(n))
83087
+ return ANSWER_STAGE_MS;
83088
+ if (n <= 0)
83089
+ return 0;
83090
+ return Math.floor(n);
83091
+ }
83016
83092
  function resolveAnswerReadyFlushMs(env) {
83017
83093
  const raw = env.SWITCHROOM_ANSWER_READY_FLUSH_MS;
83018
83094
  if (raw == null || raw.trim() === "")
@@ -83039,6 +83115,7 @@ class AnswerReadyFlushController {
83039
83115
  constructor(deps) {
83040
83116
  this.deps = deps;
83041
83117
  }
83118
+ staged = new WeakSet;
83042
83119
  get setTimeoutFn() {
83043
83120
  return this.deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms));
83044
83121
  }
@@ -83048,12 +83125,16 @@ class AnswerReadyFlushController {
83048
83125
  clear(turn) {
83049
83126
  if (turn == null)
83050
83127
  return;
83128
+ this.staged.delete(turn);
83051
83129
  const handle = this.deps.getTimerHandle(turn);
83052
83130
  if (handle != null) {
83053
83131
  this.clearTimeoutFn(handle);
83054
83132
  this.deps.setTimerHandle(turn, null);
83055
83133
  }
83056
83134
  }
83135
+ isStaged(turn) {
83136
+ return this.staged.has(turn);
83137
+ }
83057
83138
  reset() {
83058
83139
  const turn = this.deps.getCurrentTurn();
83059
83140
  this.clear(turn);
@@ -83070,9 +83151,25 @@ class AnswerReadyFlushController {
83070
83151
  if (live == null || live !== armedTurn)
83071
83152
  return;
83072
83153
  this.deps.setTimerHandle(live, null);
83073
- if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live)))
83154
+ if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live))) {
83155
+ if (this.staged.delete(live)) {
83156
+ this.deps.log?.("answer-ready stage discarded \u2014 reply/tool activity landed inside the completion window");
83157
+ }
83158
+ return;
83159
+ }
83160
+ const stageMs = this.deps.stageWindowMs ?? 0;
83161
+ if (stageMs > 0 && !this.staged.has(live)) {
83162
+ this.staged.add(live);
83163
+ this.deps.log?.(`answer-ready quiescence \u2014 staging composed terminal answer ` + `(promotes in ${stageMs}ms unless the reply lands or the turn completes)`);
83164
+ const handle = this.setTimeoutFn(() => this.onExpiry(live), stageMs);
83165
+ this.deps.setTimerHandle(live, handle);
83074
83166
  return;
83075
- this.deps.log?.("answer-ready quiescence flush \u2014 delivering composed terminal answer");
83167
+ }
83168
+ if (this.staged.delete(live)) {
83169
+ this.deps.log?.("answer-ready stage promoted \u2014 completion window closed with no reply; delivering composed terminal answer");
83170
+ } else {
83171
+ this.deps.log?.("answer-ready quiescence flush \u2014 delivering composed terminal answer");
83172
+ }
83076
83173
  this.deps.onFlush(live);
83077
83174
  }
83078
83175
  }
@@ -85971,6 +86068,9 @@ for (const name of SHARED_FRAGMENTS) {
85971
86068
  }
85972
86069
  }
85973
86070
 
86071
+ // ../src/memory/scaffold-integration.ts
86072
+ init_merge();
86073
+
85974
86074
  // ../src/litellm/timeout-budget.ts
85975
86075
  var LITELLM_ROUTER_MARGIN_S = 10;
85976
86076
  var LITELLM_TIMEOUT_TIERS = {
@@ -86171,7 +86271,8 @@ var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
86171
86271
  "HINDSIGHT_MCP_RECALL_BUDGET_MODE",
86172
86272
  "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT",
86173
86273
  "HINDSIGHT_API_RETAIN_WALL_TIMEOUT",
86174
- "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT"
86274
+ "HINDSIGHT_API_CONSOLIDATION_RECALL_MAX_CONCURRENT",
86275
+ "HINDSIGHT_MM_REFRESH_MIN_INTERVAL_S"
86175
86276
  ]);
86176
86277
  var HINDSIGHT_WORKER_SLOT_TYPES = [
86177
86278
  "consolidation",
@@ -101046,10 +101147,10 @@ function startOutboxSweep(deps) {
101046
101147
  }
101047
101148
 
101048
101149
  // ../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;
101150
+ var VERSION2 = "0.19.48";
101151
+ var COMMIT_SHA = "22eb634b";
101152
+ var COMMIT_DATE = "2026-08-02T05:59:26Z";
101153
+ var LATEST_PR = 4207;
101053
101154
  var COMMITS_AHEAD_OF_TAG = 0;
101054
101155
 
101055
101156
  // gateway/boot-version.ts
@@ -107081,6 +107182,8 @@ if (isGatewayMain)
107081
107182
  async onToolCall(client3, msg) {
107082
107183
  process.stderr.write(`telegram gateway: ipc: tool_call tool=${msg.tool} agent=${client3.agentName ?? "-"} clientId=${client3.id ?? "-"} callId=${msg.id}
107083
107184
  `);
107185
+ if (!isCronIdentity(client3.agentName))
107186
+ sessionConsumeSignal.noteMainToolCall();
107084
107187
  try {
107085
107188
  const result = await executeToolCall(msg.tool, msg.args, client3.agentName);
107086
107189
  return { type: "tool_call_result", id: msg.id, success: true, result };
@@ -108769,6 +108872,7 @@ var answerReadyFlush = new AnswerReadyFlushController({
108769
108872
  turn.answerReadyFlushTimeoutId = handle;
108770
108873
  },
108771
108874
  onFlush: () => handleSessionEvent2({ kind: "turn_end", durationMs: -1, reason: "answer-ready-quiescence" }),
108875
+ stageWindowMs: resolveAnswerStageMs(process.env),
108772
108876
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
108773
108877
  `)
108774
108878
  });
@@ -108907,7 +109011,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
108907
109011
  return false;
108908
109012
  return statusKey(live.sessionChatId, live.sessionThreadId) === key;
108909
109013
  },
108910
- isClaudeBusy: () => isMachineInTurn()
109014
+ isClaudeBusy: () => isMachineInTurn(),
109015
+ sessionActivitySince: (sinceTs) => sessionConsumeSignal.activitySince(sinceTs)
108911
109016
  });
108912
109017
  var memoryLegibilityStager = new MemoryLegibilityStager;
108913
109018
  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.