switchroom 0.14.39 → 0.14.40

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.
@@ -25257,7 +25257,7 @@ function decodeResponse2(line) {
25257
25257
  }
25258
25258
  return ResponseSchema2.parse(parsed);
25259
25259
  }
25260
- var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
25260
+ var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
25261
25261
  var init_protocol2 = __esm(() => {
25262
25262
  init_zod();
25263
25263
  MAX_FRAME_BYTES2 = 64 * 1024;
@@ -25361,6 +25361,11 @@ var init_protocol2 = __esm(() => {
25361
25361
  op: exports_external.literal("list-google-accounts"),
25362
25362
  id: exports_external.string().min(1)
25363
25363
  });
25364
+ ListMicrosoftAccountsRequestSchema = exports_external.object({
25365
+ v: exports_external.literal(PROTOCOL_VERSION),
25366
+ op: exports_external.literal("list-microsoft-accounts"),
25367
+ id: exports_external.string().min(1)
25368
+ });
25364
25369
  ProbeQuotaRequestSchema = exports_external.object({
25365
25370
  v: exports_external.literal(PROTOCOL_VERSION),
25366
25371
  op: exports_external.literal("probe-quota"),
@@ -25378,6 +25383,7 @@ var init_protocol2 = __esm(() => {
25378
25383
  RmAccountRequestSchema,
25379
25384
  SetOverrideRequestSchema,
25380
25385
  ListGoogleAccountsRequestSchema,
25386
+ ListMicrosoftAccountsRequestSchema,
25381
25387
  ProbeQuotaRequestSchema
25382
25388
  ]);
25383
25389
  GetCredentialsDataSchema = exports_external.object({
@@ -25442,6 +25448,16 @@ var init_protocol2 = __esm(() => {
25442
25448
  ListGoogleAccountsDataSchema = exports_external.object({
25443
25449
  accounts: exports_external.array(GoogleAccountStateSchema)
25444
25450
  });
25451
+ MicrosoftAccountStateSchema = exports_external.object({
25452
+ account: exports_external.string(),
25453
+ expiresAt: exports_external.number(),
25454
+ scope: exports_external.string(),
25455
+ clientId: exports_external.string(),
25456
+ accountType: exports_external.enum(["personal", "work"])
25457
+ });
25458
+ ListMicrosoftAccountsDataSchema = exports_external.object({
25459
+ accounts: exports_external.array(MicrosoftAccountStateSchema)
25460
+ });
25445
25461
  ErrorBodySchema = exports_external.object({
25446
25462
  code: exports_external.enum([
25447
25463
  "FORBIDDEN",
@@ -25565,6 +25581,14 @@ class AuthBrokerClient {
25565
25581
  });
25566
25582
  return data;
25567
25583
  }
25584
+ async listMicrosoftAccounts() {
25585
+ const data = await this.send({
25586
+ v: PROTOCOL_VERSION,
25587
+ id: randomUUID(),
25588
+ op: "list-microsoft-accounts"
25589
+ });
25590
+ return data;
25591
+ }
25568
25592
  async probeQuota(accounts, timeoutMs) {
25569
25593
  const data = await this.send({
25570
25594
  v: PROTOCOL_VERSION,
@@ -49438,8 +49462,8 @@ var {
49438
49462
  } = import__.default;
49439
49463
 
49440
49464
  // src/build-info.ts
49441
- var VERSION = "0.14.39";
49442
- var COMMIT_SHA = "fb30b654";
49465
+ var VERSION = "0.14.40";
49466
+ var COMMIT_SHA = "d2d69140";
49443
49467
 
49444
49468
  // src/cli/agent.ts
49445
49469
  init_source();
@@ -57992,13 +58016,46 @@ function registerAccountRemove2(accountParent) {
57992
58016
  }));
57993
58017
  }
57994
58018
  function registerAccountList2(accountParent) {
57995
- accountParent.command("list").description("List Microsoft accounts the broker holds credentials for. Distinct from `auth microsoft list` (YAML ACL matrix).").option("--json", "Emit raw JSON").action(withConfigError(async (_opts) => {
58019
+ accountParent.command("list").description("List Microsoft accounts the broker holds credentials for. Distinct from `auth microsoft list` (YAML ACL matrix).").option("--json", "Emit raw JSON").action(withConfigError(async (opts) => {
58020
+ const { brokerCall: brokerCall2 } = await Promise.resolve().then(() => (init_broker_call(), exports_broker_call));
58021
+ const data = await brokerCall2(async (client) => client.listMicrosoftAccounts());
58022
+ if (opts.json) {
58023
+ console.log(JSON.stringify(data, null, 2));
58024
+ return;
58025
+ }
57996
58026
  console.log();
57997
- console.log(source_default.yellow(" Broker-side listing for Microsoft accounts is a follow-up (needs a list-microsoft-accounts wire op)."));
57998
- console.log(` For now, see the YAML matrix: ${source_default.bold("switchroom auth microsoft list")}`);
58027
+ if (data.accounts.length === 0) {
58028
+ console.log(source_default.gray(" No Microsoft accounts stored in broker."));
58029
+ console.log(` Add one: ${source_default.bold("switchroom auth microsoft account add <email>")}`);
58030
+ console.log();
58031
+ return;
58032
+ }
58033
+ const accountColWidth = Math.max(...data.accounts.map((a) => a.account.length), "ACCOUNT".length);
58034
+ const typeColWidth = "TYPE".length + 4;
58035
+ const expiresColWidth = "EXPIRES".length + 2;
58036
+ console.log(`${pad2("ACCOUNT", accountColWidth)} ${pad2("TYPE", typeColWidth)} ${pad2("EXPIRES", expiresColWidth)} SCOPE`);
58037
+ console.log(`${pad2("-".repeat(7), accountColWidth)} ${pad2("-".repeat(4), typeColWidth)} ${pad2("-".repeat(7), expiresColWidth)} ${"-".repeat(5)}`);
58038
+ const now = Date.now();
58039
+ for (const a of data.accounts) {
58040
+ const expiresLabel = formatMicrosoftExpiry(a.expiresAt - now);
58041
+ const scopes = a.scope.split(" ").filter((s) => s.length > 0 && !["openid", "profile", "email", "offline_access"].includes(s)).join(", ");
58042
+ console.log(`${pad2(a.account, accountColWidth)} ${pad2(a.accountType, typeColWidth)} ${pad2(expiresLabel, expiresColWidth)} ${scopes}`);
58043
+ }
57999
58044
  console.log();
58000
58045
  }));
58001
58046
  }
58047
+ function formatMicrosoftExpiry(remainingMs) {
58048
+ if (remainingMs <= 0)
58049
+ return source_default.red("expired");
58050
+ const minutes = Math.round(remainingMs / 60000);
58051
+ if (minutes < 60)
58052
+ return `${minutes}m`;
58053
+ const hours = Math.round(minutes / 60);
58054
+ if (hours < 48)
58055
+ return `${hours}h`;
58056
+ const days = Math.round(hours / 24);
58057
+ return `${days}d`;
58058
+ }
58002
58059
  function validateAndNormalizeAccountEmail2(account) {
58003
58060
  const normalized = account.trim().toLowerCase();
58004
58061
  if (!/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/.test(normalized)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.39",
3
+ "version": "0.14.40",
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": {
@@ -16160,7 +16160,7 @@ function decodeResponse(line) {
16160
16160
  }
16161
16161
  return ResponseSchema.parse(parsed);
16162
16162
  }
16163
- var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
16163
+ var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
16164
16164
  var init_protocol = __esm(() => {
16165
16165
  init_zod();
16166
16166
  MAX_FRAME_BYTES = 64 * 1024;
@@ -16264,6 +16264,11 @@ var init_protocol = __esm(() => {
16264
16264
  op: exports_external.literal("list-google-accounts"),
16265
16265
  id: exports_external.string().min(1)
16266
16266
  });
16267
+ ListMicrosoftAccountsRequestSchema = exports_external.object({
16268
+ v: exports_external.literal(PROTOCOL_VERSION),
16269
+ op: exports_external.literal("list-microsoft-accounts"),
16270
+ id: exports_external.string().min(1)
16271
+ });
16267
16272
  ProbeQuotaRequestSchema = exports_external.object({
16268
16273
  v: exports_external.literal(PROTOCOL_VERSION),
16269
16274
  op: exports_external.literal("probe-quota"),
@@ -16281,6 +16286,7 @@ var init_protocol = __esm(() => {
16281
16286
  RmAccountRequestSchema,
16282
16287
  SetOverrideRequestSchema,
16283
16288
  ListGoogleAccountsRequestSchema,
16289
+ ListMicrosoftAccountsRequestSchema,
16284
16290
  ProbeQuotaRequestSchema
16285
16291
  ]);
16286
16292
  GetCredentialsDataSchema = exports_external.object({
@@ -16345,6 +16351,16 @@ var init_protocol = __esm(() => {
16345
16351
  ListGoogleAccountsDataSchema = exports_external.object({
16346
16352
  accounts: exports_external.array(GoogleAccountStateSchema)
16347
16353
  });
16354
+ MicrosoftAccountStateSchema = exports_external.object({
16355
+ account: exports_external.string(),
16356
+ expiresAt: exports_external.number(),
16357
+ scope: exports_external.string(),
16358
+ clientId: exports_external.string(),
16359
+ accountType: exports_external.enum(["personal", "work"])
16360
+ });
16361
+ ListMicrosoftAccountsDataSchema = exports_external.object({
16362
+ accounts: exports_external.array(MicrosoftAccountStateSchema)
16363
+ });
16348
16364
  ErrorBodySchema = exports_external.object({
16349
16365
  code: exports_external.enum([
16350
16366
  "FORBIDDEN",
@@ -16468,6 +16484,14 @@ class AuthBrokerClient {
16468
16484
  });
16469
16485
  return data;
16470
16486
  }
16487
+ async listMicrosoftAccounts() {
16488
+ const data = await this.send({
16489
+ v: PROTOCOL_VERSION,
16490
+ id: randomUUID3(),
16491
+ op: "list-microsoft-accounts"
16492
+ });
16493
+ return data;
16494
+ }
16471
16495
  async probeQuota(accounts, timeoutMs) {
16472
16496
  const data = await this.send({
16473
16497
  v: PROTOCOL_VERSION,
@@ -40682,6 +40706,14 @@ class AuthBrokerClient2 {
40682
40706
  });
40683
40707
  return data;
40684
40708
  }
40709
+ async listMicrosoftAccounts() {
40710
+ const data = await this.send({
40711
+ v: PROTOCOL_VERSION,
40712
+ id: randomUUID4(),
40713
+ op: "list-microsoft-accounts"
40714
+ });
40715
+ return data;
40716
+ }
40685
40717
  async probeQuota(accounts, timeoutMs) {
40686
40718
  const data = await this.send({
40687
40719
  v: PROTOCOL_VERSION,
@@ -47227,6 +47259,30 @@ function decideInboundDelivery(input) {
47227
47259
  return "deliver";
47228
47260
  }
47229
47261
 
47262
+ // gateway/inbound-delivery-confirm.ts
47263
+ function createDeliveryQueue() {
47264
+ return { pending: new Map };
47265
+ }
47266
+ function trackDelivery(q, key, inbound, now) {
47267
+ q.pending.set(key, { key, inbound, lastAttemptAt: now });
47268
+ }
47269
+ function ackDelivery(q, key) {
47270
+ return q.pending.delete(key);
47271
+ }
47272
+ function sweep(q, now, timeoutMs) {
47273
+ const redeliver = [];
47274
+ for (const entry of q.pending.values()) {
47275
+ if (now - entry.lastAttemptAt < timeoutMs)
47276
+ continue;
47277
+ entry.lastAttemptAt = now;
47278
+ redeliver.push(entry);
47279
+ }
47280
+ return redeliver;
47281
+ }
47282
+ function forgetDelivery(q, key) {
47283
+ q.pending.delete(key);
47284
+ }
47285
+
47230
47286
  // gateway/pending-permission-decisions.ts
47231
47287
  var DEFAULT_PENDING_PERMISSION_CAP = 32;
47232
47288
  function createPendingPermissionBuffer(opts = {}) {
@@ -47411,12 +47467,12 @@ function transition(state3, event) {
47411
47467
  turnStartedAt: null,
47412
47468
  lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt
47413
47469
  }));
47414
- const sweep = sweepSiblings(stateAfterClear, chatId, event.key);
47470
+ const sweep2 = sweepSiblings(stateAfterClear, chatId, event.key);
47415
47471
  const wasActive = state3.global.kind === "bridge_alive_in_turn" && state3.global.activeTurn === event.key;
47416
- const next = wasActive ? { ...sweep.state, global: { kind: "bridge_alive_idle" } } : sweep.state;
47472
+ const next = wasActive ? { ...sweep2.state, global: { kind: "bridge_alive_idle" } } : sweep2.state;
47417
47473
  const effects = [
47418
47474
  { kind: "clearTurnStarted", key: event.key },
47419
- ...sweep.effects
47475
+ ...sweep2.effects
47420
47476
  ];
47421
47477
  if (event.outboundEmitted) {
47422
47478
  effects.push({ kind: "noteOutbound", key: event.key, at: event.at });
@@ -51794,10 +51850,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51794
51850
  }
51795
51851
 
51796
51852
  // ../src/build-info.ts
51797
- var VERSION = "0.14.39";
51798
- var COMMIT_SHA = "fb30b654";
51799
- var COMMIT_DATE = "2026-06-02T05:46:22Z";
51800
- var LATEST_PR = 2086;
51853
+ var VERSION = "0.14.40";
51854
+ var COMMIT_SHA = "d2d69140";
51855
+ var COMMIT_DATE = "2026-06-02T08:59:46Z";
51856
+ var LATEST_PR = 2090;
51801
51857
  var COMMITS_AHEAD_OF_TAG = 0;
51802
51858
 
51803
51859
  // gateway/boot-version.ts
@@ -52967,8 +53023,14 @@ function markClaudeBusyForInbound(m) {
52967
53023
  if (Number.isFinite(n))
52968
53024
  tid = n;
52969
53025
  }
52970
- claudeBusyKeys.add(chatKey2(m.chatId, tid));
53026
+ const key = chatKey2(m.chatId, tid);
53027
+ claudeBusyKeys.add(key);
53028
+ return key;
52971
53029
  }
53030
+ var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
53031
+ var DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15000;
53032
+ var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53033
+ var deliveryQueue = createDeliveryQueue();
52972
53034
  function turnInFlightForGate() {
52973
53035
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
52974
53036
  }
@@ -54088,6 +54150,29 @@ var _deliveryMachineTick = setInterval(() => {
54088
54150
  shadowEmit({ kind: "tick", now: Date.now() });
54089
54151
  }, DELIVERY_MACHINE_TICK_MS);
54090
54152
  _deliveryMachineTick.unref?.();
54153
+ async function redeliverStrandedInbound(p) {
54154
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
54155
+ process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
54156
+ `);
54157
+ try {
54158
+ const { clearAgentComposer: clearAgentComposer2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
54159
+ if (selfAgent)
54160
+ clearAgentComposer2({ agentName: selfAgent });
54161
+ } catch {}
54162
+ const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
54163
+ if (!ok) {
54164
+ pendingInboundBuffer.push(selfAgent, p.inbound);
54165
+ forgetDelivery(deliveryQueue, p.key);
54166
+ }
54167
+ }
54168
+ var _deliveryConfirmSweep = setInterval(() => {
54169
+ if (!DELIVERY_CONFIRM_ENABLED)
54170
+ return;
54171
+ for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
54172
+ redeliverStrandedInbound(p);
54173
+ }
54174
+ }, DELIVERY_CONFIRM_SWEEP_MS);
54175
+ _deliveryConfirmSweep.unref?.();
54091
54176
  startTimer2({
54092
54177
  editMessage: async (ctx) => {
54093
54178
  const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined;
@@ -56478,6 +56563,9 @@ function handleSessionEvent(ev) {
56478
56563
  isDm: isDmChatId(ev.chatId)
56479
56564
  };
56480
56565
  currentTurn = next;
56566
+ if (DELIVERY_CONFIRM_ENABLED) {
56567
+ ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
56568
+ }
56481
56569
  shadowEmit({
56482
56570
  kind: "turnStart",
56483
56571
  key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined),
@@ -57910,8 +57998,12 @@ ${preBlock(write.output)}`;
57910
57998
  }
57911
57999
  }
57912
58000
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
57913
- if (delivered)
57914
- markClaudeBusyForInbound(inboundMsg);
58001
+ if (delivered) {
58002
+ const busyKey = markClaudeBusyForInbound(inboundMsg);
58003
+ if (DELIVERY_CONFIRM_ENABLED) {
58004
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now());
58005
+ }
58006
+ }
57915
58007
  if (!delivered) {
57916
58008
  pendingInboundBuffer.push(selfAgent, inboundMsg);
57917
58009
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
@@ -280,6 +280,14 @@ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } f
280
280
  import { createInboundSpool } from './inbound-spool.js'
281
281
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
282
282
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
283
+ import {
284
+ createDeliveryQueue,
285
+ trackDelivery,
286
+ ackDelivery,
287
+ sweep as sweepDeliveryQueue,
288
+ forgetDelivery,
289
+ type PendingDelivery,
290
+ } from './inbound-delivery-confirm.js'
283
291
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
284
292
  import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
285
293
  // Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
@@ -1309,15 +1317,35 @@ function markClaudeBusyForInbound(m: {
1309
1317
  chatId: string
1310
1318
  threadId?: number
1311
1319
  meta?: Record<string, string>
1312
- }): void {
1320
+ }): string {
1313
1321
  let tid: number | null = m.threadId ?? null
1314
1322
  if (tid == null && m.meta?.message_thread_id != null) {
1315
1323
  const n = Number(m.meta.message_thread_id)
1316
1324
  if (Number.isFinite(n)) tid = n
1317
1325
  }
1318
- claudeBusyKeys.add(chatKey(m.chatId, tid))
1326
+ const key = chatKey(m.chatId, tid)
1327
+ claudeBusyKeys.add(key)
1328
+ return key
1319
1329
  }
1320
1330
 
1331
+ // ─── Reliable inbound delivery: deliver-until-acked (the marko drop-wedge) ─
1332
+ // A delivered inbound is ACKED only by the `enqueue` session-event (claude
1333
+ // actually started the turn) — NOT by sendToAgent returning true. Until
1334
+ // acked, the message stays queued; if it didn't land within the timeout it
1335
+ // stranded in claude's composer, so we re-deliver it. Re-deliver forever
1336
+ // until acked — never drop (vs the old fire-and-forget that the 300s
1337
+ // silence-poke swallowed). Kill switch: SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0
1338
+ // → legacy fire-and-forget. See inbound-delivery-confirm.ts.
1339
+ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== '0'
1340
+ // How long to wait for claude's `enqueue` ack before treating a delivery as
1341
+ // stranded and re-delivering. Generous by default — claude acks within ~1s of
1342
+ // a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
1343
+ // (env) for tests/forensics; a too-low value re-delivers healthy slow turns
1344
+ // (duplicate turn), which is why the default is comfortably above ack latency.
1345
+ const DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15_000
1346
+ const DELIVERY_CONFIRM_SWEEP_MS = 5_000
1347
+ const deliveryQueue = createDeliveryQueue<InboundMessage>()
1348
+
1321
1349
  /**
1322
1350
  * Authoritative "is a turn in flight?" for every gate that previously
1323
1351
  * read `claudeBusyKeys.size`. PR 3b cutover (extends PR 3a's bridgeUp
@@ -4061,6 +4089,38 @@ const _deliveryMachineTick = setInterval(() => {
4061
4089
  }, DELIVERY_MACHINE_TICK_MS)
4062
4090
  _deliveryMachineTick.unref?.()
4063
4091
 
4092
+ // Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
4093
+ // Every few seconds, re-send any inbound that was handed to claude but never
4094
+ // acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
4095
+ // the composer so the re-sent notification lands on a clean line, then
4096
+ // re-send. Reuses the same delivery primitives; the message is never dropped.
4097
+ // (Refs ipcServer / pendingInboundBuffer declared below — resolved at fire
4098
+ // time, after module init.) unref so the interval never holds the process.
4099
+ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Promise<void> {
4100
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4101
+ process.stderr.write(
4102
+ `telegram gateway: inbound strand (no enqueue ack) key=${p.key} — re-clearing composer + re-delivering\n`,
4103
+ )
4104
+ try {
4105
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
4106
+ if (selfAgent) clearAgentComposer({ agentName: selfAgent })
4107
+ } catch { /* best-effort; re-deliver regardless */ }
4108
+ const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
4109
+ if (!ok) {
4110
+ // Bridge offline between attempts — hand off to the offline buffer
4111
+ // (bridgeUp drains it) and stop tracking here; the spool owns it now.
4112
+ pendingInboundBuffer.push(selfAgent, p.inbound)
4113
+ forgetDelivery(deliveryQueue, p.key)
4114
+ }
4115
+ }
4116
+ const _deliveryConfirmSweep = setInterval(() => {
4117
+ if (!DELIVERY_CONFIRM_ENABLED) return
4118
+ for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
4119
+ void redeliverStrandedInbound(p)
4120
+ }
4121
+ }, DELIVERY_CONFIRM_SWEEP_MS)
4122
+ _deliveryConfirmSweep.unref?.()
4123
+
4064
4124
  // #1445 cross-turn pending-async ambient. When a turn ends after the
4065
4125
  // model dispatched background async work (Agent / Task / Bash run-in-
4066
4126
  // background) and the model has stopped speaking, keep editing the
@@ -7880,6 +7940,16 @@ function handleSessionEvent(ev: SessionEvent): void {
7880
7940
  isDm: isDmChatId(ev.chatId),
7881
7941
  }
7882
7942
  currentTurn = next
7943
+ // Ack inbound delivery (the marko drop-wedge): claude actually started
7944
+ // this turn, so its delivered inbound landed — stop tracking it for
7945
+ // re-delivery. `enqueue` carries the same chat/thread the inbound was
7946
+ // keyed on, so the key matches.
7947
+ if (DELIVERY_CONFIRM_ENABLED) {
7948
+ ackDelivery(
7949
+ deliveryQueue,
7950
+ chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
7951
+ )
7952
+ }
7883
7953
  // PR3b-cutover: feed the authoritative turn-start to the delivery
7884
7954
  // machine. `enqueue` fires for EVERY turn atom regardless of
7885
7955
  // source — inbound, cron, subagent-handback, vault-resume,
@@ -10596,7 +10666,15 @@ async function handleInbound(
10596
10666
  }
10597
10667
 
10598
10668
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
10599
- if (delivered) markClaudeBusyForInbound(inboundMsg)
10669
+ if (delivered) {
10670
+ const busyKey = markClaudeBusyForInbound(inboundMsg)
10671
+ // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
10672
+ // lands, the message stranded in the composer and the sweep re-delivers
10673
+ // it. See inbound-delivery-confirm.ts.
10674
+ if (DELIVERY_CONFIRM_ENABLED) {
10675
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now())
10676
+ }
10677
+ }
10600
10678
  if (!delivered) {
10601
10679
  pendingInboundBuffer.push(selfAgent, inboundMsg)
10602
10680
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Reliable inbound delivery: deliver-until-acked (the marko drop-wedge).
3
+ *
4
+ * Delivering an inbound to claude is fire-and-forget: the gateway calls
5
+ * `sendToAgent`, the bridge turns it into an MCP channel notification, and
6
+ * the unmodified CLI appends the text into its composer and auto-submits
7
+ * ONLY when the composer is empty + idle. If the message lands while claude
8
+ * is still finalizing the prior turn, the auto-submit races turn-completion
9
+ * and the text strands unsubmitted — claude never starts the turn, so the
10
+ * gateway eventually drops the message at the 300s silence-poke. Observed
11
+ * recurring on `marko` (supergroup topic + DMs alike).
12
+ *
13
+ * This is the queue that makes delivery reliable. The contract is the whole
14
+ * idea, and it is deliberately small:
15
+ *
16
+ * 1. A delivered inbound is ACKED only when claude actually starts the
17
+ * turn — the `enqueue` session-event (the one signal that claude truly
18
+ * picked the message up). NOT when `sendToAgent` returns true.
19
+ * 2. Until acked, the message stays tracked. If it hasn't been acked
20
+ * within `timeoutMs`, it stranded: re-deliver it (the gateway re-clears
21
+ * the composer and re-sends).
22
+ * 3. Re-deliver as many times as it takes. We never drop the message and
23
+ * never give up — a reliable queue keeps the message until it lands.
24
+ *
25
+ * Keyed per `chatKey(chatId, threadId)`, so DMs and supergroup forum topics
26
+ * are handled identically (the key is opaque here). The #1556 gate
27
+ * serialises delivery per key, so at most one delivery per key is in flight.
28
+ *
29
+ * Pure bookkeeping only — the gateway does the actual composer-clear and
30
+ * re-send for whatever `sweep` returns. Unit-tested in isolation.
31
+ */
32
+
33
+ export interface PendingDelivery<M> {
34
+ /** chatKey(chatId, threadId) — opaque to this module. */
35
+ readonly key: string
36
+ /** The exact inbound to re-send until claude acks it. */
37
+ readonly inbound: M
38
+ /** When the latest delivery attempt was made (unix-ms). */
39
+ lastAttemptAt: number
40
+ }
41
+
42
+ export interface DeliveryQueue<M> {
43
+ readonly pending: Map<string, PendingDelivery<M>>
44
+ }
45
+
46
+ export function createDeliveryQueue<M>(): DeliveryQueue<M> {
47
+ return { pending: new Map() }
48
+ }
49
+
50
+ /**
51
+ * Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
52
+ * Overwrites any prior pending for the key — the #1556 gate serialises per
53
+ * key, so a later inbound supersedes an earlier un-acked one for that key.
54
+ */
55
+ export function trackDelivery<M>(
56
+ q: DeliveryQueue<M>,
57
+ key: string,
58
+ inbound: M,
59
+ now: number,
60
+ ): void {
61
+ q.pending.set(key, { key, inbound, lastAttemptAt: now })
62
+ }
63
+
64
+ /**
65
+ * Ack a delivery — call from the `enqueue` session-event (claude started the
66
+ * turn, so the message landed). Returns true if a pending entry was cleared.
67
+ */
68
+ export function ackDelivery<M>(q: DeliveryQueue<M>, key: string): boolean {
69
+ return q.pending.delete(key)
70
+ }
71
+
72
+ /**
73
+ * Return the inbounds that stranded (no ack within `timeoutMs`) and should be
74
+ * re-delivered now. Resets each returned entry's clock so the next sweep
75
+ * waits another full `timeoutMs` — the gateway re-sends them. Entries still
76
+ * within the window are left untouched (claude may yet be picking them up).
77
+ */
78
+ export function sweep<M>(
79
+ q: DeliveryQueue<M>,
80
+ now: number,
81
+ timeoutMs: number,
82
+ ): PendingDelivery<M>[] {
83
+ const redeliver: PendingDelivery<M>[] = []
84
+ for (const entry of q.pending.values()) {
85
+ if (now - entry.lastAttemptAt < timeoutMs) continue
86
+ entry.lastAttemptAt = now
87
+ redeliver.push(entry)
88
+ }
89
+ return redeliver
90
+ }
91
+
92
+ /** Forget a key without acking (e.g. the bridge went offline and the message
93
+ * was handed back to the offline buffer, which owns it now). */
94
+ export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
95
+ q.pending.delete(key)
96
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ ackDelivery,
5
+ createDeliveryQueue,
6
+ forgetDelivery,
7
+ sweep,
8
+ trackDelivery,
9
+ type DeliveryQueue,
10
+ } from '../gateway/inbound-delivery-confirm.js'
11
+
12
+ /**
13
+ * Regression coverage for the marko drop-wedge.
14
+ *
15
+ * An inbound delivered to claude's TUI composer strands unsubmitted when the
16
+ * auto-submit races turn-completion. claude never emits `enqueue`, so the
17
+ * gateway used to sit "typing…" for 300s then DROP the message.
18
+ *
19
+ * The queue's contract: a delivered inbound is acked ONLY by `enqueue`; until
20
+ * then it is re-delivered every `timeoutMs`, forever, never dropped — and an
21
+ * acked delivery never re-fires (no duplicate turns).
22
+ */
23
+ type Msg = { text: string }
24
+ const TIMEOUT = 15_000
25
+
26
+ function fresh(): DeliveryQueue<Msg> {
27
+ return createDeliveryQueue<Msg>()
28
+ }
29
+
30
+ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () => {
31
+ it('an acked delivery is never re-delivered (happy path — no duplicate turns)', () => {
32
+ const q = fresh()
33
+ trackDelivery(q, 'chat:_', { text: 'hi' }, 1_000)
34
+ expect(ackDelivery(q, 'chat:_')).toBe(true) // enqueue arrived
35
+ expect(sweep(q, 1_000 + 999_999, TIMEOUT)).toHaveLength(0)
36
+ expect(q.pending.size).toBe(0)
37
+ })
38
+
39
+ it('within the timeout, an un-acked delivery is left alone (claude may still be picking it up)', () => {
40
+ const q = fresh()
41
+ trackDelivery(q, 'chat:_', { text: 'hi' }, 1_000)
42
+ expect(sweep(q, 1_000 + 14_999, TIMEOUT)).toHaveLength(0)
43
+ expect(q.pending.size).toBe(1)
44
+ })
45
+
46
+ it('a strand (no ack) is re-delivered after the timeout, and the clock resets', () => {
47
+ const q = fresh()
48
+ trackDelivery(q, 'chat:_', { text: 'draft nurture email' }, 1_000)
49
+ const r = sweep(q, 1_000 + 15_000, TIMEOUT)
50
+ expect(r).toHaveLength(1)
51
+ expect(r[0]!.inbound.text).toBe('draft nurture email')
52
+ expect(r[0]!.lastAttemptAt).toBe(1_000 + 15_000) // clock reset
53
+ // not re-swept until another full timeout elapses
54
+ expect(sweep(q, 1_000 + 15_000 + 14_999, TIMEOUT)).toHaveLength(0)
55
+ })
56
+
57
+ it('keeps re-delivering forever until acked — never drops (the reliability invariant)', () => {
58
+ const q = fresh()
59
+ let t = 0
60
+ trackDelivery(q, 'chat:_', { text: 'x' }, t)
61
+ for (let i = 0; i < 50; i++) {
62
+ t += 15_000
63
+ expect(sweep(q, t, TIMEOUT)).toHaveLength(1) // still trying after 50 strands
64
+ }
65
+ expect(q.pending.size).toBe(1) // never dropped
66
+ // claude finally picks it up → acked → stops.
67
+ expect(ackDelivery(q, 'chat:_')).toBe(true)
68
+ expect(sweep(q, t + 999_999, TIMEOUT)).toHaveLength(0)
69
+ })
70
+
71
+ it('an ack that lands right after a re-delivery stops further re-delivery (no duplicate turns)', () => {
72
+ const q = fresh()
73
+ trackDelivery(q, 'chat:_', { text: 'x' }, 0)
74
+ sweep(q, 15_000, TIMEOUT) // strand → re-delivered
75
+ expect(ackDelivery(q, 'chat:_')).toBe(true) // the re-delivered copy landed
76
+ expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
77
+ expect(q.pending.size).toBe(0)
78
+ })
79
+
80
+ it('keys are independent — a strand on one topic does not affect another (DM + supergroup topics)', () => {
81
+ const q = fresh()
82
+ trackDelivery(q, '-100:4', { text: 'crm topic msg' }, 0) // supergroup CRM topic
83
+ trackDelivery(q, '555:_', { text: 'dm msg' }, 0) // a DM
84
+ ackDelivery(q, '555:_') // the DM submits fine
85
+ const r = sweep(q, 15_000, TIMEOUT)
86
+ expect(r).toHaveLength(1)
87
+ expect(r[0]!.key).toBe('-100:4') // only the stranded topic re-delivers
88
+ })
89
+
90
+ it('tracking the same key twice keeps only the latest inbound (gate serialises per key)', () => {
91
+ const q = fresh()
92
+ trackDelivery(q, 'chat:_', { text: 'first' }, 0)
93
+ trackDelivery(q, 'chat:_', { text: 'second' }, 100)
94
+ expect(q.pending.size).toBe(1)
95
+ expect(sweep(q, 100 + 15_000, TIMEOUT)[0]!.inbound.text).toBe('second')
96
+ })
97
+
98
+ it('ack on an unknown key is a harmless no-op', () => {
99
+ expect(ackDelivery(fresh(), 'never-tracked')).toBe(false)
100
+ })
101
+
102
+ it('forgetDelivery clears without acking or re-delivering (bridge went offline)', () => {
103
+ const q = fresh()
104
+ trackDelivery(q, 'chat:_', { text: 'x' }, 0)
105
+ forgetDelivery(q, 'chat:_')
106
+ expect(q.pending.size).toBe(0)
107
+ expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
108
+ })
109
+ })