switchroom 0.14.47 → 0.14.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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.47";
49466
- var COMMIT_SHA = "fbd2e491";
49465
+ var VERSION = "0.14.48";
49466
+ var COMMIT_SHA = "a6517652";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.47",
3
+ "version": "0.14.48",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46927,7 +46927,7 @@ function escapeHtml7(s) {
46927
46927
 
46928
46928
  // gateway/pending-inbound-buffer.ts
46929
46929
  var DEFAULT_PENDING_INBOUND_CAP = 32;
46930
- function redeliverBufferedInbound(buffer, agent, send, spool) {
46930
+ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
46931
46931
  const pending = buffer.drain(agent);
46932
46932
  let redelivered = 0;
46933
46933
  let rebuffered = 0;
@@ -46942,6 +46942,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
46942
46942
  for (const o of originals)
46943
46943
  spool?.ack(o);
46944
46944
  redelivered += originals.length;
46945
+ onDelivered?.(merged, originals);
46945
46946
  } else {
46946
46947
  for (const o of originals)
46947
46948
  buffer.push(agent, o);
@@ -47004,14 +47005,14 @@ function mergeRun(run2) {
47004
47005
  merged.attachment = mediaEntry.attachment;
47005
47006
  return merged;
47006
47007
  }
47007
- function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
47008
+ function idleDrainTick(buffer, agent, isBridgeAlive, send, spool, onDelivered) {
47008
47009
  if (!agent)
47009
47010
  return null;
47010
47011
  if (buffer.depth(agent) === 0)
47011
47012
  return null;
47012
47013
  if (!isBridgeAlive())
47013
47014
  return null;
47014
- return redeliverBufferedInbound(buffer, agent, send, spool);
47015
+ return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered);
47015
47016
  }
47016
47017
  function createPendingInboundBuffer(opts = {}) {
47017
47018
  const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
@@ -47618,7 +47619,7 @@ function formatEventDetail(event) {
47618
47619
  }
47619
47620
 
47620
47621
  // gateway/pending-inbound-buffer.ts
47621
- function redeliverBufferedInbound2(buffer, agent, send, spool) {
47622
+ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
47622
47623
  const pending = buffer.drain(agent);
47623
47624
  let redelivered = 0;
47624
47625
  let rebuffered = 0;
@@ -47633,6 +47634,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
47633
47634
  for (const o of originals)
47634
47635
  spool?.ack(o);
47635
47636
  redelivered += originals.length;
47637
+ onDelivered?.(merged, originals);
47636
47638
  } else {
47637
47639
  for (const o of originals)
47638
47640
  buffer.push(agent, o);
@@ -47725,7 +47727,7 @@ function dispatchOne(effect, ctx) {
47725
47727
  }
47726
47728
  return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg);
47727
47729
  };
47728
- const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined);
47730
+ const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined, ctx.onUserInboundDelivered ? (merged) => ctx.onUserInboundDelivered(merged) : undefined);
47729
47731
  if (result.drained > 0) {
47730
47732
  log(`telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` + `drained=${result.drained} redelivered=${result.redelivered} ` + `rebuffered=${result.rebuffered}
47731
47733
  `);
@@ -52097,10 +52099,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52097
52099
  }
52098
52100
 
52099
52101
  // ../src/build-info.ts
52100
- var VERSION = "0.14.47";
52101
- var COMMIT_SHA = "fbd2e491";
52102
- var COMMIT_DATE = "2026-06-03T07:22:07Z";
52103
- var LATEST_PR = 2119;
52102
+ var VERSION = "0.14.48";
52103
+ var COMMIT_SHA = "a6517652";
52104
+ var COMMIT_DATE = "2026-06-03T07:57:29Z";
52105
+ var LATEST_PR = 2120;
52104
52106
  var COMMITS_AHEAD_OF_TAG = 0;
52105
52107
 
52106
52108
  // gateway/boot-version.ts
@@ -52665,14 +52667,20 @@ function buildResumeWatchdogReportInbound(ctx) {
52665
52667
  meta
52666
52668
  };
52667
52669
  }
52668
- function selectResumeBuilder(endedVia) {
52670
+ function selectResumeBuilder(endedVia, opts) {
52671
+ let kind;
52669
52672
  if (endedVia === "timeout")
52673
+ kind = "report";
52674
+ else if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
52675
+ kind = "resume";
52676
+ else if (endedVia == null)
52677
+ kind = "resume";
52678
+ else
52679
+ kind = null;
52680
+ if (kind === "resume" && opts?.ageMs != null && opts?.maxAgeMs != null && opts.ageMs > opts.maxAgeMs) {
52670
52681
  return "report";
52671
- if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
52672
- return "resume";
52673
- if (endedVia == null)
52674
- return "resume";
52675
- return null;
52682
+ }
52683
+ return kind;
52676
52684
  }
52677
52685
 
52678
52686
  // registry/subagents-schema.ts
@@ -53123,7 +53131,14 @@ try {
53123
53131
  const pending2 = findLatestTurnIfInterrupted(turnsDb);
53124
53132
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
53125
53133
  if (pending2 != null && selfAgent) {
53126
- const kind = selectResumeBuilder(pending2.ended_via);
53134
+ const RESUME_MAX_AGE_MS = (() => {
53135
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
53136
+ return Number.isFinite(v) && v > 0 ? v : 10800000;
53137
+ })();
53138
+ const kind = selectResumeBuilder(pending2.ended_via, {
53139
+ ageMs: Math.max(0, Date.now() - pending2.started_at),
53140
+ maxAgeMs: RESUME_MAX_AGE_MS
53141
+ });
53127
53142
  if (kind === "resume") {
53128
53143
  bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending2 }) };
53129
53144
  } else if (kind === "report") {
@@ -53390,7 +53405,7 @@ function purgeReactionTracking(key, endingTurn) {
53390
53405
  if (d)
53391
53406
  markClaudeBusyForInbound(m);
53392
53407
  return d;
53393
- }, inboundSpool);
53408
+ }, inboundSpool, trackRedeliveredInbound);
53394
53409
  if (fr.redelivered > 0) {
53395
53410
  process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53396
53411
  `);
@@ -53420,7 +53435,7 @@ function releaseTurnBufferGate(key) {
53420
53435
  if (d)
53421
53436
  markClaudeBusyForInbound(m);
53422
53437
  return d;
53423
- }, inboundSpool);
53438
+ }, inboundSpool, trackRedeliveredInbound);
53424
53439
  if (fr.redelivered > 0) {
53425
53440
  process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53426
53441
  `);
@@ -54406,7 +54421,7 @@ startTimer({
54406
54421
  if (d)
54407
54422
  markClaudeBusyForInbound(m);
54408
54423
  return d;
54409
- }, inboundSpool);
54424
+ }, inboundSpool, trackRedeliveredInbound);
54410
54425
  process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
54411
54426
  `);
54412
54427
  }
@@ -54416,6 +54431,20 @@ var _deliveryMachineTick = setInterval(() => {
54416
54431
  shadowEmit({ kind: "tick", now: Date.now() });
54417
54432
  }, DELIVERY_MACHINE_TICK_MS);
54418
54433
  _deliveryMachineTick.unref?.();
54434
+ function trackRedeliveredInbound(merged) {
54435
+ if (!DELIVERY_CONFIRM_ENABLED)
54436
+ return;
54437
+ if (!shouldTrackDelivery({
54438
+ isSteering: false,
54439
+ isInterrupt: false,
54440
+ hasSource: merged.meta?.source != null,
54441
+ effectiveText: merged.text
54442
+ })) {
54443
+ return;
54444
+ }
54445
+ const key = chatKey2(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null);
54446
+ trackDelivery(deliveryQueue, key, merged, Date.now(), merged.messageId != null ? String(merged.messageId) : null);
54447
+ }
54419
54448
  async function redeliverStrandedInbound(p) {
54420
54449
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
54421
54450
  process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
@@ -54522,7 +54551,8 @@ var ipcServer = createIpcServer({
54522
54551
  pendingInboundBuffer,
54523
54552
  inboundSpool: inboundSpool ?? null,
54524
54553
  pendingPermissionBuffer,
54525
- client: client3
54554
+ client: client3,
54555
+ onUserInboundDelivered: trackRedeliveredInbound
54526
54556
  });
54527
54557
  } else {
54528
54558
  const pending2 = pendingInboundBuffer.drain(client3.agentName);
@@ -54530,6 +54560,7 @@ var ipcServer = createIpcServer({
54530
54560
  try {
54531
54561
  client3.send(msg);
54532
54562
  inboundSpool?.ack(msg);
54563
+ trackRedeliveredInbound(msg);
54533
54564
  } catch (err) {
54534
54565
  process.stderr.write(`telegram gateway: pending-inbound drain failed agent=${client3.agentName} source=${msg.meta?.source ?? "-"}: ${err.message}
54535
54566
  `);
@@ -55085,7 +55116,7 @@ if (!STATIC) {
55085
55116
  if (d)
55086
55117
  markClaudeBusyForInbound(m);
55087
55118
  return d;
55088
- }, inboundSpool);
55119
+ }, inboundSpool, trackRedeliveredInbound);
55089
55120
  if (r != null && r.redelivered > 0) {
55090
55121
  process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
55091
55122
  `);
@@ -1064,7 +1064,19 @@ try {
1064
1064
  const pending = findLatestTurnIfInterrupted(turnsDb)
1065
1065
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1066
1066
  if (pending != null && selfAgent) {
1067
- const kind = selectResumeBuilder(pending.ended_via)
1067
+ // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1068
+ // interrupted work older than RESUME_MAX_AGE_MS — selectResumeBuilder
1069
+ // downgrades a stale 'resume' to the passive 'report' so the user is told
1070
+ // ("I was working on X ~Nh ago") but nothing replays unprompted. Env
1071
+ // override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1072
+ const RESUME_MAX_AGE_MS = (() => {
1073
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1074
+ return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1075
+ })()
1076
+ const kind = selectResumeBuilder(pending.ended_via, {
1077
+ ageMs: Math.max(0, Date.now() - pending.started_at),
1078
+ maxAgeMs: RESUME_MAX_AGE_MS,
1079
+ })
1068
1080
  if (kind === 'resume') {
1069
1081
  bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1070
1082
  } else if (kind === 'report') {
@@ -1801,6 +1813,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1801
1813
  return d
1802
1814
  },
1803
1815
  inboundSpool,
1816
+ trackRedeliveredInbound,
1804
1817
  )
1805
1818
  if (fr.redelivered > 0) {
1806
1819
  process.stderr.write(
@@ -1896,6 +1909,7 @@ function releaseTurnBufferGate(key: string): void {
1896
1909
  return d
1897
1910
  },
1898
1911
  inboundSpool,
1912
+ trackRedeliveredInbound,
1899
1913
  )
1900
1914
  if (fr.redelivered > 0) {
1901
1915
  process.stderr.write(
@@ -4134,6 +4148,7 @@ silencePoke.startTimer({
4134
4148
  return d
4135
4149
  },
4136
4150
  inboundSpool,
4151
+ trackRedeliveredInbound,
4137
4152
  )
4138
4153
  process.stderr.write(
4139
4154
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
@@ -4163,6 +4178,45 @@ const _deliveryMachineTick = setInterval(() => {
4163
4178
  }, DELIVERY_MACHINE_TICK_MS)
4164
4179
  _deliveryMachineTick.unref?.()
4165
4180
 
4181
+ // Enrol a buffer-redelivered inbound in the deliver-until-acked queue so the
4182
+ // existing sweep re-delivers it until claude's `enqueue` ack lands. Wired into
4183
+ // EVERY redelivery path (bridgeUp drain, silence-poke fallback, flap/reply-gate
4184
+ // flushes) — `send` returning true only means the bytes reached the bridge, NOT
4185
+ // that claude consumed them. Right after a restart (esp. a slow MCP boot) the
4186
+ // inject can hit a not-ready session and be silently dropped, and nothing
4187
+ // retried it: the clerk 2026-06-03 lost-message incident. Mirrors the
4188
+ // live-delivery tracking at the handleInbound site (chatKey + messageId), so
4189
+ // DMs and supergroup forum topics are handled identically. Only real user
4190
+ // inbounds are tracked — shouldTrackDelivery excludes steer/interrupt/
4191
+ // synthetic-source/empty, which never produce an `enqueue` and would otherwise
4192
+ // re-deliver forever.
4193
+ function trackRedeliveredInbound(merged: InboundMessage): void {
4194
+ if (!DELIVERY_CONFIRM_ENABLED) return
4195
+ if (
4196
+ !shouldTrackDelivery({
4197
+ isSteering: false,
4198
+ isInterrupt: false,
4199
+ // Synthetic inbounds (cron / vault / handback / resume) carry a source
4200
+ // and are NOT tracked here — they enqueue under their own semantics, and
4201
+ // (for the resume synthetics) tracking them safely first needs the
4202
+ // resume builder to emit meta.message_id so the deliver-until-acked ack
4203
+ // matches its enqueue. Tracked separately as a follow-up (see PR notes).
4204
+ hasSource: merged.meta?.source != null,
4205
+ effectiveText: merged.text,
4206
+ })
4207
+ ) {
4208
+ return
4209
+ }
4210
+ const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
4211
+ trackDelivery(
4212
+ deliveryQueue,
4213
+ key,
4214
+ merged,
4215
+ Date.now(),
4216
+ merged.messageId != null ? String(merged.messageId) : null,
4217
+ )
4218
+ }
4219
+
4166
4220
  // Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
4167
4221
  // Every few seconds, re-send any inbound that was handed to claude but never
4168
4222
  // acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
@@ -4400,6 +4454,11 @@ const ipcServer: IpcServer = createIpcServer({
4400
4454
  inboundSpool: inboundSpool ?? null,
4401
4455
  pendingPermissionBuffer,
4402
4456
  client,
4457
+ // Enrol each drained user inbound in the deliver-until-acked queue
4458
+ // so the 5s sweep re-delivers until claude's `enqueue` ack lands —
4459
+ // a socket-write into a still-booting session is NOT consumption
4460
+ // (clerk lost-message incident, 2026-06-03).
4461
+ onUserInboundDelivered: trackRedeliveredInbound,
4403
4462
  })
4404
4463
  } else {
4405
4464
  // Kill-switch fallback: imperative drain (parity with pre-cutover
@@ -4410,6 +4469,10 @@ const ipcServer: IpcServer = createIpcServer({
4410
4469
  try {
4411
4470
  client.send(msg)
4412
4471
  inboundSpool?.ack(msg)
4472
+ // Same enrol as the cutover drain path: a socket-write success is
4473
+ // not proof claude consumed it — enrol so the sweep re-delivers
4474
+ // until `enqueue` (clerk lost-message incident, 2026-06-03).
4475
+ trackRedeliveredInbound(msg)
4413
4476
  } catch (err) {
4414
4477
  process.stderr.write(
4415
4478
  `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
@@ -5318,6 +5381,7 @@ if (!STATIC) {
5318
5381
  return d
5319
5382
  },
5320
5383
  inboundSpool,
5384
+ trackRedeliveredInbound,
5321
5385
  )
5322
5386
  if (r != null && r.redelivered > 0) {
5323
5387
  process.stderr.write(
@@ -45,6 +45,15 @@ export interface DispatchCtx {
45
45
  readonly client?: IpcClient
46
46
  /** Optional log sink — default stderr. Test hook. */
47
47
  readonly log?: (line: string) => void
48
+ /**
49
+ * Optional: enrol a drained+redelivered inbound in the deliver-until-acked
50
+ * queue. The bridgeUp drain's socket-write "success" is NOT proof claude
51
+ * consumed the message — right after a restart (esp. with a slow MCP boot)
52
+ * the inject can hit a not-ready session and be dropped. Wiring this makes
53
+ * the existing 5s sweep re-deliver until claude's `enqueue` ack lands.
54
+ * (clerk lost-message incident, 2026-06-03.)
55
+ */
56
+ readonly onUserInboundDelivered?: (merged: InboundMessage) => void
48
57
  }
49
58
 
50
59
  const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
@@ -103,6 +112,9 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
103
112
  ctx.selfAgent,
104
113
  send,
105
114
  ctx.inboundSpool ?? undefined,
115
+ ctx.onUserInboundDelivered
116
+ ? (merged) => ctx.onUserInboundDelivered!(merged)
117
+ : undefined,
106
118
  )
107
119
  if (result.drained > 0) {
108
120
  log(
@@ -87,6 +87,14 @@ export function redeliverBufferedInbound(
87
87
  agent: string,
88
88
  send: (msg: InboundMessage) => boolean,
89
89
  spool?: InboundSpool,
90
+ // Called once per merged group on CONFIRMED delivery (after spool.ack).
91
+ // The caller uses it to enrol the redelivered inbound in the
92
+ // deliver-until-acked queue (`trackDelivery`) so it is re-sent until
93
+ // claude's `enqueue` ack lands — closing the restart boot-race where a
94
+ // socket-write "succeeds" into a not-ready session and the message is
95
+ // silently dropped (clerk 2026-06-03). `send` returning true only means
96
+ // the bytes reached the bridge, NOT that claude consumed them.
97
+ onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
90
98
  ): { drained: number; redelivered: number; rebuffered: number } {
91
99
  const pending = buffer.drain(agent)
92
100
  let redelivered = 0
@@ -110,6 +118,10 @@ export function redeliverBufferedInbound(
110
118
  // originals are, so we ack by original identity.
111
119
  for (const o of originals) spool?.ack(o)
112
120
  redelivered += originals.length
121
+ // Enrol in the deliver-until-acked queue (caller's hook). A bare
122
+ // socket-write success is NOT proof claude consumed it; the queue's
123
+ // sweep re-delivers until the `enqueue` ack lands.
124
+ onDelivered?.(merged, originals)
113
125
  } else {
114
126
  // Re-buffer the originals (not the merged synthetic) so the spool
115
127
  // identity is preserved and the next drain re-merges them losslessly.
@@ -258,11 +270,15 @@ export function idleDrainTick(
258
270
  isBridgeAlive: () => boolean,
259
271
  send: (msg: InboundMessage) => boolean,
260
272
  spool?: InboundSpool,
273
+ // Forwarded to redeliverBufferedInbound so the post-flap-settle drain also
274
+ // enrols redelivered inbounds in the deliver-until-acked queue (parity with
275
+ // the bridgeUp drain — clerk lost-message incident, 2026-06-03).
276
+ onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
261
277
  ): { drained: number; redelivered: number; rebuffered: number } | null {
262
278
  if (!agent) return null
263
279
  if (buffer.depth(agent) === 0) return null
264
280
  if (!isBridgeAlive()) return null
265
- return redeliverBufferedInbound(buffer, agent, send, spool)
281
+ return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered)
266
282
  }
267
283
 
268
284
  export function createPendingInboundBuffer(
@@ -172,9 +172,25 @@ export function buildResumeWatchdogReportInbound(
172
172
  */
173
173
  export function selectResumeBuilder(
174
174
  endedVia: TurnEndedVia | null,
175
+ // 3h staleness failsafe (operator spec, 2026-06-03): when the interrupted
176
+ // turn is older than `maxAgeMs`, an AUTO-resume is downgraded to the passive
177
+ // `report` — silently re-injecting hours-old work could act on long-stale
178
+ // context (a tax figure, a "send it" the user has moved on from). Pass both
179
+ // to enable; omit (default) keeps the legacy blanket-resume behaviour.
180
+ opts?: { ageMs?: number; maxAgeMs?: number },
175
181
  ): 'resume' | 'report' | null {
176
- if (endedVia === 'timeout') return 'report'
177
- if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') return 'resume'
178
- if (endedVia == null) return 'resume' // still-open at boot = killed mid-flight
179
- return null
182
+ let kind: 'resume' | 'report' | null
183
+ if (endedVia === 'timeout') kind = 'report'
184
+ else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
185
+ else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
186
+ else kind = null
187
+ if (
188
+ kind === 'resume' &&
189
+ opts?.ageMs != null &&
190
+ opts?.maxAgeMs != null &&
191
+ opts.ageMs > opts.maxAgeMs
192
+ ) {
193
+ return 'report' // too old to safely auto-resume — passive notice only
194
+ }
195
+ return kind
180
196
  }
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
220
220
  expect(calls).toBe(0)
221
221
  })
222
222
 
223
+ // onDelivered: the deliver-until-acked enrol hook (clerk lost-message
224
+ // incident 2026-06-03). A socket-write "success" is not proof claude
225
+ // consumed it; the caller uses onDelivered to enrol the redelivered inbound
226
+ // in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
227
+ it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
228
+ const buf = createPendingInboundBuffer({ log: () => {} })
229
+ buf.push('klanker', inbound('user', 1))
230
+ buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
231
+ const delivered: number[] = []
232
+ const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
233
+ delivered.push(merged.messageId as number)
234
+ })
235
+ expect(r.redelivered).toBe(2)
236
+ expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
237
+ })
238
+
239
+ it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
240
+ const buf = createPendingInboundBuffer({ log: () => {} })
241
+ buf.push('klanker', inbound('user', 1))
242
+ const delivered: number[] = []
243
+ const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
244
+ delivered.push(m.messageId as number),
245
+ )
246
+ expect(r.rebuffered).toBe(1)
247
+ expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
248
+ })
249
+
223
250
  it('only touches the named agent', () => {
224
251
  const buf = createPendingInboundBuffer({ log: () => {} })
225
252
  buf.push('klanker', inbound('user', 1))
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
179
179
  expect(selectResumeBuilder(endedVia)).toBe(expected)
180
180
  })
181
181
  }
182
+
183
+ // 3h staleness failsafe (operator spec, 2026-06-03).
184
+ const MAX = 10_800_000 // 3h
185
+ it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
186
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
187
+ expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
188
+ })
189
+ it('keeps resume when within maxAgeMs', () => {
190
+ expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
191
+ expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
192
+ })
193
+ it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
194
+ expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
195
+ expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
196
+ })
197
+ it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
198
+ expect(selectResumeBuilder('restart')).toBe('resume')
199
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
200
+ })
182
201
  })