switchroom 0.12.17 → 0.12.19

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure transition selector for the proactive-compaction user
3
+ * notification (a single Telegram card edited START → FINISH). Kept
4
+ * side-effect-free so the lifecycle — the part prone to double-post /
5
+ * stale-edit / spurious-finish — is unit-testable in isolation. The
6
+ * impure shell (send/editMessageText, the wall-clock timeout) lives in
7
+ * gateway.ts.
8
+ *
9
+ * The "finished" signal is the proactive-compaction state machine's
10
+ * re-arm edge (`armed: false → true`, which the decider only produces
11
+ * when post-`/compact` occupancy drops below 0.6×cap — i.e. context
12
+ * verifiably shrank). This module does NOT own the wall-clock timeout
13
+ * (an idle agent produces no evaluations, so a dangling card must be
14
+ * resolved by a real timer in the shell, independent of this loop).
15
+ *
16
+ * Session reset/rotation (`session.max_idle` / `max_turns`) is
17
+ * deliberately silent and is not represented here.
18
+ */
19
+
20
+ export interface CompactNotifyState {
21
+ phase: "idle" | "awaiting";
22
+ /**
23
+ * The active session file when START was posted. FINISH is only
24
+ * accepted if the re-arm edge is observed against this SAME file —
25
+ * a sub-agent transcript briefly leading session-tail's currentFile
26
+ * can read a small occupancy and spuriously satisfy the re-arm
27
+ * condition; gating on the start-file rejects that false positive.
28
+ */
29
+ fileAtStart: string | null;
30
+ }
31
+
32
+ /**
33
+ * - `none` — no card action this evaluation.
34
+ * - `start` — post a fresh START card.
35
+ * - `start-superseding` — a prior card is still outstanding; mark it
36
+ * superseded, then post a fresh START card.
37
+ * - `finish` — edit the outstanding card to FINISHED.
38
+ */
39
+ export type CompactNotifyAction =
40
+ | "none"
41
+ | "start"
42
+ | "start-superseding"
43
+ | "finish";
44
+
45
+ export interface CompactNotifyEvent {
46
+ /** The proactive-compaction decider fired `/compact` this eval. */
47
+ fired: boolean;
48
+ /** Decider re-arm edge this eval (wasArmed=false → state.armed=true). */
49
+ rearmed: boolean;
50
+ /** session-tail's active file used for the occupancy read this eval. */
51
+ activeFile: string | null;
52
+ }
53
+
54
+ export function idleCompactNotifyState(): CompactNotifyState {
55
+ return { phase: "idle", fileAtStart: null };
56
+ }
57
+
58
+ /**
59
+ * Select the card action for this idle evaluation and the next state.
60
+ * Pure: same inputs → same outputs, no I/O.
61
+ *
62
+ * Precedence:
63
+ * 1. A fire always (re)starts a card. If one was still outstanding,
64
+ * supersede it first (single-outstanding-card invariant).
65
+ * 2. While awaiting, a re-arm edge ON THE SAME start-file → FINISH.
66
+ * 3. Otherwise hold (the shell's wall-clock timeout resolves a card
67
+ * that never gets a re-arm — e.g. a compaction that didn't shrink
68
+ * context, or an agent that went idle right after START).
69
+ */
70
+ export function nextCompactNotify(
71
+ state: CompactNotifyState,
72
+ ev: CompactNotifyEvent,
73
+ ): { state: CompactNotifyState; action: CompactNotifyAction } {
74
+ if (ev.fired) {
75
+ const superseding = state.phase === "awaiting";
76
+ return {
77
+ state: { phase: "awaiting", fileAtStart: ev.activeFile },
78
+ action: superseding ? "start-superseding" : "start",
79
+ };
80
+ }
81
+
82
+ if (state.phase === "awaiting") {
83
+ if (
84
+ ev.rearmed &&
85
+ ev.activeFile != null &&
86
+ ev.activeFile === state.fileAtStart
87
+ ) {
88
+ return { state: idleCompactNotifyState(), action: "finish" };
89
+ }
90
+ return { state, action: "none" };
91
+ }
92
+
93
+ return { state, action: "none" };
94
+ }
@@ -17,7 +17,7 @@ import { execFileSync, execSync, spawn } from 'child_process'
17
17
  import {
18
18
  readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync,
19
19
  statSync, renameSync, realpathSync, chmodSync, openSync, closeSync,
20
- existsSync, unlinkSync,
20
+ existsSync, unlinkSync, appendFileSync,
21
21
  } from 'fs'
22
22
  import { homedir } from 'os'
23
23
  import { join, extname, sep, basename } from 'path'
@@ -230,6 +230,7 @@ import { refreshBanner } from '../slot-banner-driver.js'
230
230
  import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
231
231
  import { readTurnUsages } from '../../src/agents/perf.js'
232
232
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
233
+ import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
233
234
  import {
234
235
  tryHostdDispatch,
235
236
  hostdRequestId,
@@ -248,6 +249,8 @@ import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js
248
249
  import { handleRequestDriveApproval } from './drive-write-approval.js'
249
250
  import { buildDiffPreviewCard } from './diff-preview-card.js'
250
251
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
252
+ import { createInboundSpool } from './inbound-spool.js'
253
+ import { decideInboundDelivery } from './inbound-delivery-gate.js'
251
254
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
252
255
  import {
253
256
  buildVaultGrantApprovedInbound,
@@ -1086,6 +1089,25 @@ let lastSessionActiveFile: string | null = null
1086
1089
  // turn and we must not double-dispatch before the first send settles.
1087
1090
  let compactState: CompactState = initialCompactState()
1088
1091
  let compactDispatching = false
1092
+
1093
+ // User-facing proactive-compaction notification (single Telegram card
1094
+ // edited START → FINISH; transition selection is pure in
1095
+ // ./compact-notify). Session reset/rotation stays silent — not here.
1096
+ // The wall-clock timer is owned HERE (not the pure helper): an idle
1097
+ // agent produces no idle evaluations, so a card that never gets a
1098
+ // re-arm edge must be resolved by a real timer, independent of the
1099
+ // eval loop, or it would dangle forever.
1100
+ const COMPACT_CARD_TIMEOUT_MS = 15 * 60 * 1000
1101
+ interface OutstandingCompactCard {
1102
+ chatId: string
1103
+ threadId: number | undefined
1104
+ messageId: number
1105
+ occAtStart: number
1106
+ capAtStart: number
1107
+ timer: ReturnType<typeof setTimeout>
1108
+ }
1109
+ let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
1110
+ let outstandingCompactCard: OutstandingCompactCard | null = null
1089
1111
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
1090
1112
  const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
1091
1113
  const suppressPtyPreview = new Set<string>()
@@ -1258,6 +1280,30 @@ function purgeReactionTracking(key: string): void {
1258
1280
  // response to the client was already sent when the restart was
1259
1281
  // scheduled, so nobody is waiting on this.
1260
1282
  if (activeTurnStartedAt.size === 0) {
1283
+ // #1556: the deterministic delivery point. claude has just gone
1284
+ // idle — flush any inbound held mid-turn so the channel
1285
+ // notification lands at the idle prompt and submits as a fresh
1286
+ // turn (instead of stranding in the composer, the lawgpt wedge).
1287
+ // Zero-churn: depth check first, no work on the common empty path.
1288
+ // Lossless: redeliver re-buffers any per-message miss (bridge
1289
+ // mid-reconnect), which onClientRegistered then drains.
1290
+ const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
1291
+ if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
1292
+ const fr = redeliverBufferedInbound(
1293
+ pendingInboundBuffer,
1294
+ selfAgentForFlush,
1295
+ (m) => ipcServer.sendToAgent(selfAgentForFlush, m),
1296
+ inboundSpool,
1297
+ )
1298
+ if (fr.redelivered > 0) {
1299
+ process.stderr.write(
1300
+ `telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} ` +
1301
+ `held inbound for ${selfAgentForFlush}` +
1302
+ `${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
1303
+ )
1304
+ }
1305
+ }
1306
+
1261
1307
  if (pendingRestarts.size > 0) {
1262
1308
  for (const [agentName, _timestamp] of pendingRestarts.entries()) {
1263
1309
  triggerSelfRestart(agentName, 'turn-complete-pending-restart');
@@ -1321,8 +1367,35 @@ function maybeProactiveCompact(): void {
1321
1367
  const t = turns[0];
1322
1368
  const occupancy = t.input + t.cacheRead + t.cacheCreate;
1323
1369
 
1370
+ const wasArmed = compactState.armed;
1324
1371
  const decision = decideProactiveCompact(compactState, occupancy, cap);
1325
1372
  compactState = decision.state;
1373
+ // Re-arm edge: the decider only flips disarmed→armed when post-
1374
+ // /compact occupancy fell below 0.6×cap — i.e. context verifiably
1375
+ // shrank. That edge is the honest "compaction finished" signal.
1376
+ const rearmed = !wasArmed && decision.state.armed;
1377
+
1378
+ // User-facing notification transitions (pure). Resolved synchronously
1379
+ // here — before any await and before the !fire return — so a
1380
+ // re-entrant purge pass can't double-post (compactState was already
1381
+ // persisted/disarmed above). All card I/O is reached only past the
1382
+ // `cap == null` opt-in return earlier in this function, so a fleet
1383
+ // with the feature off never posts anything (Defaults preserved).
1384
+ const nt = nextCompactNotify(compactNotifyState, {
1385
+ fired: decision.fire,
1386
+ rearmed,
1387
+ activeFile: file,
1388
+ });
1389
+ compactNotifyState = nt.state;
1390
+ if (nt.action === 'finish') {
1391
+ void resolveCompactCard('finished', occupancy);
1392
+ } else if (nt.action === 'start' || nt.action === 'start-superseding') {
1393
+ if (nt.action === 'start-superseding') {
1394
+ void resolveCompactCard('superseded', occupancy);
1395
+ }
1396
+ void postCompactCard(occupancy, cap);
1397
+ }
1398
+
1326
1399
  if (!decision.fire) return;
1327
1400
 
1328
1401
  // Set the re-entrancy guard synchronously BEFORE the await so a
@@ -1345,6 +1418,109 @@ function maybeProactiveCompact(): void {
1345
1418
  });
1346
1419
  }
1347
1420
 
1421
+ /**
1422
+ * Post the START card for a proactive compaction. Best-effort: a failed
1423
+ * send just means no card (the compaction itself still happens). The
1424
+ * outstanding record + a wall-clock timeout are armed so the card can
1425
+ * never dangle if the re-arm edge never arrives (failed/idle case).
1426
+ */
1427
+ async function postCompactCard(occ: number, cap: number): Promise<void> {
1428
+ try {
1429
+ const chatId = loadAccess().allowFrom[0];
1430
+ if (!chatId) return;
1431
+ const threadId = chatThreadMap.get(chatId);
1432
+ const text =
1433
+ `🗜️ <b>Context compaction</b>\n` +
1434
+ `Working context hit ~${occ.toLocaleString()} tokens ` +
1435
+ `(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
1436
+ `Older detail moves to Hindsight; I'll confirm here once the ` +
1437
+ `context has shrunk (may take a turn or two).`;
1438
+ const sent = await swallowingApiCall(
1439
+ () =>
1440
+ bot.api.sendMessage(chatId, text, {
1441
+ parse_mode: 'HTML',
1442
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
1443
+ }),
1444
+ { chat_id: chatId, verb: 'proactiveCompact.start' },
1445
+ );
1446
+ const messageId = (sent as { message_id?: number } | undefined)
1447
+ ?.message_id;
1448
+ if (typeof messageId !== 'number') return;
1449
+ const timer = setTimeout(() => {
1450
+ void resolveCompactCard('timeout', null);
1451
+ }, COMPACT_CARD_TIMEOUT_MS);
1452
+ timer.unref?.();
1453
+ outstandingCompactCard = {
1454
+ chatId,
1455
+ threadId,
1456
+ messageId,
1457
+ occAtStart: occ,
1458
+ capAtStart: cap,
1459
+ timer,
1460
+ };
1461
+ } catch (err) {
1462
+ process.stderr.write(
1463
+ `telegram gateway: proactive-compact start card failed: ` +
1464
+ `${err instanceof Error ? err.message : String(err)}\n`,
1465
+ );
1466
+ }
1467
+ }
1468
+
1469
+ /**
1470
+ * Resolve the outstanding START card to a terminal state by editing it
1471
+ * in place. `finished` is driven by the decider re-arm edge (context
1472
+ * verifiably shrank), `superseded` when a newer compaction starts first,
1473
+ * `timeout` by the wall-clock timer (re-arm never arrived). The
1474
+ * outstanding record + timer are cleared synchronously BEFORE the await
1475
+ * so a stale message_id can never be edited twice and the timer can't
1476
+ * double-fire. On `timeout` the pure notify state is also reset so a
1477
+ * future compaction starts a clean lifecycle.
1478
+ */
1479
+ async function resolveCompactCard(
1480
+ kind: 'finished' | 'superseded' | 'timeout',
1481
+ occNow: number | null,
1482
+ ): Promise<void> {
1483
+ const card = outstandingCompactCard;
1484
+ if (!card) return;
1485
+ outstandingCompactCard = null;
1486
+ clearTimeout(card.timer);
1487
+ if (kind === 'timeout') compactNotifyState = idleCompactNotifyState();
1488
+ let text: string;
1489
+ if (kind === 'finished') {
1490
+ text =
1491
+ `✅ <b>Context compacted</b>\n` +
1492
+ `Working context reduced` +
1493
+ (occNow != null
1494
+ ? ` (~${card.occAtStart.toLocaleString()} → ` +
1495
+ `~${occNow.toLocaleString()} tokens)`
1496
+ : '') +
1497
+ `. Hindsight retains the detail.`;
1498
+ } else if (kind === 'superseded') {
1499
+ text =
1500
+ `↩️ <b>Context compaction superseded</b>\n` +
1501
+ `A newer compaction started before this one confirmed.`;
1502
+ } else {
1503
+ text =
1504
+ `⚠️ <b>Compaction issued</b>\n` +
1505
+ `<code>/compact</code> was requested but the context isn't ` +
1506
+ `confirmed reduced yet. Native compaction and Hindsight still apply.`;
1507
+ }
1508
+ try {
1509
+ await swallowingApiCall(
1510
+ () =>
1511
+ bot.api.editMessageText(card.chatId, card.messageId, text, {
1512
+ parse_mode: 'HTML',
1513
+ }),
1514
+ { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
1515
+ );
1516
+ } catch (err) {
1517
+ process.stderr.write(
1518
+ `telegram gateway: proactive-compact ${kind} card edit failed: ` +
1519
+ `${err instanceof Error ? err.message : String(err)}\n`,
1520
+ );
1521
+ }
1522
+ }
1523
+
1348
1524
  function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
1349
1525
  const key = statusKey(chatId, threadId)
1350
1526
  const ctrl = activeStatusReactions.get(key)
@@ -2861,6 +3037,7 @@ silencePoke.startTimer({
2861
3037
  pendingInboundBuffer,
2862
3038
  fbSelfAgent,
2863
3039
  (m) => ipcServer.sendToAgent(fbSelfAgent, m),
3040
+ inboundSpool,
2864
3041
  )
2865
3042
  process.stderr.write(
2866
3043
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
@@ -2879,7 +3056,42 @@ silencePoke.startTimer({
2879
3056
  // vault_request_access card during the 100ms bridge-reconnect window
2880
3057
  // would mint the grant but silently drop the `vault_grant_approved`
2881
3058
  // inbound, leaving the agent stuck waiting for a manual poke.
2882
- const pendingInboundBuffer = createPendingInboundBuffer()
3059
+ // Durable inbound spool on the persistent per-agent volume
3060
+ // (STATE_DIR = /state/agent/telegram in prod — survives container
3061
+ // recreate). Makes the "⏳ your message is queued and will be
3062
+ // processed when it reconnects" promise deterministic across a
3063
+ // gateway/container restart (finn/carrie lost-on-restart incident,
3064
+ // 2026-05-19). STATIC mode has no runtime/bridge, so no spool.
3065
+ const inboundSpool = STATIC
3066
+ ? undefined
3067
+ : createInboundSpool({
3068
+ path: join(STATE_DIR, 'inbound-spool.jsonl'),
3069
+ fs: {
3070
+ appendFileSync: (p, d) => appendFileSync(p, d),
3071
+ readFileSync: (p) => readFileSync(p, 'utf8'),
3072
+ writeFileSync: (p, d) => writeFileSync(p, d),
3073
+ renameSync: (a, b) => renameSync(a, b),
3074
+ existsSync: (p) => existsSync(p),
3075
+ statSizeSync: (p) => statSync(p).size,
3076
+ },
3077
+ })
3078
+ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
3079
+ // Boot-replay: re-queue every un-acked spooled inbound into the
3080
+ // in-memory buffer so the existing drain triggers (onClientRegistered
3081
+ // / silence-poke #1546 / idle-drain #1549) deliver them. push →
3082
+ // spool.put dedups on the already-live id, so this re-push does NOT
3083
+ // double-append. This is what makes a queued message survive a
3084
+ // restart instead of being silently lost.
3085
+ if (inboundSpool != null) {
3086
+ const replay = inboundSpool.liveEntries()
3087
+ for (const e of replay) pendingInboundBuffer.push(e.agent, e.msg)
3088
+ if (replay.length > 0) {
3089
+ process.stderr.write(
3090
+ `telegram gateway: inbound-spool boot-replay re-queued ${replay.length} ` +
3091
+ `un-acked inbound (durable-queue, survives restart)\n`,
3092
+ )
3093
+ }
3094
+ }
2883
3095
  const pendingPermissionBuffer = createPendingPermissionBuffer()
2884
3096
 
2885
3097
  /**
@@ -2930,6 +3142,12 @@ const ipcServer: IpcServer = createIpcServer({
2930
3142
  for (const msg of pending) {
2931
3143
  try {
2932
3144
  client.send(msg)
3145
+ // Confirmed delivery to the just-registered live bridge →
3146
+ // tombstone the durable spool entry so it isn't boot-replayed
3147
+ // again. A throw below leaves it spooled (un-acked) so the
3148
+ // idle-drain / escalation path still recovers it — strictly
3149
+ // safer than the old log-and-drop.
3150
+ inboundSpool?.ack(msg)
2933
3151
  } catch (err) {
2934
3152
  process.stderr.write(
2935
3153
  `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
@@ -3392,12 +3610,17 @@ const ipcServer: IpcServer = createIpcServer({
3392
3610
  //
3393
3611
  // This is the third drain trigger. It's gated to be zero-cost and
3394
3612
  // zero-churn: skip entirely when nothing is buffered (one Map.get, no
3395
- // log) or when the bridge isn't alive (exactly sendToAgent's own
3396
- // guard — so we never drain into a dead bridge and re-buffer/log-spin).
3397
- // Only when there IS a buffered message AND a live bridge do we reuse
3398
- // the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
3399
- // per-message miss). A message delivered while a turn is active is
3400
- // queued normally by the bridge same as a live arrival, not lost.
3613
+ // log), when the bridge isn't alive (exactly sendToAgent's own guard —
3614
+ // so we never drain into a dead bridge and re-buffer/log-spin), OR
3615
+ // when a turn is in flight. The turn gate is #1556: a message
3616
+ // delivered while a turn is active is NOT safely queued by the bridge
3617
+ // claude types it into its TUI composer and the auto-submit races
3618
+ // turn-completion, stranding it (the lawgpt wedge). Draining only at
3619
+ // `activeTurnStartedAt.size === 0` guarantees the channel notification
3620
+ // lands at an idle prompt and submits as a fresh turn. Only when there
3621
+ // IS a buffered message AND a live bridge AND no active turn do we
3622
+ // reuse the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
3623
+ // per-message miss).
3401
3624
  const IDLE_DRAIN_INTERVAL_MS = 5000
3402
3625
  if (!STATIC) {
3403
3626
  setInterval(() => {
@@ -3406,10 +3629,14 @@ if (!STATIC) {
3406
3629
  pendingInboundBuffer,
3407
3630
  selfAgent,
3408
3631
  () => {
3632
+ // #1556: never drain mid-turn — that re-creates the composer
3633
+ // wedge this buffer exists to prevent.
3634
+ if (activeTurnStartedAt.size > 0) return false
3409
3635
  const c = ipcServer.getClient(selfAgent)
3410
3636
  return c != null && c.isAlive()
3411
3637
  },
3412
3638
  (m) => ipcServer.sendToAgent(selfAgent, m),
3639
+ inboundSpool,
3413
3640
  )
3414
3641
  if (r != null && r.redelivered > 0) {
3415
3642
  process.stderr.write(
@@ -3418,6 +3645,28 @@ if (!STATIC) {
3418
3645
  `${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ''}\n`,
3419
3646
  )
3420
3647
  }
3648
+ // Bounded escalation: a spooled inbound still un-acked past its
3649
+ // bound (default 15 min — well past the 5-min silence-poke ladder)
3650
+ // is undeliverable in practice. Retract the "will be processed"
3651
+ // promise EXPLICITLY (honest failure) instead of letting it sit
3652
+ // forever. This is what makes the guarantee deterministic: every
3653
+ // queued message ends either delivered or visibly retracted.
3654
+ inboundSpool?.sweepEscalations((e) => {
3655
+ const chat = e.msg.chatId
3656
+ const threadOpts =
3657
+ typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
3658
+ ? { message_thread_id: Number(e.msg.meta.threadId) }
3659
+ : {}
3660
+ void swallowingApiCall(
3661
+ () =>
3662
+ bot.api.sendMessage(
3663
+ chat,
3664
+ "⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.",
3665
+ { ...threadOpts },
3666
+ ),
3667
+ { chat_id: chat, verb: 'inbound-spool-escalation' },
3668
+ )
3669
+ })
3421
3670
  }, IDLE_DRAIN_INTERVAL_MS).unref()
3422
3671
  }
3423
3672
 
@@ -7227,6 +7476,29 @@ async function handleInbound(
7227
7476
  // push to pendingInboundBuffer, which onClientRegistered drains on
7228
7477
  // the next bridge register — so the notice below is now truthful.
7229
7478
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
7479
+
7480
+ // #1556: turn-gated delivery. A non-steering inbound that arrives
7481
+ // mid-turn must NOT be sent to the bridge now — claude would type it
7482
+ // into its TUI composer and the auto-submit races turn-completion,
7483
+ // stranding the message (the lawgpt wedge, 2026-05-19). Buffer it;
7484
+ // `purgeReactionTracking`'s turn-complete hook and the turn-gated
7485
+ // idle-drain flush it the instant claude goes idle, where the channel
7486
+ // notification submits cleanly as a fresh turn. Steering messages are
7487
+ // exempt — reaching claude mid-turn is the whole point of /steer.
7488
+ if (
7489
+ decideInboundDelivery({
7490
+ turnInFlight: activeTurnStartedAt.size > 0,
7491
+ isSteering,
7492
+ }) === 'buffer-until-idle'
7493
+ ) {
7494
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
7495
+ process.stderr.write(
7496
+ `telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
7497
+ `chat=${chat_id} msg=${msgId ?? '-'} — will flush on turn-complete\n`,
7498
+ )
7499
+ return
7500
+ }
7501
+
7230
7502
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
7231
7503
  if (!delivered) {
7232
7504
  pendingInboundBuffer.push(selfAgent, inboundMsg)
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Inbound delivery gate (#1556 — the lawgpt composer-wedge).
3
+ *
4
+ * Pure decision: given the live turn state, should a freshly-received
5
+ * Telegram inbound be delivered to the bridge *now*, or held in the
6
+ * pending-inbound buffer until claude is idle?
7
+ *
8
+ * ## Why this exists
9
+ *
10
+ * The gateway used to `ipcServer.sendToAgent(inbound)` unconditionally,
11
+ * buffering ONLY when the bridge was offline. The load-bearing (and
12
+ * false) assumption — stated verbatim in three places before this fix
13
+ * (`pending-inbound-buffer.ts`, the idle-drain comment, and the
14
+ * implicit unconditional send) — was:
15
+ *
16
+ * "a message delivered while a turn is active is queued normally by
17
+ * the bridge, same as a live arrival, not lost."
18
+ *
19
+ * It is not. The bridge converts an inbound into an MCP
20
+ * `notifications/claude/channel` notification (`bridge.ts:onInbound`).
21
+ * When claude receives that notification mid-turn, the unmodified CLI
22
+ * types the text into its TUI composer and relies on an auto-submit
23
+ * once the turn ends. That submit races turn-completion and frequently
24
+ * does not fire — the message strands in the composer, claude sits at
25
+ * an idle prompt with the user's instruction un-actioned, and nothing
26
+ * self-heals it (the turn-active watchdog only catches *in-turn* hangs;
27
+ * this is *between-turns*-with-undelivered-input, which reads as
28
+ * healthy idle). Observed live: agent `lawgpt`, 2026-05-19 — a
29
+ * follow-up message sat unsubmitted indefinitely; only a restart
30
+ * cleared it, and the restart *lost* the message.
31
+ *
32
+ * ## The deterministic guarantee
33
+ *
34
+ * A non-steering inbound on the Telegram `handleInbound` path is
35
+ * delivered to the bridge ONLY when no turn is in flight. The channel
36
+ * notification therefore always lands at an idle claude prompt, where
37
+ * it submits cleanly as a fresh turn. It can be *delayed* (until the
38
+ * current turn completes) but can never strand in the composer. The
39
+ * turn-complete hook (`purgeReactionTracking`) and the turn-gated
40
+ * idle-drain timer flush the buffer the instant
41
+ * `activeTurnStartedAt.size === 0`.
42
+ *
43
+ * Scope: this gates the Telegram `handleInbound` path only — the one
44
+ * the lawgpt wedge hit. The `inject_inbound` IPC path (cron / synthetic
45
+ * operator wakeups) reaches the bridge directly and is deliberately
46
+ * NOT gated here: cron fires carry at-least-once replay semantics and
47
+ * their delivery contract is a separate product decision, out of scope
48
+ * for this bug.
49
+ *
50
+ * ## Steering is deliberately exempt
51
+ *
52
+ * An explicit `/steer` (`/s`) message is *meant* to reach claude
53
+ * mid-turn — that is the whole point of the steering feature (redirect
54
+ * the agent while it works). Steering messages keep immediate delivery.
55
+ * The wedge only ever affected the queued-mid-turn default path.
56
+ */
57
+
58
+ export interface InboundDeliveryGateInput {
59
+ /** A turn is in flight RIGHT NOW (live: `activeTurnStartedAt.size > 0`),
60
+ * evaluated at delivery time — not a receipt-time snapshot, so a turn
61
+ * that completed between receipt and here correctly reads as idle. */
62
+ turnInFlight: boolean
63
+ /** This inbound carried an explicit `/steer` (`/s`) prefix and is an
64
+ * intentional mid-turn redirect. */
65
+ isSteering: boolean
66
+ }
67
+
68
+ export type InboundDeliveryDecision =
69
+ /** Send to the bridge now (idle prompt, or an intentional steer). */
70
+ | 'deliver'
71
+ /** Hold in the pending-inbound buffer; the turn-complete hook /
72
+ * turn-gated idle-drain flushes it when claude goes idle. */
73
+ | 'buffer-until-idle'
74
+
75
+ /**
76
+ * Pure. The ONLY condition that defers delivery is "a turn is in flight
77
+ * AND this is not a steering message". Everything else delivers
78
+ * immediately (idle → submits at once; steering → intentional mid-turn).
79
+ */
80
+ export function decideInboundDelivery(
81
+ input: InboundDeliveryGateInput,
82
+ ): InboundDeliveryDecision {
83
+ if (input.turnInFlight && !input.isSteering) return 'buffer-until-idle'
84
+ return 'deliver'
85
+ }