switchroom 0.14.39 → 0.14.41

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.41";
49466
+ var COMMIT_SHA = "747ab2f1";
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.41",
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,45 @@ 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, messageId = null) {
47267
+ q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now });
47268
+ }
47269
+ function ackDelivery(q, key, enqueueMessageId = null) {
47270
+ const entry = q.pending.get(key);
47271
+ if (!entry)
47272
+ return false;
47273
+ if (entry.messageId != null && entry.messageId !== enqueueMessageId)
47274
+ return false;
47275
+ q.pending.delete(key);
47276
+ return true;
47277
+ }
47278
+ function sweep(q, now, timeoutMs) {
47279
+ const redeliver = [];
47280
+ for (const entry of q.pending.values()) {
47281
+ if (now - entry.lastAttemptAt < timeoutMs)
47282
+ continue;
47283
+ entry.lastAttemptAt = now;
47284
+ redeliver.push(entry);
47285
+ }
47286
+ return redeliver;
47287
+ }
47288
+ function forgetDelivery(q, key) {
47289
+ q.pending.delete(key);
47290
+ }
47291
+ function shouldTrackDelivery(input) {
47292
+ if (input.isSteering || input.isInterrupt)
47293
+ return false;
47294
+ if (input.hasSource)
47295
+ return false;
47296
+ if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0)
47297
+ return false;
47298
+ return true;
47299
+ }
47300
+
47230
47301
  // gateway/pending-permission-decisions.ts
47231
47302
  var DEFAULT_PENDING_PERMISSION_CAP = 32;
47232
47303
  function createPendingPermissionBuffer(opts = {}) {
@@ -47411,12 +47482,12 @@ function transition(state3, event) {
47411
47482
  turnStartedAt: null,
47412
47483
  lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt
47413
47484
  }));
47414
- const sweep = sweepSiblings(stateAfterClear, chatId, event.key);
47485
+ const sweep2 = sweepSiblings(stateAfterClear, chatId, event.key);
47415
47486
  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;
47487
+ const next = wasActive ? { ...sweep2.state, global: { kind: "bridge_alive_idle" } } : sweep2.state;
47417
47488
  const effects = [
47418
47489
  { kind: "clearTurnStarted", key: event.key },
47419
- ...sweep.effects
47490
+ ...sweep2.effects
47420
47491
  ];
47421
47492
  if (event.outboundEmitted) {
47422
47493
  effects.push({ kind: "noteOutbound", key: event.key, at: event.at });
@@ -51794,10 +51865,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51794
51865
  }
51795
51866
 
51796
51867
  // ../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;
51868
+ var VERSION = "0.14.41";
51869
+ var COMMIT_SHA = "747ab2f1";
51870
+ var COMMIT_DATE = "2026-06-02T11:03:39Z";
51871
+ var LATEST_PR = 2095;
51801
51872
  var COMMITS_AHEAD_OF_TAG = 0;
51802
51873
 
51803
51874
  // gateway/boot-version.ts
@@ -52967,8 +53038,16 @@ function markClaudeBusyForInbound(m) {
52967
53038
  if (Number.isFinite(n))
52968
53039
  tid = n;
52969
53040
  }
52970
- claudeBusyKeys.add(chatKey2(m.chatId, tid));
53041
+ const key = chatKey2(m.chatId, tid);
53042
+ claudeBusyKeys.add(key);
53043
+ return key;
52971
53044
  }
53045
+ var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
53046
+ var _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS;
53047
+ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== "" ? Number(_deliveryTimeoutRaw) : 15000;
53048
+ var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53049
+ var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53050
+ var deliveryQueue = createDeliveryQueue();
52972
53051
  function turnInFlightForGate() {
52973
53052
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
52974
53053
  }
@@ -54088,6 +54167,38 @@ var _deliveryMachineTick = setInterval(() => {
54088
54167
  shadowEmit({ kind: "tick", now: Date.now() });
54089
54168
  }, DELIVERY_MACHINE_TICK_MS);
54090
54169
  _deliveryMachineTick.unref?.();
54170
+ async function redeliverStrandedInbound(p) {
54171
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
54172
+ process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
54173
+ `);
54174
+ try {
54175
+ const { clearAgentComposer: clearAgentComposer2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
54176
+ if (selfAgent)
54177
+ clearAgentComposer2({ agentName: selfAgent });
54178
+ } catch {}
54179
+ const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
54180
+ if (ok) {
54181
+ markClaudeBusyForInbound(p.inbound);
54182
+ if (!deliveryQueue.pending.has(p.key)) {
54183
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId);
54184
+ }
54185
+ } else {
54186
+ pendingInboundBuffer.push(selfAgent, p.inbound);
54187
+ forgetDelivery(deliveryQueue, p.key);
54188
+ }
54189
+ }
54190
+ var _deliveryConfirmSweep = setInterval(() => {
54191
+ if (!DELIVERY_CONFIRM_ENABLED)
54192
+ return;
54193
+ if (currentTurn != null)
54194
+ return;
54195
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0)
54196
+ return;
54197
+ for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
54198
+ redeliverStrandedInbound(p);
54199
+ }
54200
+ }, DELIVERY_CONFIRM_SWEEP_MS);
54201
+ _deliveryConfirmSweep.unref?.();
54091
54202
  startTimer2({
54092
54203
  editMessage: async (ctx) => {
54093
54204
  const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined;
@@ -56478,6 +56589,9 @@ function handleSessionEvent(ev) {
56478
56589
  isDm: isDmChatId(ev.chatId)
56479
56590
  };
56480
56591
  currentTurn = next;
56592
+ if (DELIVERY_CONFIRM_ENABLED) {
56593
+ ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
56594
+ }
56481
56595
  shadowEmit({
56482
56596
  kind: "turnStart",
56483
56597
  key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined),
@@ -57910,10 +58024,26 @@ ${preBlock(write.output)}`;
57910
58024
  }
57911
58025
  }
57912
58026
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
57913
- if (delivered)
57914
- markClaudeBusyForInbound(inboundMsg);
58027
+ if (delivered) {
58028
+ const busyKey = markClaudeBusyForInbound(inboundMsg);
58029
+ if (DELIVERY_CONFIRM_ENABLED && shouldTrackDelivery({
58030
+ isSteering,
58031
+ isInterrupt: interrupt.isInterrupt,
58032
+ hasSource: inboundMsg.meta?.source != null,
58033
+ effectiveText
58034
+ })) {
58035
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId));
58036
+ }
58037
+ }
57915
58038
  if (!delivered) {
57916
- pendingInboundBuffer.push(selfAgent, inboundMsg);
58039
+ if (shouldTrackDelivery({
58040
+ isSteering,
58041
+ isInterrupt: interrupt.isInterrupt,
58042
+ hasSource: inboundMsg.meta?.source != null,
58043
+ effectiveText
58044
+ })) {
58045
+ pendingInboundBuffer.push(selfAgent, inboundMsg);
58046
+ }
57917
58047
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
57918
58048
  swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u23F3 Agent is restarting \u2014 your message is queued and will be processed when it reconnects.", { ...threadOpts }), {
57919
58049
  chat_id,
@@ -280,6 +280,15 @@ 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
+ shouldTrackDelivery,
290
+ type PendingDelivery,
291
+ } from './inbound-delivery-confirm.js'
283
292
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
284
293
  import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
285
294
  // Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
@@ -1309,15 +1318,43 @@ function markClaudeBusyForInbound(m: {
1309
1318
  chatId: string
1310
1319
  threadId?: number
1311
1320
  meta?: Record<string, string>
1312
- }): void {
1321
+ }): string {
1313
1322
  let tid: number | null = m.threadId ?? null
1314
1323
  if (tid == null && m.meta?.message_thread_id != null) {
1315
1324
  const n = Number(m.meta.message_thread_id)
1316
1325
  if (Number.isFinite(n)) tid = n
1317
1326
  }
1318
- claudeBusyKeys.add(chatKey(m.chatId, tid))
1327
+ const key = chatKey(m.chatId, tid)
1328
+ claudeBusyKeys.add(key)
1329
+ return key
1319
1330
  }
1320
1331
 
1332
+ // ─── Reliable inbound delivery: deliver-until-acked (the marko drop-wedge) ─
1333
+ // A delivered inbound is ACKED only by the `enqueue` session-event (claude
1334
+ // actually started the turn) — NOT by sendToAgent returning true. Until
1335
+ // acked, the message stays queued; if it didn't land within the timeout it
1336
+ // stranded in claude's composer, so we re-deliver it. Re-deliver forever
1337
+ // until acked — never drop (vs the old fire-and-forget that the 300s
1338
+ // silence-poke swallowed). Kill switch: SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0
1339
+ // → legacy fire-and-forget. See inbound-delivery-confirm.ts.
1340
+ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== '0'
1341
+ // How long to wait for claude's `enqueue` ack before treating a delivery as
1342
+ // stranded and re-delivering. Generous by default — claude acks within ~1s of
1343
+ // a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
1344
+ // (env) for tests/forensics; a too-low value re-delivers healthy slow turns
1345
+ // (duplicate turn), which is why the default is comfortably above ack latency.
1346
+ const _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS
1347
+ const _deliveryTimeoutParsed =
1348
+ _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== '' ? Number(_deliveryTimeoutRaw) : 15_000
1349
+ // Clamp to a positive, finite value: a negative / zero / NaN env override would
1350
+ // make the sweep treat every tracked entry as stranded and re-deliver every
1351
+ // cycle forever (a self-inflicted re-delivery loop). To disable the feature,
1352
+ // use SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0, not a degenerate timeout.
1353
+ const DELIVERY_CONFIRM_TIMEOUT_MS =
1354
+ Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15_000
1355
+ const DELIVERY_CONFIRM_SWEEP_MS = 5_000
1356
+ const deliveryQueue = createDeliveryQueue<InboundMessage>()
1357
+
1321
1358
  /**
1322
1359
  * Authoritative "is a turn in flight?" for every gate that previously
1323
1360
  * read `claudeBusyKeys.size`. PR 3b cutover (extends PR 3a's bridgeUp
@@ -4061,6 +4098,57 @@ const _deliveryMachineTick = setInterval(() => {
4061
4098
  }, DELIVERY_MACHINE_TICK_MS)
4062
4099
  _deliveryMachineTick.unref?.()
4063
4100
 
4101
+ // Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
4102
+ // Every few seconds, re-send any inbound that was handed to claude but never
4103
+ // acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
4104
+ // the composer so the re-sent notification lands on a clean line, then
4105
+ // re-send. Reuses the same delivery primitives; the message is never dropped.
4106
+ // (Refs ipcServer / pendingInboundBuffer declared below — resolved at fire
4107
+ // time, after module init.) unref so the interval never holds the process.
4108
+ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Promise<void> {
4109
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4110
+ process.stderr.write(
4111
+ `telegram gateway: inbound strand (no enqueue ack) key=${p.key} — re-clearing composer + re-delivering\n`,
4112
+ )
4113
+ try {
4114
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
4115
+ if (selfAgent) clearAgentComposer({ agentName: selfAgent })
4116
+ } catch { /* best-effort; re-deliver regardless */ }
4117
+ const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
4118
+ if (ok) {
4119
+ // Keep the #1556 gate coherent with the re-sent delivery, and survive an
4120
+ // ack that raced the `await import` above: only `enqueue` clears tracking,
4121
+ // so if a concurrent ack removed the entry, re-affirm it — never drop.
4122
+ // Both ops are idempotent.
4123
+ markClaudeBusyForInbound(p.inbound)
4124
+ if (!deliveryQueue.pending.has(p.key)) {
4125
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
4126
+ }
4127
+ } else {
4128
+ // Bridge offline between attempts — hand off to the offline buffer
4129
+ // (bridgeUp drains it) and stop tracking here; the spool owns it now.
4130
+ pendingInboundBuffer.push(selfAgent, p.inbound)
4131
+ forgetDelivery(deliveryQueue, p.key)
4132
+ }
4133
+ }
4134
+ const _deliveryConfirmSweep = setInterval(() => {
4135
+ if (!DELIVERY_CONFIRM_ENABLED) return
4136
+ // Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
4137
+ // by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
4138
+ // means a real turn is in flight — re-clearing the composer + re-sending now
4139
+ // would clobber it (the exact mid-turn wedge this queue exists to prevent). A
4140
+ // pending permission / ask_user prompt is likewise a live interaction. Defer:
4141
+ // leave the entry pending (it isn't acked) so the next idle sweep retries.
4142
+ // NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
4143
+ // stays set through a strand, so it is NOT a usable "idle" signal here.
4144
+ if (currentTurn != null) return
4145
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
4146
+ for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
4147
+ void redeliverStrandedInbound(p)
4148
+ }
4149
+ }, DELIVERY_CONFIRM_SWEEP_MS)
4150
+ _deliveryConfirmSweep.unref?.()
4151
+
4064
4152
  // #1445 cross-turn pending-async ambient. When a turn ends after the
4065
4153
  // model dispatched background async work (Agent / Task / Bash run-in-
4066
4154
  // background) and the model has stopped speaking, keep editing the
@@ -7880,6 +7968,21 @@ function handleSessionEvent(ev: SessionEvent): void {
7880
7968
  isDm: isDmChatId(ev.chatId),
7881
7969
  }
7882
7970
  currentTurn = next
7971
+ // Ack inbound delivery (the marko drop-wedge): claude actually started
7972
+ // this turn, so its delivered inbound landed — stop tracking it for
7973
+ // re-delivery. `enqueue` carries the same chat/thread the inbound was
7974
+ // keyed on, so the key matches.
7975
+ if (DELIVERY_CONFIRM_ENABLED) {
7976
+ // Match on the source message id: `enqueue` fires for EVERY turn
7977
+ // start (cron / subagent-handback / vault-resume / restart-marker
7978
+ // too — see comment below), so a key-only ack would let a synthetic
7979
+ // turn clear a real user message still waiting under the same key.
7980
+ ackDelivery(
7981
+ deliveryQueue,
7982
+ chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
7983
+ ev.messageId,
7984
+ )
7985
+ }
7883
7986
  // PR3b-cutover: feed the authoritative turn-start to the delivery
7884
7987
  // machine. `enqueue` fires for EVERY turn atom regardless of
7885
7988
  // source — inbound, cron, subagent-handback, vault-resume,
@@ -10596,9 +10699,44 @@ async function handleInbound(
10596
10699
  }
10597
10700
 
10598
10701
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
10599
- if (delivered) markClaudeBusyForInbound(inboundMsg)
10702
+ if (delivered) {
10703
+ const busyKey = markClaudeBusyForInbound(inboundMsg)
10704
+ // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
10705
+ // lands, the message stranded in the composer and the sweep re-delivers
10706
+ // it. Track ONLY messages that produce an `enqueue` to ack against —
10707
+ // shouldTrackDelivery excludes steering / `!` interrupt (amend the running
10708
+ // turn), synthetic (meta.source) inbounds, and empty bodies, all of which
10709
+ // never enqueue and would re-deliver forever. The tracked messageId lets
10710
+ // the ack match only THIS message's enqueue (not a synthetic turn sharing
10711
+ // the key). See shouldTrackDelivery / ackDelivery (inbound-delivery-confirm.ts).
10712
+ if (
10713
+ DELIVERY_CONFIRM_ENABLED &&
10714
+ shouldTrackDelivery({
10715
+ isSteering,
10716
+ isInterrupt: interrupt.isInterrupt,
10717
+ hasSource: inboundMsg.meta?.source != null,
10718
+ effectiveText,
10719
+ })
10720
+ ) {
10721
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
10722
+ }
10723
+ }
10600
10724
  if (!delivered) {
10601
- pendingInboundBuffer.push(selfAgent, inboundMsg)
10725
+ // Only persist fresh user turns to the durable spool. Steering / `!`
10726
+ // interrupt / empty bodies are mid-turn amendments or no-ops that would
10727
+ // arrive orphaned if replayed as a fresh turn after a restart — drop them
10728
+ // (the restart notice below tells the user to re-send). Mirrors the
10729
+ // tracking + #1556-gate carve-outs.
10730
+ if (
10731
+ shouldTrackDelivery({
10732
+ isSteering,
10733
+ isInterrupt: interrupt.isInterrupt,
10734
+ hasSource: inboundMsg.meta?.source != null,
10735
+ effectiveText,
10736
+ })
10737
+ ) {
10738
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
10739
+ }
10602
10740
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
10603
10741
  // #1075: thread-id-bearing — swallow via robustApiCall so a deleted
10604
10742
  // topic doesn't crash the gateway. Fire-and-forget; the inbound is