switchroom 0.18.28 → 0.18.30

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.
Files changed (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -20036,7 +20036,8 @@ var init_protocol2 = __esm(() => {
20036
20036
  v: exports_external.literal(PROTOCOL_VERSION),
20037
20037
  op: exports_external.literal("get-credentials"),
20038
20038
  id: exports_external.string().min(1),
20039
- provider: ProviderNameSchema.optional()
20039
+ provider: ProviderNameSchema.optional(),
20040
+ account: exports_external.string().min(1).optional()
20040
20041
  });
20041
20042
  ListStateRequestSchema = exports_external.object({
20042
20043
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -20351,13 +20352,17 @@ class AuthBrokerClient {
20351
20352
  sock.destroy();
20352
20353
  }
20353
20354
  }
20354
- async getCredentials(provider) {
20355
+ async getCredentials(provider, account) {
20355
20356
  const base = {
20356
20357
  v: PROTOCOL_VERSION,
20357
20358
  id: randomUUID(),
20358
20359
  op: "get-credentials"
20359
20360
  };
20360
- const req = provider !== undefined ? { ...base, provider } : base;
20361
+ let req = base;
20362
+ if (provider !== undefined)
20363
+ req = { ...req, provider };
20364
+ if (account !== undefined)
20365
+ req = { ...req, account };
20361
20366
  const data = await this.send(req);
20362
20367
  return data;
20363
20368
  }
@@ -20632,7 +20637,7 @@ var init_client2 = __esm(() => {
20632
20637
  });
20633
20638
 
20634
20639
  // ../src/config/schema.ts
20635
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
20640
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
20636
20641
  var init_schema = __esm(() => {
20637
20642
  init_zod();
20638
20643
  CodeRepoEntrySchema = exports_external.object({
@@ -21030,11 +21035,52 @@ var init_schema = __esm(() => {
21030
21035
  approvers: exports_external.array(ApproverIdSchema).min(1).optional().describe("Per-agent approver override. When set, replaces (does not extend) " + "the top-level drive.approvers list for this agent's onboarding card."),
21031
21036
  tier: GoogleWorkspaceTierSchema.optional().describe("Per-agent tier override (RFC G Phase 1). When set, replaces the " + "top-level google_workspace.tier for this agent. Common case: most " + "agents on `core`, one specialist on `extended` for Slides access.")
21032
21037
  }).optional();
21038
+ MicrosoftAccountEmailSchema = exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
21039
+ message: "microsoft_workspace.account must be a Microsoft account email like " + "'alice@outlook.com' or 'alice@contoso.com' (colons not allowed)"
21040
+ }).transform((v) => v.trim().toLowerCase());
21041
+ MicrosoftToolTokenSchema = exports_external.string().min(1).regex(/^[a-z0-9-]+$/, {
21042
+ message: "microsoft_workspace tools[] tokens are forwarded to softeria's " + "--enabled-tools regex; each must be lowercase kebab-case " + "([a-z0-9-]+, e.g. 'mail', 'calendar', 'send-mail') so an unescaped " + "regex metacharacter can't crash the launcher"
21043
+ });
21044
+ MicrosoftAccountBindingSchema = exports_external.object({
21045
+ account: MicrosoftAccountEmailSchema.describe("The Microsoft account this binding uses. Must be a key in top-level " + "`microsoft_accounts:` with this agent in its `enabled_for[]`."),
21046
+ tools: exports_external.array(MicrosoftToolTokenSchema).min(1).optional().describe("Per-account tool allowlist \u2192 softeria `--enabled-tools <regex>` " + "(tokens joined with `|`). Omitted = all tools exposed for this account."),
21047
+ org_mode: exports_external.boolean().optional().describe("Per-binding org_mode override (RFC #1873 \u00a76.4).")
21048
+ });
21033
21049
  AgentMicrosoftWorkspaceConfigSchema = exports_external.object({
21034
- account: exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
21035
- message: "microsoft_workspace.account must be a Microsoft account email like " + "'alice@outlook.com' or 'alice@contoso.com' (colons not allowed)"
21036
- }).transform((v) => v.trim().toLowerCase()).optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized)."),
21037
- org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 \u00a76.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false).")
21050
+ account: MicrosoftAccountEmailSchema.optional().describe("RFC #1873: the Microsoft account this agent uses for the M365 MCP. " + "Must be a key in top-level `microsoft_accounts:` with this agent " + "listed in its `enabled_for[]`. Read by the auth-broker " + "(get-credentials, provider=microsoft) and by the scaffold to " + "decide whether to emit the `ms-365` MCP entry. Normalized to " + "lowercase so it matches the microsoft_accounts key (which is " + "also normalized). Mutually exclusive with `accounts` (plural)."),
21051
+ tools: exports_external.array(MicrosoftToolTokenSchema).min(1).optional().describe("Per-account tool allowlist for the SINGULAR `account` form \u2192 " + "softeria `--enabled-tools <regex>`. Omitted = all tools. Only " + "valid with the singular `account`; using it together with the " + "plural `accounts` (which carries per-binding `tools`) is an error."),
21052
+ org_mode: exports_external.boolean().optional().describe("Per-agent org_mode override (RFC #1873 \u00a76.4). When set, replaces " + "the top-level microsoft_workspace.org_mode for this agent. " + "Defaults to top-level value (which defaults to false)."),
21053
+ accounts: exports_external.array(MicrosoftAccountBindingSchema).min(1).optional().describe("Plural multi-account form: bind MULTIPLE Microsoft accounts to " + "this agent, each with its own tool scope. Mutually exclusive with " + "the singular `account`. Each account gets its own `ms-365-<slug>` " + "MCP server.")
21054
+ }).superRefine((v, ctx) => {
21055
+ const hasSingular = v.account !== undefined;
21056
+ const hasPlural = v.accounts !== undefined;
21057
+ if (hasSingular && hasPlural) {
21058
+ ctx.addIssue({
21059
+ code: exports_external.ZodIssueCode.custom,
21060
+ message: "microsoft_workspace: use EITHER `account` (singular) OR " + "`accounts` (plural array), not both",
21061
+ path: ["accounts"]
21062
+ });
21063
+ }
21064
+ if (hasPlural && v.tools !== undefined) {
21065
+ ctx.addIssue({
21066
+ code: exports_external.ZodIssueCode.custom,
21067
+ message: "microsoft_workspace: block-level `tools` applies to the singular " + "`account` only; with `accounts` put `tools` inside each binding",
21068
+ path: ["tools"]
21069
+ });
21070
+ }
21071
+ if (hasPlural) {
21072
+ const seen = new Set;
21073
+ for (const b of v.accounts) {
21074
+ if (seen.has(b.account)) {
21075
+ ctx.addIssue({
21076
+ code: exports_external.ZodIssueCode.custom,
21077
+ message: `microsoft_workspace: duplicate account '${b.account}' in accounts[]`,
21078
+ path: ["accounts"]
21079
+ });
21080
+ }
21081
+ seen.add(b.account);
21082
+ }
21083
+ }
21038
21084
  }).optional();
21039
21085
  AgentNotionWorkspaceConfigSchema = exports_external.object({
21040
21086
  databases: exports_external.array(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/, {
@@ -21409,6 +21455,26 @@ var init_schema = __esm(() => {
21409
21455
  for (const [name, a] of Object.entries(cfg.agents ?? {})) {
21410
21456
  checkServes(a.serves, ["agents", name, "serves"]);
21411
21457
  }
21458
+ const microsoftAccounts = cfg.microsoft_accounts;
21459
+ for (const [name, a] of Object.entries(cfg.agents ?? {})) {
21460
+ const mw = a.microsoft_workspace;
21461
+ const accounts = mw?.accounts;
21462
+ if (!accounts)
21463
+ continue;
21464
+ accounts.forEach((b, i) => {
21465
+ const acct = b?.account?.trim().toLowerCase();
21466
+ if (!acct)
21467
+ return;
21468
+ const enabledFor = microsoftAccounts?.[acct]?.enabled_for ?? [];
21469
+ if (!enabledFor.includes(name)) {
21470
+ ctx.addIssue({
21471
+ code: exports_external.ZodIssueCode.custom,
21472
+ message: `agent '${name}' binds Microsoft account '${acct}' but is not in ` + `microsoft_accounts['${acct}'].enabled_for[] \u2014 operator must run ` + `\`switchroom auth microsoft enable ${acct} ${name}\``,
21473
+ path: ["agents", name, "microsoft_workspace", "accounts", i, "account"]
21474
+ });
21475
+ }
21476
+ });
21477
+ }
21412
21478
  });
21413
21479
  });
21414
21480
 
@@ -22024,6 +22090,25 @@ function validateNotionWorkspaceConfig(config) {
22024
22090
  return issues;
22025
22091
  }
22026
22092
 
22093
+ // ../src/config/timezone.ts
22094
+ function isResolvableTimezone(zone) {
22095
+ try {
22096
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
22097
+ return true;
22098
+ } catch {
22099
+ return false;
22100
+ }
22101
+ }
22102
+ var CONTAINER_DEFAULT_UTC_ZONES;
22103
+ var init_timezone = __esm(() => {
22104
+ CONTAINER_DEFAULT_UTC_ZONES = new Set([
22105
+ "UTC",
22106
+ "Etc/UTC",
22107
+ "Etc/Universal",
22108
+ "Universal"
22109
+ ]);
22110
+ });
22111
+
22027
22112
  // ../src/config/loader.ts
22028
22113
  import { readFileSync as readFileSync9, existsSync as existsSync8 } from "node:fs";
22029
22114
  import { homedir as homedir4 } from "node:os";
@@ -22142,8 +22227,30 @@ function loadConfig(configPath) {
22142
22227
  if (notionIssues.length > 0) {
22143
22228
  throw new ConfigError(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
22144
22229
  }
22230
+ validateAllTimezones(config, filePath);
22145
22231
  return config;
22146
22232
  }
22233
+ function validateAllTimezones(config, filePath) {
22234
+ const issues = [];
22235
+ const check = (zone, where) => {
22236
+ if (zone == null)
22237
+ return;
22238
+ if (!isResolvableTimezone(zone)) {
22239
+ issues.push(` ${where}: "${zone}" is not a resolvable IANA timezone ` + `(shape is valid but no such zone exists \u2014 check for a typo, ` + `e.g. "Australia/Melbourne", "America/New_York", "UTC").`);
22240
+ }
22241
+ };
22242
+ check(config.switchroom?.timezone, "switchroom.timezone");
22243
+ check(config.defaults?.timezone, "defaults.timezone");
22244
+ for (const [profileName, profile] of Object.entries(config.profiles ?? {})) {
22245
+ check(profile?.timezone, `profiles.${profileName}.timezone`);
22246
+ }
22247
+ for (const [agentName, agentRaw] of Object.entries(config.agents)) {
22248
+ check(agentRaw?.timezone, `agents.${agentName}.timezone`);
22249
+ }
22250
+ if (issues.length > 0) {
22251
+ throw new ConfigError(`Invalid timezone configuration in ${filePath}`, issues);
22252
+ }
22253
+ }
22147
22254
  function validateAllCronTopicAliases(config, filePath) {
22148
22255
  const issues = [];
22149
22256
  for (const [agentName, agentRaw] of Object.entries(config.agents)) {
@@ -22179,6 +22286,7 @@ var init_loader = __esm(() => {
22179
22286
  init_paths();
22180
22287
  init_overlay_loader();
22181
22288
  init_merge();
22289
+ init_timezone();
22182
22290
  import_yaml3 = __toESM(require_dist(), 1);
22183
22291
  ConfigError = class ConfigError extends Error {
22184
22292
  details;
@@ -28638,6 +28746,7 @@ var init_redact = __esm(() => {
28638
28746
  // history.ts
28639
28747
  var exports_history = {};
28640
28748
  __export(exports_history, {
28749
+ verifyHistoryWritable: () => verifyHistoryWritable,
28641
28750
  recordReaction: () => recordReaction,
28642
28751
  recordOutbound: () => recordOutbound,
28643
28752
  recordInbound: () => recordInbound,
@@ -28676,6 +28785,15 @@ function loadDatabaseClass() {
28676
28785
  throw new Error(`history.ts requires Bun runtime (bun:sqlite). Caller: ${err.message}`);
28677
28786
  }
28678
28787
  }
28788
+ function warnHistory(msg) {
28789
+ try {
28790
+ process.stderr.write(`telegram history: ${msg}
28791
+ `);
28792
+ } catch {}
28793
+ }
28794
+ function isValidMessageId(id) {
28795
+ return typeof id === "number" && Number.isInteger(id) && id > 0;
28796
+ }
28679
28797
  function initHistory(stateDir, retentionDays = 30) {
28680
28798
  if (db != null)
28681
28799
  return;
@@ -28757,6 +28875,33 @@ function initHistory(stateDir, retentionDays = 30) {
28757
28875
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400;
28758
28876
  db.prepare("DELETE FROM messages WHERE ts < ?").run(cutoff);
28759
28877
  }
28878
+ const check = verifyHistoryWritable();
28879
+ if (!check.ok) {
28880
+ warnHistory(`WRITER SELF-CHECK FAILED at boot (path=${path2}): ${check.error ?? "unknown"} \u2014 ` + `history recording is NOT durable; get_recent_messages recovery and the ` + `reply-backstop already-replied suppression will be blind. Investigate the ` + `DB path/mount/permissions before trusting delivery accounting.`);
28881
+ }
28882
+ }
28883
+ function verifyHistoryWritable() {
28884
+ if (db == null)
28885
+ return { ok: false, error: "initHistory() not called" };
28886
+ const SENTINEL_CHAT = "__history_selfcheck__";
28887
+ const sentinelId = Date.now();
28888
+ try {
28889
+ db.prepare("DELETE FROM messages WHERE chat_id = ?").run(SENTINEL_CHAT);
28890
+ db.prepare(`INSERT OR REPLACE INTO messages
28891
+ (chat_id, thread_id, message_id, role, ts, text)
28892
+ VALUES (?, NULL, ?, 'assistant', ?, ?)`).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), "selfcheck");
28893
+ const row = db.prepare("SELECT text FROM messages WHERE chat_id = ? AND message_id = ?").get(SENTINEL_CHAT, sentinelId);
28894
+ if (row?.text !== "selfcheck") {
28895
+ return { ok: false, error: "sentinel row not read back after insert" };
28896
+ }
28897
+ return { ok: true };
28898
+ } catch (err) {
28899
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
28900
+ } finally {
28901
+ try {
28902
+ db.prepare("DELETE FROM messages WHERE chat_id = ?").run(SENTINEL_CHAT);
28903
+ } catch {}
28904
+ }
28760
28905
  }
28761
28906
  function _resetForTests() {
28762
28907
  if (db != null) {
@@ -28815,6 +28960,10 @@ function requireDb() {
28815
28960
  function recordInbound(args) {
28816
28961
  if (args.message_id == null)
28817
28962
  return;
28963
+ if (!isValidMessageId(args.message_id)) {
28964
+ warnHistory(`recordInbound: dropping row with invalid message_id=${String(args.message_id)} ` + `(chat=${args.chat_id}) \u2014 a delivered inbound will be absent from history`);
28965
+ return;
28966
+ }
28818
28967
  const stmt = requireDb().prepare(`
28819
28968
  INSERT OR REPLACE INTO messages
28820
28969
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
@@ -28826,23 +28975,38 @@ function recordOutbound(args) {
28826
28975
  if (args.message_ids.length === 0)
28827
28976
  return;
28828
28977
  const ts = args.ts ?? Math.floor(Date.now() / 1000);
28829
- const groupId = args.message_ids[0];
28978
+ const validRows = [];
28979
+ for (let i = 0;i < args.message_ids.length; i++) {
28980
+ const id = args.message_ids[i];
28981
+ if (!isValidMessageId(id)) {
28982
+ warnHistory(`recordOutbound: dropping chunk ${i} with invalid message_id=${String(id)} ` + `(chat=${args.chat_id}) \u2014 a delivered outbound will be absent from history, ` + `blinding the reply-backstop already-replied suppression`);
28983
+ continue;
28984
+ }
28985
+ validRows.push({
28986
+ id,
28987
+ text: redact(args.texts[i] ?? ""),
28988
+ attachKind: args.attachment_kinds?.[i] ?? null
28989
+ });
28990
+ }
28991
+ if (validRows.length === 0)
28992
+ return;
28993
+ const groupId = validRows[0].id;
28830
28994
  const stmt = requireDb().prepare(`
28831
28995
  INSERT OR REPLACE INTO messages
28832
28996
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
28833
28997
  VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
28834
28998
  `);
28835
- const tx = requireDb().transaction((rows2) => {
28836
- for (const [msgId, text4, attachKind] of rows2) {
28837
- stmt.run(args.chat_id, args.thread_id ?? null, msgId, ts, text4, attachKind, groupId);
28999
+ const tx = requireDb().transaction((rows) => {
29000
+ for (const r of rows) {
29001
+ stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId);
28838
29002
  }
28839
29003
  });
28840
- const rows = args.message_ids.map((id, i) => [
28841
- id,
28842
- redact(args.texts[i] ?? ""),
28843
- args.attachment_kinds?.[i] ?? null
28844
- ]);
28845
- tx(rows);
29004
+ try {
29005
+ tx(validRows);
29006
+ } catch (err) {
29007
+ warnHistory(`recordOutbound: INSERT failed (chat=${args.chat_id} ids=[${validRows.map((r) => r.id).join(",")}]): ` + `${err instanceof Error ? err.message : String(err)} \u2014 this outbound will be absent from history`);
29008
+ throw err;
29009
+ }
28846
29010
  }
28847
29011
  function recordEdit(args) {
28848
29012
  requireDb().prepare(`
@@ -39831,6 +39995,64 @@ function buildExtraAttachmentMeta(resolved) {
39831
39995
  return out;
39832
39996
  }
39833
39997
 
39998
+ // shared/local-time.ts
39999
+ function fmtLocalClock(ms, tz) {
40000
+ return new Intl.DateTimeFormat("en-US", {
40001
+ timeZone: tz,
40002
+ hour: "numeric",
40003
+ minute: "2-digit",
40004
+ hour12: true
40005
+ }).format(new Date(ms)).replace(/\s([AP])M$/, (_m, p) => `${p.toLowerCase()}m`);
40006
+ }
40007
+ function fmtLocalDate(ms, tz) {
40008
+ return new Intl.DateTimeFormat("en-GB", {
40009
+ timeZone: tz,
40010
+ day: "numeric",
40011
+ month: "short"
40012
+ }).format(new Date(ms));
40013
+ }
40014
+ function localDay(ms, tz) {
40015
+ return new Intl.DateTimeFormat("en-CA", {
40016
+ timeZone: tz,
40017
+ year: "numeric",
40018
+ month: "2-digit",
40019
+ day: "2-digit"
40020
+ }).format(new Date(ms));
40021
+ }
40022
+ function tzAbbrev(ms, tz) {
40023
+ const at = new Date(ms);
40024
+ for (const loc of ["en-US", "en-AU", "en-GB"]) {
40025
+ const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
40026
+ if (v && !/^(?:GMT|UTC)/i.test(v))
40027
+ return v;
40028
+ }
40029
+ return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
40030
+ }
40031
+ function resolveEnvTimezone(env = process.env) {
40032
+ return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? "UTC";
40033
+ }
40034
+ function fmtLocalStamp(ms, tz) {
40035
+ try {
40036
+ const at = new Date(ms);
40037
+ const weekday = new Intl.DateTimeFormat("en-US", {
40038
+ timeZone: tz,
40039
+ weekday: "long"
40040
+ }).format(at);
40041
+ const date = localDay(ms, tz);
40042
+ const time = new Intl.DateTimeFormat("en-US", {
40043
+ timeZone: tz,
40044
+ hour: "2-digit",
40045
+ minute: "2-digit",
40046
+ hour12: true
40047
+ }).format(at);
40048
+ return `${weekday} ${date} ${time} ${tzAbbrev(ms, tz)}`;
40049
+ } catch {
40050
+ if (tz !== "UTC")
40051
+ return fmtLocalStamp(ms, "UTC");
40052
+ return new Date(ms).toISOString();
40053
+ }
40054
+ }
40055
+
39834
40056
  // steering.ts
39835
40057
  function escapeXmlAttribute(s) {
39836
40058
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -39947,7 +40169,7 @@ function buildForwardOriginMeta(origins) {
39947
40169
  if (o.id != null)
39948
40170
  out[`forwarded_from_id${suffix}`] = String(o.id);
39949
40171
  if (o.date != null) {
39950
- out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString();
40172
+ out[`forwarded_date${suffix}`] = fmtLocalStamp(o.date * 1000, resolveEnvTimezone());
39951
40173
  }
39952
40174
  });
39953
40175
  return out;
@@ -39958,6 +40180,190 @@ function forwardOriginDateIso(o) {
39958
40180
  return new Date(o.date * 1000).toISOString();
39959
40181
  }
39960
40182
 
40183
+ // gateway/unhandled-message.ts
40184
+ var MESSAGE_ENVELOPE_KEYS = new Set([
40185
+ "message_id",
40186
+ "message_thread_id",
40187
+ "date",
40188
+ "chat",
40189
+ "from",
40190
+ "sender_chat",
40191
+ "forward_origin",
40192
+ "reply_to_message",
40193
+ "external_reply",
40194
+ "quote",
40195
+ "reply_to_story",
40196
+ "edit_date",
40197
+ "media_group_id",
40198
+ "author_signature",
40199
+ "is_topic_message",
40200
+ "is_automatic_forward",
40201
+ "via_bot",
40202
+ "sender_boost_count",
40203
+ "business_connection_id",
40204
+ "effect_id",
40205
+ "has_protected_content",
40206
+ "is_from_offline",
40207
+ "link_preview_options",
40208
+ "show_caption_above_media",
40209
+ "entities",
40210
+ "caption_entities",
40211
+ "paid_star_count"
40212
+ ]);
40213
+ var SERVICE_NOISE_KEYS = new Set([
40214
+ "new_chat_members",
40215
+ "left_chat_member",
40216
+ "new_chat_title",
40217
+ "new_chat_photo",
40218
+ "delete_chat_photo",
40219
+ "group_chat_created",
40220
+ "supergroup_chat_created",
40221
+ "channel_chat_created",
40222
+ "message_auto_delete_timer_changed",
40223
+ "migrate_to_chat_id",
40224
+ "migrate_from_chat_id",
40225
+ "forum_topic_created",
40226
+ "forum_topic_edited",
40227
+ "forum_topic_closed",
40228
+ "forum_topic_reopened",
40229
+ "general_forum_topic_hidden",
40230
+ "general_forum_topic_unhidden",
40231
+ "video_chat_scheduled",
40232
+ "video_chat_started",
40233
+ "video_chat_ended",
40234
+ "video_chat_participants_invited",
40235
+ "giveaway_created",
40236
+ "giveaway",
40237
+ "giveaway_winners",
40238
+ "giveaway_completed",
40239
+ "boost_added",
40240
+ "chat_background_set",
40241
+ "write_access_allowed",
40242
+ "proximity_alert_triggered",
40243
+ "chat_set_theme",
40244
+ "connected_website",
40245
+ "direct_message_price_changed"
40246
+ ]);
40247
+ function messageContentKeys(msg) {
40248
+ return Object.keys(msg).filter((k) => !MESSAGE_ENVELOPE_KEYS.has(k));
40249
+ }
40250
+ function planUnhandledMessage(msg) {
40251
+ const contentKeys = messageContentKeys(msg);
40252
+ if (contentKeys.length > 0 && contentKeys.every((k) => SERVICE_NOISE_KEYS.has(k))) {
40253
+ return { action: "log-only", contentKeys };
40254
+ }
40255
+ const contentType = contentKeys[0] ?? "unknown";
40256
+ const text = (typeof msg.text === "string" ? msg.text : undefined) ?? (typeof msg.caption === "string" ? msg.caption : undefined) ?? `(unhandled message content: ${contentType})`;
40257
+ return { action: "turn", text, contentKeys };
40258
+ }
40259
+ var TAP_MAX_LINES_PER_MINUTE = 300;
40260
+ function installUpdateTap(bot, log, nowMs = Date.now) {
40261
+ let windowStart = 0;
40262
+ let windowCount = 0;
40263
+ let suppressed = 0;
40264
+ bot.use(async (ctx, next) => {
40265
+ try {
40266
+ const now = nowMs();
40267
+ if (now - windowStart >= 60000) {
40268
+ if (suppressed > 0) {
40269
+ log(`telegram gateway: rx tap suppressed ${suppressed} update lines in the last minute (cap ${TAP_MAX_LINES_PER_MINUTE}/min)
40270
+ `);
40271
+ }
40272
+ windowStart = now;
40273
+ windowCount = 0;
40274
+ suppressed = 0;
40275
+ }
40276
+ if (windowCount < TAP_MAX_LINES_PER_MINUTE) {
40277
+ windowCount++;
40278
+ const upd = ctx.update;
40279
+ const updateType = Object.keys(upd).find((k) => k !== "update_id") ?? "unknown";
40280
+ const msg = ctx.message;
40281
+ const detail = msg ? ` content=[${messageContentKeys(msg).join(",")}]` : "";
40282
+ log(`telegram gateway: rx update_id=${ctx.update.update_id} type=${updateType}${detail}
40283
+ `);
40284
+ } else {
40285
+ suppressed++;
40286
+ }
40287
+ } catch {}
40288
+ await next();
40289
+ });
40290
+ }
40291
+ function installUnhandledMessageCatchAll(bot, onInbound, log) {
40292
+ bot.on("message", async (ctx) => {
40293
+ try {
40294
+ const msg = ctx.message;
40295
+ const plan = planUnhandledMessage(msg);
40296
+ log(`telegram gateway: catch-all inbound (no specific handler) ` + `update_id=${ctx.update.update_id} chat_id=${ctx.chat?.id ?? "?"} ` + `message_id=${ctx.message?.message_id ?? "?"} ` + `content_keys=[${plan.contentKeys.join(",")}] action=${plan.action}
40297
+ `);
40298
+ if (plan.action === "turn") {
40299
+ await onInbound(ctx, plan.text);
40300
+ }
40301
+ } catch (err) {
40302
+ log(`telegram gateway: catch-all handler error: ${err.message}
40303
+ `);
40304
+ }
40305
+ });
40306
+ }
40307
+
40308
+ // shared/local-time.ts
40309
+ function localDay2(ms, tz) {
40310
+ return new Intl.DateTimeFormat("en-CA", {
40311
+ timeZone: tz,
40312
+ year: "numeric",
40313
+ month: "2-digit",
40314
+ day: "2-digit"
40315
+ }).format(new Date(ms));
40316
+ }
40317
+ function tzAbbrev2(ms, tz) {
40318
+ const at = new Date(ms);
40319
+ for (const loc of ["en-US", "en-AU", "en-GB"]) {
40320
+ const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
40321
+ if (v && !/^(?:GMT|UTC)/i.test(v))
40322
+ return v;
40323
+ }
40324
+ return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
40325
+ }
40326
+ function resolveEnvTimezone2(env = process.env) {
40327
+ return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? "UTC";
40328
+ }
40329
+ function fmtLocalStamp2(ms, tz) {
40330
+ try {
40331
+ const at = new Date(ms);
40332
+ const weekday = new Intl.DateTimeFormat("en-US", {
40333
+ timeZone: tz,
40334
+ weekday: "long"
40335
+ }).format(at);
40336
+ const date = localDay2(ms, tz);
40337
+ const time = new Intl.DateTimeFormat("en-US", {
40338
+ timeZone: tz,
40339
+ hour: "2-digit",
40340
+ minute: "2-digit",
40341
+ hour12: true
40342
+ }).format(at);
40343
+ return `${weekday} ${date} ${time} ${tzAbbrev2(ms, tz)}`;
40344
+ } catch {
40345
+ if (tz !== "UTC")
40346
+ return fmtLocalStamp2(ms, "UTC");
40347
+ return new Date(ms).toISOString();
40348
+ }
40349
+ }
40350
+ var LEADING_ISO_Z = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(\s|$)/;
40351
+ function renderLogTimestampsLocal(text, tz) {
40352
+ if (!text)
40353
+ return text;
40354
+ return text.split(`
40355
+ `).map((line) => {
40356
+ const m = LEADING_ISO_Z.exec(line);
40357
+ if (!m)
40358
+ return line;
40359
+ const ms = Date.parse(m[1]);
40360
+ if (Number.isNaN(ms))
40361
+ return line;
40362
+ return `${fmtLocalStamp2(ms, tz)}${m[2] === "" ? "" : " "}${line.slice(m[0].length)}`;
40363
+ }).join(`
40364
+ `);
40365
+ }
40366
+
39961
40367
  // status-reactions.ts
39962
40368
  var TELEGRAM_REACTION_WHITELIST = new Set([
39963
40369
  "\uD83D\uDC4D",
@@ -57171,40 +57577,6 @@ function sendGateConfigFromEnv(env = process.env) {
57171
57577
  return out;
57172
57578
  }
57173
57579
 
57174
- // shared/local-time.ts
57175
- function fmtLocalClock(ms, tz) {
57176
- return new Intl.DateTimeFormat("en-US", {
57177
- timeZone: tz,
57178
- hour: "numeric",
57179
- minute: "2-digit",
57180
- hour12: true
57181
- }).format(new Date(ms)).replace(/\s([AP])M$/, (_m, p) => `${p.toLowerCase()}m`);
57182
- }
57183
- function fmtLocalDate(ms, tz) {
57184
- return new Intl.DateTimeFormat("en-GB", {
57185
- timeZone: tz,
57186
- day: "numeric",
57187
- month: "short"
57188
- }).format(new Date(ms));
57189
- }
57190
- function localDay(ms, tz) {
57191
- return new Intl.DateTimeFormat("en-CA", {
57192
- timeZone: tz,
57193
- year: "numeric",
57194
- month: "2-digit",
57195
- day: "2-digit"
57196
- }).format(new Date(ms));
57197
- }
57198
- function tzAbbrev(ms, tz) {
57199
- const at = new Date(ms);
57200
- for (const loc of ["en-US", "en-AU", "en-GB"]) {
57201
- const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
57202
- if (v && !/^(?:GMT|UTC)/i.test(v))
57203
- return v;
57204
- }
57205
- return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
57206
- }
57207
-
57208
57580
  // send-gate-observability.ts
57209
57581
  function formatStatsLine(stats) {
57210
57582
  const c = stats.global;
@@ -57239,7 +57611,7 @@ function createStatsLogger(config) {
57239
57611
  function isAlertableScope(scopeKey) {
57240
57612
  return scopeKey === "global" || scopeKey.startsWith("chat:") || scopeKey.startsWith("group:");
57241
57613
  }
57242
- function fmtLocalStamp(ms, tz, refMs) {
57614
+ function fmtLocalStamp3(ms, tz, refMs) {
57243
57615
  const clock = fmtLocalClock(ms, tz);
57244
57616
  const sameDay = refMs != null && localDay(ms, tz) === localDay(refMs, tz);
57245
57617
  const body = sameDay ? clock : `${fmtLocalDate(ms, tz)} ${clock}`;
@@ -57283,7 +57655,7 @@ function createFloodWindowObserver(config) {
57283
57655
  }
57284
57656
  function openAlertText(w, now) {
57285
57657
  const openFor = fmtDur(now - w.observedAt);
57286
- return `\u26a0\ufe0f Telegram flood ban active (scope \`${w.scopeKey}\`). ` + `Open for ${openFor}, expected to clear at ${fmtLocalStamp(w.untilTs, tz, now)}. ` + `Outbound to that scope is being suppressed.`;
57658
+ return `\u26a0\ufe0f Telegram flood ban active (scope \`${w.scopeKey}\`). ` + `Open for ${openFor}, expected to clear at ${fmtLocalStamp3(w.untilTs, tz, now)}. ` + `Outbound to that scope is being suppressed.`;
57287
57659
  }
57288
57660
  function closeAlertText(recs) {
57289
57661
  const observedAt = Math.min(...recs.map((r) => r.observedAt));
@@ -63314,6 +63686,7 @@ function endsWithSilentMarker(text4) {
63314
63686
  return false;
63315
63687
  return isSilentFlushMarker(lines[lines.length - 1]);
63316
63688
  }
63689
+ var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
63317
63690
  function selectFlushDeliveryText(blocks) {
63318
63691
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
63319
63692
  if (candidates.length === 0)
@@ -63328,8 +63701,18 @@ function selectFlushDeliveryText(blocks) {
63328
63701
  `);
63329
63702
  }
63330
63703
  var NARRATION_OPENER = /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i;
63704
+ var NARRATION_TRAILER = /(?:\.{3}|\u2026|:)\s*$/;
63705
+ function isTrailingNarrationLine(block) {
63706
+ const t = block.trim();
63707
+ if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS)
63708
+ return false;
63709
+ if (t.includes(`
63710
+ `))
63711
+ return false;
63712
+ return NARRATION_TRAILER.test(t);
63713
+ }
63331
63714
  function isNarrationBlock(block) {
63332
- return NARRATION_OPENER.test(block.trimStart());
63715
+ return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block);
63333
63716
  }
63334
63717
  function decideTurnFlush(input) {
63335
63718
  const flushEnabled = input.flushEnabled !== false;
@@ -63657,71 +64040,6 @@ function extractRequestId(raw) {
63657
64040
  return m ? m[1] : undefined;
63658
64041
  }
63659
64042
 
63660
- // operator-events.ts
63661
- function classifyClaudeError(raw) {
63662
- try {
63663
- return classifyInner(raw);
63664
- } catch {
63665
- return "unknown-4xx";
63666
- }
63667
- }
63668
- function classifyInner(raw) {
63669
- if (raw == null)
63670
- return "unknown-4xx";
63671
- const obj = typeof raw === "object" ? raw : {};
63672
- const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
63673
- const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
63674
- const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
63675
- const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
63676
- const sdkCode = extractString(obj, "error_code") ?? "";
63677
- if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
63678
- const msg = message.toLowerCase();
63679
- if (msg.includes("expired") || msg.includes("refresh")) {
63680
- return "credentials-expired";
63681
- }
63682
- return "credentials-invalid";
63683
- }
63684
- if (errorType === "invalid_api_key" || errorCode === "invalid_api_key" || sdkCode === "invalid_api_key" || message.toLowerCase().includes("invalid_api_key") || message.toLowerCase().includes("invalid api key")) {
63685
- return "credentials-invalid";
63686
- }
63687
- if (errorType === "credit_balance_too_low" || errorCode === "credit_balance_too_low" || sdkCode === "credit_balance_too_low" || message.toLowerCase().includes("credit_balance_too_low") || message.toLowerCase().includes("credit balance")) {
63688
- return "credit-exhausted";
63689
- }
63690
- if (errorType === "rate_limit_error" || errorCode === "rate_limit_error" || sdkCode === "rate_limit_error" || message.toLowerCase().includes("rate_limit_error") || message.toLowerCase().includes("rate limit")) {
63691
- return "rate-limited";
63692
- }
63693
- if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
63694
- return "rate-limited";
63695
- }
63696
- if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
63697
- return "agent-crashed";
63698
- }
63699
- if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
63700
- return "agent-restarted-unexpectedly";
63701
- }
63702
- if (status != null) {
63703
- if (status >= 400 && status < 500)
63704
- return "unknown-4xx";
63705
- if (status >= 500 && status < 600)
63706
- return "unknown-5xx";
63707
- }
63708
- return "unknown-4xx";
63709
- }
63710
- function extractString(obj, key) {
63711
- const v = obj[key];
63712
- return typeof v === "string" && v.length > 0 ? v : null;
63713
- }
63714
- function extractNumber(obj, key) {
63715
- const v = obj[key];
63716
- return typeof v === "number" ? v : null;
63717
- }
63718
- function getNestedObj(obj, key) {
63719
- const v = obj[key];
63720
- return typeof v === "object" && v != null ? v : {};
63721
- }
63722
- var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
63723
- var cooldownMap = new Map;
63724
-
63725
64043
  // model-unavailable.ts
63726
64044
  init_quota_check();
63727
64045
  init_card_format();
@@ -63762,6 +64080,18 @@ function isLitellmProxyLocal429(text4) {
63762
64080
  return true;
63763
64081
  return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
63764
64082
  }
64083
+ function isLitellmProxyAuthMisconfig(text4) {
64084
+ if (typeof text4 !== "string" || text4.length === 0)
64085
+ return false;
64086
+ const sample = text4.length > 16384 ? text4.slice(0, 16384) : text4;
64087
+ const lower = sample.toLowerCase();
64088
+ if (lower.includes("x-api-key header is required"))
64089
+ return true;
64090
+ const isAuthErr = lower.includes("authentication_error") || lower.includes("authenticationerror");
64091
+ if (!isAuthErr)
64092
+ return false;
64093
+ return lower.includes("fallback") && lower.includes("x-api-key");
64094
+ }
63765
64095
  function parseLitellmLimitDetail(text4, parseTimeNow = new Date) {
63766
64096
  const empty2 = { limitType: null, limit: null, currentUsage: null, resetAtMs: null };
63767
64097
  if (typeof text4 !== "string" || text4.length === 0)
@@ -64006,6 +64336,83 @@ function parseRelativeDuration(s) {
64006
64336
  return matched && total > 0 ? total : null;
64007
64337
  }
64008
64338
 
64339
+ // operator-events.ts
64340
+ function classifyClaudeError(raw) {
64341
+ try {
64342
+ return classifyInner(raw);
64343
+ } catch {
64344
+ return "unknown-4xx";
64345
+ }
64346
+ }
64347
+ function classifyInner(raw) {
64348
+ if (raw == null)
64349
+ return "unknown-4xx";
64350
+ const obj = typeof raw === "object" ? raw : {};
64351
+ const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
64352
+ const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
64353
+ const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
64354
+ const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
64355
+ const sdkCode = extractString(obj, "error_code") ?? "";
64356
+ if (isLitellmProxyAuthMisconfig(`${errorType}
64357
+ ${errorCode}
64358
+ ${sdkCode}
64359
+ ${message}`)) {
64360
+ return "proxy-misconfig";
64361
+ }
64362
+ if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
64363
+ const msg = message.toLowerCase();
64364
+ if (msg.includes("expired") || msg.includes("refresh")) {
64365
+ return "credentials-expired";
64366
+ }
64367
+ return "credentials-invalid";
64368
+ }
64369
+ if (errorType === "invalid_api_key" || errorCode === "invalid_api_key" || sdkCode === "invalid_api_key" || message.toLowerCase().includes("invalid_api_key") || message.toLowerCase().includes("invalid api key")) {
64370
+ return "credentials-invalid";
64371
+ }
64372
+ if (errorType === "credit_balance_too_low" || errorCode === "credit_balance_too_low" || sdkCode === "credit_balance_too_low" || message.toLowerCase().includes("credit_balance_too_low") || message.toLowerCase().includes("credit balance")) {
64373
+ return "credit-exhausted";
64374
+ }
64375
+ if (errorType === "rate_limit_error" || errorCode === "rate_limit_error" || sdkCode === "rate_limit_error" || message.toLowerCase().includes("rate_limit_error") || message.toLowerCase().includes("rate limit")) {
64376
+ return "rate-limited";
64377
+ }
64378
+ if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
64379
+ return "rate-limited";
64380
+ }
64381
+ if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
64382
+ return "agent-crashed";
64383
+ }
64384
+ if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
64385
+ return "agent-restarted-unexpectedly";
64386
+ }
64387
+ if (status != null) {
64388
+ if (status >= 400 && status < 500)
64389
+ return "unknown-4xx";
64390
+ if (status >= 500 && status < 600)
64391
+ return "unknown-5xx";
64392
+ }
64393
+ return "unknown-4xx";
64394
+ }
64395
+ function extractString(obj, key) {
64396
+ const v = obj[key];
64397
+ return typeof v === "string" && v.length > 0 ? v : null;
64398
+ }
64399
+ function extractNumber(obj, key) {
64400
+ const v = obj[key];
64401
+ return typeof v === "number" ? v : null;
64402
+ }
64403
+ function getNestedObj(obj, key) {
64404
+ const v = obj[key];
64405
+ return typeof v === "object" && v != null ? v : {};
64406
+ }
64407
+ var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
64408
+ var cooldownMap = new Map;
64409
+ var OPERATOR_ACTIONABLE_KINDS = new Set([
64410
+ "credentials-expired",
64411
+ "credentials-invalid",
64412
+ "credit-exhausted",
64413
+ "proxy-misconfig"
64414
+ ]);
64415
+
64009
64416
  // session-tail.ts
64010
64417
  function sanitizeCwdToProjectName(cwd) {
64011
64418
  return cwd.replace(/[^a-zA-Z0-9]/g, "-");
@@ -65407,13 +65814,17 @@ class AuthBrokerClient2 {
65407
65814
  sock.destroy();
65408
65815
  }
65409
65816
  }
65410
- async getCredentials(provider) {
65817
+ async getCredentials(provider, account) {
65411
65818
  const base = {
65412
65819
  v: PROTOCOL_VERSION,
65413
65820
  id: randomUUID4(),
65414
65821
  op: "get-credentials"
65415
65822
  };
65416
- const req = provider !== undefined ? { ...base, provider } : base;
65823
+ let req = base;
65824
+ if (provider !== undefined)
65825
+ req = { ...req, provider };
65826
+ if (account !== undefined)
65827
+ req = { ...req, account };
65417
65828
  const data = await this.send(req);
65418
65829
  return data;
65419
65830
  }
@@ -66623,6 +67034,20 @@ function renderOperatorEvent(ev) {
66623
67034
  ]
66624
67035
  }
66625
67036
  };
67037
+ case "proxy-misconfig":
67038
+ return {
67039
+ text: [
67040
+ `\uD83D\uDEE0\ufe0f **Model-gateway auth misconfig** for **${agent}**.`,
67041
+ detail ? `_${detail}_` : "",
67042
+ `The local LiteLLM proxy re-dispatched a fallback without forwarding the OAuth header (keyless deployment \u2192 Anthropic 401). Fix the proxy fallback config \u2014 this is NOT a login problem.`
67043
+ ].filter(Boolean).join(`
67044
+ `),
67045
+ keyboard: {
67046
+ inline_keyboard: [
67047
+ [{ text: "\u274c Dismiss", callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }]
67048
+ ]
67049
+ }
67050
+ };
66626
67051
  case "credit-exhausted":
66627
67052
  return {
66628
67053
  text: [
@@ -66783,6 +67208,55 @@ function shouldEmitOperatorEvent(agent, kind, now = Date.now(), cooldownMs = DEF
66783
67208
  cooldownMap2.set(key, now);
66784
67209
  return true;
66785
67210
  }
67211
+ var OPERATOR_ACTIONABLE_KINDS2 = new Set([
67212
+ "credentials-expired",
67213
+ "credentials-invalid",
67214
+ "credit-exhausted",
67215
+ "proxy-misconfig"
67216
+ ]);
67217
+ function isOperatorActionableKind(kind) {
67218
+ return OPERATOR_ACTIONABLE_KINDS2.has(kind);
67219
+ }
67220
+ function decideOperatorEventAudience(kind, allowFrom, operatorChatId) {
67221
+ if (!isOperatorActionableKind(kind)) {
67222
+ return { operatorChats: [...allowFrom], userNoticeChats: [] };
67223
+ }
67224
+ const operator = operatorChatId != null && allowFrom.includes(operatorChatId) ? operatorChatId : allowFrom[0];
67225
+ const operatorChats = operator != null ? [operator] : [];
67226
+ const userNoticeChats = allowFrom.filter((c) => c !== operator);
67227
+ return { operatorChats, userNoticeChats };
67228
+ }
67229
+ function renderUserFacingFailureNotice() {
67230
+ return "\u26a0\ufe0f Sorry \u2014 I couldn't complete that just now. It's a problem on our side, not anything you did. Please try again shortly.";
67231
+ }
67232
+
67233
+ // pending-user-notice.ts
67234
+ var PENDING_USER_NOTICE_TTL_MS = 10 * 60000;
67235
+
67236
+ class PendingUserNoticeGate {
67237
+ pending = [];
67238
+ schedule(notice) {
67239
+ this.prune(notice.atMs);
67240
+ this.pending = this.pending.filter((p) => p.agent !== notice.agent);
67241
+ this.pending.push(notice);
67242
+ }
67243
+ resolveTurnEnd(turnDeliveredReply, now = Date.now()) {
67244
+ this.prune(now);
67245
+ const out = turnDeliveredReply ? [] : [...this.pending];
67246
+ this.pending = [];
67247
+ return out;
67248
+ }
67249
+ hasPending(now = Date.now()) {
67250
+ return this.pending.some((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS);
67251
+ }
67252
+ prune(now) {
67253
+ this.pending = this.pending.filter((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS);
67254
+ }
67255
+ reset() {
67256
+ this.pending = [];
67257
+ }
67258
+ }
67259
+ var pendingUserNoticeGate = new PendingUserNoticeGate;
66786
67260
 
66787
67261
  // operator-events-history.ts
66788
67262
  var EVENT_TTL_MS = 60 * 60 * 1000;
@@ -66896,7 +67370,13 @@ function parseLlmError(raw, retryState) {
66896
67370
  }
66897
67371
  function classifyKindAndSource(text4) {
66898
67372
  const lower = text4.toLowerCase();
67373
+ if (isLitellmProxyAuthMisconfig(text4)) {
67374
+ return { kind: "infra_misconfig", source: "litellm-local" };
67375
+ }
66899
67376
  const claudeKind = classifyClaudeError({ message: text4, type: text4 });
67377
+ if (claudeKind === "proxy-misconfig") {
67378
+ return { kind: "infra_misconfig", source: "litellm-local" };
67379
+ }
66900
67380
  if (claudeKind === "credentials-expired" || claudeKind === "credentials-invalid") {
66901
67381
  return { kind: "auth", source: "anthropic" };
66902
67382
  }
@@ -66939,6 +67419,8 @@ function buildCoreText(kind, source) {
66939
67419
  return "Usage limit reached on this Claude subscription.";
66940
67420
  case "auth":
66941
67421
  return "Claude login needs re-authentication.";
67422
+ case "infra_misconfig":
67423
+ return "Local model-gateway auth misconfig (proxy fallback dropped the OAuth header).";
66942
67424
  case "transient":
66943
67425
  return source === "network" ? "Couldn't reach Anthropic (network) \u2014 retrying automatically." : "A temporary upstream hiccup \u2014 retrying automatically.";
66944
67426
  case "unknown":
@@ -66984,6 +67466,8 @@ function buildRecommendation(parsed, tz) {
66984
67466
  switch (parsed.kind) {
66985
67467
  case "auth":
66986
67468
  return "\u2192 Re-authenticate this account to continue.";
67469
+ case "infra_misconfig":
67470
+ return "\u2192 Fix the LiteLLM proxy fallback config (deployment missing OAuth passthrough).";
66987
67471
  case "quota_wall": {
66988
67472
  const reset2 = formatResetClock(parsed.resetAt, tz);
66989
67473
  return reset2 ? `\u2192 Switch to another account, or wait for the quota to reset at ${reset2}.` : "\u2192 Switch to another account, or wait for the quota to reset.";
@@ -67025,6 +67509,8 @@ function kindEmoji(kind) {
67025
67509
  return "\u26a0\ufe0f";
67026
67510
  case "auth":
67027
67511
  return "\uD83D\uDD11";
67512
+ case "infra_misconfig":
67513
+ return "\uD83D\uDEE0\ufe0f";
67028
67514
  case "transient":
67029
67515
  return "\uD83C\uDF10";
67030
67516
  case "unknown":
@@ -69214,7 +69700,7 @@ function endsWithSilentMarker2(text4) {
69214
69700
  return false;
69215
69701
  return isSilentFlushMarker2(lines[lines.length - 1]);
69216
69702
  }
69217
- var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
69703
+ var FLUSH_SUBSTANTIVE_MIN_CHARS2 = 200;
69218
69704
  function selectFlushDeliveryText2(blocks) {
69219
69705
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
69220
69706
  if (candidates.length === 0)
@@ -69229,8 +69715,18 @@ function selectFlushDeliveryText2(blocks) {
69229
69715
  `);
69230
69716
  }
69231
69717
  var NARRATION_OPENER2 = /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i;
69718
+ var NARRATION_TRAILER2 = /(?:\.{3}|\u2026|:)\s*$/;
69719
+ function isTrailingNarrationLine2(block) {
69720
+ const t = block.trim();
69721
+ if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS2)
69722
+ return false;
69723
+ if (t.includes(`
69724
+ `))
69725
+ return false;
69726
+ return NARRATION_TRAILER2.test(t);
69727
+ }
69232
69728
  function isNarrationBlock2(block) {
69233
- return NARRATION_OPENER2.test(block.trimStart());
69729
+ return NARRATION_OPENER2.test(block.trimStart()) || isTrailingNarrationLine2(block);
69234
69730
  }
69235
69731
  function decideTurnFlush2(input) {
69236
69732
  const flushEnabled = input.flushEnabled !== false;
@@ -71165,16 +71661,6 @@ Allowed: \`${deps.escapeHtml(allow)}\``, { html: true });
71165
71661
  await deps.reply(ctx, finalText, { html: true, accent });
71166
71662
  }
71167
71663
 
71168
- // ../src/agents/model-picker.ts
71169
- function labelTag(label) {
71170
- let h = 2166136261;
71171
- for (const ch of label) {
71172
- h ^= ch.codePointAt(0);
71173
- h = Math.imul(h, 16777619) >>> 0;
71174
- }
71175
- return h.toString(16).padStart(8, "0");
71176
- }
71177
-
71178
71664
  // gateway/model-command.ts
71179
71665
  var MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable", "default"];
71180
71666
  var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/;
@@ -71184,11 +71670,29 @@ function isValidModelArg(arg) {
71184
71670
  function isSrModel(name) {
71185
71671
  return name.startsWith("sr-");
71186
71672
  }
71187
- function isClaudeModel(name) {
71188
- const lower = name.toLowerCase();
71189
- if (MODEL_ALIASES.includes(lower))
71190
- return true;
71191
- return lower.startsWith("claude-");
71673
+ function parseModelSwitchTarget(reason) {
71674
+ const m = reason.match(/\/model\s+(\S+)/);
71675
+ return m ? m[1] : null;
71676
+ }
71677
+ function modelFamilyToken(token) {
71678
+ const t = token.trim().toLowerCase();
71679
+ if (t.startsWith("claude-")) {
71680
+ const family = t.slice("claude-".length).split("-").filter((p) => p.length > 0)[0];
71681
+ return family ?? t;
71682
+ }
71683
+ return t;
71684
+ }
71685
+ function classifyModelSwitchConfirmation(input) {
71686
+ const { reason, launched, configured } = input;
71687
+ const isApplyBoot = launched.length > 0 && launched !== configured;
71688
+ if (isApplyBoot)
71689
+ return { kind: "applied", launched };
71690
+ const target = parseModelSwitchTarget(reason);
71691
+ const revertedTo = launched.length > 0 ? launched : configured;
71692
+ if (target != null && target.toLowerCase() !== "default" && modelFamilyToken(target) !== modelFamilyToken(revertedTo)) {
71693
+ return { kind: "not-applied", target, revertedTo };
71694
+ }
71695
+ return { kind: "default", launched: revertedTo };
71192
71696
  }
71193
71697
  function parseModelCommand(text4) {
71194
71698
  const m = text4.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
@@ -71233,13 +71737,13 @@ function modelCommandReceiptLine(agent, parsed, busy) {
71233
71737
  const arg = parsed.kind === "set" ? parsed.model : parsed.kind === "show" ? "(show)" : "(help)";
71234
71738
  return `telegram gateway: gw /model received agent=${agent} kind=${parsed.kind} arg=${arg} busy=${busy}`;
71235
71739
  }
71236
- var PERSIST_NOTE = "_Session-only \u2014 this override lasts until the agent\u2019s next restart, then reverts to the configured `model:`. `/model default` clears it now. To change the default permanently, set `model:` in switchroom.yaml._";
71740
+ var PERSIST_NOTE = "_A `/model` switch relaunches the session (~30s) on the chosen model. Session-only \u2014 reverts to the configured `model:` on the next restart. `/model default` reverts now. Live scrollback is replaced by a fresh session; memory and the handoff briefing carry the context. To change the default permanently, set `model:` in switchroom.yaml._";
71237
71741
  function helpText2(deps, reason) {
71238
71742
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `\`${a}\``).join(" \u00b7 ");
71239
71743
  const lines = [];
71240
71744
  if (reason)
71241
71745
  lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
71242
- lines.push("**/model** \u2014 show or switch the Claude model", "`/model` \u2014 show the configured model", `\`/model <name>\` \u2014 switch the live session (${MODEL_ALIASES.map((a) => `\`${a}\``).join(" \u00b7 ")} or a full model id)`, `_OpenRouter shortcuts:_ ${srAliasExamples}`, "_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._", PERSIST_NOTE);
71746
+ lines.push("**/model** \u2014 show or switch the Claude model", "`/model` \u2014 show the configured model", `\`/model <name>\` \u2014 switch the live session (${MODEL_ALIASES.map((a) => `\`${a}\``).join(" \u00b7 ")} or a full model id)`, `_OpenRouter shortcuts:_ ${srAliasExamples}`, "_Every switch relaunches the session (~30s) on the chosen model \u2014 Claude and OpenRouter (sr-\\*) alike._", PERSIST_NOTE);
71243
71747
  return { text: lines.join(`
71244
71748
  `), html: true };
71245
71749
  }
@@ -71273,132 +71777,46 @@ async function handleModelCommand(parsed, deps) {
71273
71777
  html: true
71274
71778
  };
71275
71779
  }
71276
- const currentSession = deps.getActiveSessionModel();
71277
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
71278
- try {
71279
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`);
71280
- } catch (err) {
71281
- if (isRestartInFlight(err)) {
71282
- return {
71283
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71284
- html: true
71285
- };
71286
- }
71287
- const msg = err instanceof Error ? err.message : String(err);
71288
- return {
71289
- text: `\u274c Could not schedule restart: ${deps.escapeHtml(msg)}`,
71290
- html: true
71291
- };
71292
- }
71293
- return {
71294
- text: [
71295
- `Switching from \`${deps.escapeHtml(currentSession)}\` back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`,
71296
- PERSIST_NOTE
71297
- ].join(`
71298
- `),
71299
- html: true
71300
- };
71780
+ if (model.toLowerCase() === "default") {
71781
+ return scheduleDefaultRelaunchReply(deps, "user: /model default (revert relaunch)");
71301
71782
  }
71302
- if (isSrModel(model)) {
71303
- try {
71304
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`);
71305
- } catch (err) {
71306
- if (isRestartInFlight(err)) {
71307
- return {
71308
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71309
- html: true
71310
- };
71311
- }
71312
- const msg = err instanceof Error ? err.message : String(err);
71313
- return {
71314
- text: `\u274c Could not schedule model switch: ${deps.escapeHtml(msg)}`,
71315
- html: true
71316
- };
71317
- }
71783
+ return scheduleRelaunchReply(deps, model, `user: /model ${model} (session-only relaunch)`);
71784
+ }
71785
+ function switchingLine(deps, model) {
71786
+ const friendly = isSrModel(model) ? srFriendlyLabel(model) : model;
71787
+ return `\uD83D\uDD04 Switching to \`${deps.escapeHtml(friendly)}\` \u2014 relaunching the session (~30s).`;
71788
+ }
71789
+ function relaunchErrorReply(deps, model, err) {
71790
+ if (isRestartInFlight(err)) {
71318
71791
  return {
71319
- text: [
71320
- `Switching to \`${deps.escapeHtml(model)}\` \u2014 restarting session (~30s).`,
71321
- PERSIST_NOTE
71322
- ].join(`
71323
- `),
71792
+ text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71324
71793
  html: true
71325
71794
  };
71326
71795
  }
71327
- const verbHtml = `\`/model ${deps.escapeHtml(model)}\``;
71328
- let result;
71796
+ const msg = err instanceof Error ? err.message : String(err);
71797
+ return { text: `\u274c Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true };
71798
+ }
71799
+ async function scheduleRelaunchReply(deps, model, reason) {
71329
71800
  try {
71330
- result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
71331
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71332
- errorPattern: MODEL_SWITCH_ERROR_RE,
71333
- settleBeforeSendMs: 1500
71334
- });
71801
+ await deps.scheduleModelRelaunch(model, reason);
71335
71802
  } catch (err) {
71336
- const msg = err instanceof Error ? err.message : String(err);
71337
- return {
71338
- text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`,
71339
- html: true
71340
- };
71803
+ return relaunchErrorReply(deps, model, err);
71341
71804
  }
71342
- if (result.outcome === "ok" || result.outcome === "ok_no_output") {
71343
- const confirmation = result.outcome === "ok" ? modelSwitchConfirmationLine(result.output) : null;
71344
- if (confirmation) {
71345
- if (isKeptModelConfirmation(confirmation)) {
71346
- return {
71347
- text: [
71348
- `${verbHtml}`,
71349
- deps.preBlock(confirmation),
71350
- ...result.truncated ? ["_truncated_"] : [],
71351
- PERSIST_NOTE
71352
- ].join(`
71353
- `),
71354
- html: true
71355
- };
71356
- }
71357
- const confirmed = sessionModelFromConfirmation(confirmation) ?? model;
71358
- return {
71359
- text: [
71360
- `${verbHtml}`,
71361
- deps.preBlock(confirmation),
71362
- ...result.truncated ? ["_truncated_"] : [],
71363
- PERSIST_NOTE
71364
- ].join(`
71365
- `),
71366
- html: true,
71367
- selectedModel: confirmed
71368
- };
71369
- }
71370
- const errLine = result.outcome === "ok" ? modelSwitchErrorLine(result.output) : null;
71371
- if (errLine) {
71372
- return {
71373
- text: [
71374
- `\u274c ${verbHtml} \u2014 the switch did not take:`,
71375
- deps.preBlock(errLine),
71376
- "Check `/model` for a valid, available model."
71377
- ].join(`
71378
- `),
71379
- html: true
71380
- };
71381
- }
71382
- const optimisticLabel = optimisticModelRecordLabel(model);
71383
- return {
71384
- text: [
71385
- `${verbHtml} \u2014 sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
71386
- PERSIST_NOTE
71387
- ].join(`
71388
- `),
71389
- html: true,
71390
- selectedModel: optimisticLabel,
71391
- optimistic: true
71392
- };
71393
- }
71394
- if (result.errorCode === "session_missing") {
71395
- return {
71396
- text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove `experimental.legacy_pty: true` if set.",
71397
- html: true
71398
- };
71805
+ return { text: [switchingLine(deps, model), PERSIST_NOTE].join(`
71806
+ `), html: true };
71807
+ }
71808
+ async function scheduleDefaultRelaunchReply(deps, reason) {
71809
+ try {
71810
+ await deps.scheduleModelDefaultRelaunch(reason);
71811
+ } catch (err) {
71812
+ return relaunchErrorReply(deps, "default", err);
71399
71813
  }
71400
71814
  return {
71401
- text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
71815
+ text: [
71816
+ "\uD83D\uDD04 Reverting to the configured default model \u2014 relaunching the session (~30s).",
71817
+ PERSIST_NOTE
71818
+ ].join(`
71819
+ `),
71402
71820
  html: true
71403
71821
  };
71404
71822
  }
@@ -71451,15 +71869,6 @@ function expandSrAlias(arg) {
71451
71869
  function srFriendlyLabel(srName) {
71452
71870
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, "").replace(/-/g, " ");
71453
71871
  }
71454
- function optimisticModelRecordLabel(token) {
71455
- if (isSrModel(token))
71456
- return token;
71457
- const lower = token.toLowerCase();
71458
- if (MODEL_ALIASES.includes(lower)) {
71459
- return lower.charAt(0).toUpperCase() + lower.slice(1);
71460
- }
71461
- return token;
71462
- }
71463
71872
  function classifyDiscoveredOptions(options) {
71464
71873
  return {
71465
71874
  claude: options.filter((o) => !o.label.startsWith("sr-") && !o.label.includes("/") && (/^[A-Z]/.test(o.label) || o.label.startsWith("claude-"))),
@@ -71467,7 +71876,19 @@ function classifyDiscoveredOptions(options) {
71467
71876
  };
71468
71877
  }
71469
71878
  function modelSelectCallbackData(label) {
71470
- return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`;
71879
+ const token = canonicalClaudeToken(label);
71880
+ if (token)
71881
+ return `${MODEL_CALLBACK_SELECT}${token}`;
71882
+ if (/^default\b/i.test(label.trim()))
71883
+ return `${MODEL_CALLBACK_SELECT}default`;
71884
+ return MODEL_CALLBACK_SELECT;
71885
+ }
71886
+ function isRecognizedSwitchToken(token) {
71887
+ if (token.toLowerCase() === "default")
71888
+ return true;
71889
+ if (isSrModel(token))
71890
+ return true;
71891
+ return canonicalClaudeToken(token) !== null;
71471
71892
  }
71472
71893
  var BUSY_REFUSAL_TEXT = "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Your tap was not applied.";
71473
71894
  function isBusyRefusalText(text4) {
@@ -71617,75 +72038,28 @@ async function handleModelMenuCallback(data, deps) {
71617
72038
  if (data === MODEL_CALLBACK_PAGE_MAIN) {
71618
72039
  return { answer: "Back", reply: await buildModelMenu(deps, "main") };
71619
72040
  }
71620
- if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
71621
- const alias = data.slice(MODEL_CALLBACK_ALIAS.length);
71622
- if (!isValidModelArg(alias)) {
71623
- return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71624
- }
71625
- if (deps.isBusy()) {
71626
- return {
71627
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71628
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71629
- toastOnly: true,
71630
- busyRefusal: true
71631
- };
71632
- }
71633
- let aliasResult;
71634
- try {
71635
- aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
71636
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71637
- errorPattern: MODEL_SWITCH_ERROR_RE,
71638
- settleBeforeSendMs: 1500
71639
- });
71640
- } catch (err) {
71641
- const msg = err instanceof Error ? err.message : String(err);
71642
- return {
71643
- answer: "Switch failed",
71644
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`)
71645
- };
71646
- }
71647
- if (aliasResult.outcome === "ok" || aliasResult.outcome === "ok_no_output") {
71648
- const confirmation = aliasResult.outcome === "ok" ? modelSwitchConfirmationLine(aliasResult.output) : null;
71649
- if (confirmation) {
71650
- const kept = isKeptModelConfirmation(confirmation);
71651
- return {
71652
- answer: confirmation,
71653
- reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
71654
- ...kept ? {} : {
71655
- selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
71656
- selectedModelToken: alias
71657
- }
71658
- };
71659
- }
71660
- const aliasErr = aliasResult.outcome === "ok" ? modelSwitchErrorLine(aliasResult.output) : null;
71661
- if (aliasErr) {
71662
- return {
71663
- answer: "Switch failed",
71664
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`)
71665
- };
71666
- }
71667
- const optimisticLabel = optimisticModelRecordLabel(alias);
71668
- return {
71669
- answer: `Sent /model ${alias} \u2014 check /status`,
71670
- reply: await menuWithBannerStatic(deps, `Sent \`/model ${deps.escapeHtml(alias)}\` \u2014 couldn\u2019t read a confirmation line. \`/status\` will show the live model once it\u2019s confirmed.`),
71671
- selectedModel: optimisticLabel,
71672
- selectedModelToken: alias
71673
- };
71674
- }
71675
- return {
71676
- answer: "Switch failed",
71677
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed \u2014 agent may be mid-turn`)
71678
- };
71679
- }
71680
72041
  if (data === MODEL_CALLBACK_HEADER) {
71681
72042
  return { answer: "Tap a model in this section to switch", reply: { text: "", html: true }, toastOnly: true };
71682
72043
  }
71683
- if (data.startsWith(MODEL_CALLBACK_SR)) {
71684
- const srName = data.slice(MODEL_CALLBACK_SR.length);
71685
- const friendlyName = srFriendlyLabel(srName);
71686
- if (!isValidModelArg(srName)) {
72044
+ if (data.startsWith(MODEL_CALLBACK_ALIAS) || data.startsWith(MODEL_CALLBACK_SR) || data.startsWith(MODEL_CALLBACK_SELECT)) {
72045
+ let token;
72046
+ let label;
72047
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
72048
+ token = data.slice(MODEL_CALLBACK_ALIAS.length);
72049
+ label = token;
72050
+ } else if (data.startsWith(MODEL_CALLBACK_SR)) {
72051
+ token = data.slice(MODEL_CALLBACK_SR.length);
72052
+ label = srFriendlyLabel(token);
72053
+ } else {
72054
+ token = data.slice(MODEL_CALLBACK_SELECT.length);
72055
+ label = token;
72056
+ }
72057
+ if (!isValidModelArg(token)) {
71687
72058
  return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71688
72059
  }
72060
+ if (!isRecognizedSwitchToken(token)) {
72061
+ return { answer: "Model list changed \u2014 menu refreshed", reply: await buildModelMenu(deps) };
72062
+ }
71689
72063
  if (deps.isBusy()) {
71690
72064
  return {
71691
72065
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
@@ -71694,87 +72068,38 @@ async function handleModelMenuCallback(data, deps) {
71694
72068
  busyRefusal: true
71695
72069
  };
71696
72070
  }
71697
- try {
71698
- await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
71699
- } catch (err) {
71700
- const msg = err instanceof Error ? err.message : String(err);
72071
+ return menuRelaunchOutcome(deps, token, label);
72072
+ }
72073
+ return { answer: "Unknown action", reply: await buildModelMenu(deps) };
72074
+ }
72075
+ async function menuRelaunchOutcome(deps, token, label) {
72076
+ const isDefault = token.toLowerCase() === "default";
72077
+ try {
72078
+ if (isDefault) {
72079
+ await deps.scheduleModelDefaultRelaunch("user: /model default (revert relaunch, menu)");
72080
+ } else {
72081
+ await deps.scheduleModelRelaunch(token, `user: /model ${token} (session-only relaunch, menu)`);
72082
+ }
72083
+ } catch (err) {
72084
+ if (isRestartInFlight(err)) {
71701
72085
  return {
71702
- answer: "Switch failed",
71703
- reply: await menuWithBannerStatic(deps, `\u274c Switch to **${deps.escapeHtml(friendlyName)}** failed: ${deps.escapeHtml(msg)}`)
72086
+ answer: "A restart is already in flight (~15s)",
72087
+ reply: await menuWithBannerStatic(deps, `\u23f3 A restart is already in flight \u2014 your switch to **${deps.escapeHtml(label)}** will apply as it completes (~15s).`)
71704
72088
  };
71705
72089
  }
72090
+ const msg = err instanceof Error ? err.message : String(err);
71706
72091
  return {
71707
- answer: `Switching to ${friendlyName} \u2014 restarting (~30s)`,
71708
- reply: await menuWithBannerStatic(deps, `\uD83D\uDD04 Switching session to **${deps.escapeHtml(friendlyName)}** \u2014 restarting (~30s).
71709
- ${PERSIST_NOTE}`),
71710
- selectedModel: srName
71711
- };
71712
- }
71713
- if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
71714
- return { answer: "Unknown action", reply: await buildModelMenu(deps) };
71715
- }
71716
- if (deps.isBusy()) {
71717
- return {
71718
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71719
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71720
- toastOnly: true,
71721
- busyRefusal: true
71722
- };
71723
- }
71724
- const tag = data.slice(MODEL_CALLBACK_SELECT.length);
71725
- const discovered = await deps.discover(deps.getAgentName());
71726
- if (!discovered.ok) {
71727
- return {
71728
- answer: "Picker unavailable",
71729
- reply: await menuWithBanner(deps, `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`)
71730
- };
71731
- }
71732
- const target = discovered.options.find((o) => labelTag(o.label) === tag);
71733
- if (!target) {
71734
- const fresh = await buildModelMenu(deps);
71735
- return { answer: "Model list changed \u2014 menu refreshed", reply: fresh };
71736
- }
71737
- const result = await deps.select(deps.getAgentName(), target.label);
71738
- if (!result.ok) {
71739
- return {
71740
- answer: "Switch failed \u2014 see the menu",
71741
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(target.label)}** failed: ${deps.escapeHtml(result.reason)}`)
71742
- };
71743
- }
71744
- if (isKeptModelConfirmation(result.confirmation)) {
71745
- return {
71746
- answer: deps.escapeHtml(result.confirmation),
71747
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`)
72092
+ answer: "Switch failed",
72093
+ reply: await menuWithBannerStatic(deps, `\u274c Switch to **${deps.escapeHtml(label)}** failed: ${deps.escapeHtml(msg)}`)
71748
72094
  };
71749
72095
  }
71750
- const token = canonicalClaudeToken(target.label);
71751
- const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined;
71752
- const clearedDefault = token == null && /^default\b/i.test(target.label.trim());
72096
+ const friendly = isDefault ? "the configured default" : label;
71753
72097
  return {
71754
- answer: deps.escapeHtml(result.confirmation),
71755
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`),
71756
- ...selectedModel ? { selectedModel } : {},
71757
- ...token ? { selectedModelToken: token } : {},
71758
- ...clearedDefault ? { clearedDefault: true } : {}
72098
+ answer: `Switching to ${isDefault ? "default" : label} \u2014 relaunching (~30s)`,
72099
+ reply: await menuWithBannerStatic(deps, `\uD83D\uDD04 Switching session to **${deps.escapeHtml(friendly)}** \u2014 relaunching (~30s).
72100
+ ${PERSIST_NOTE}`)
71759
72101
  };
71760
72102
  }
71761
- function isSrToClaudeTransition(prevModel, nextModel) {
71762
- return !!prevModel?.startsWith("sr-") && !nextModel.startsWith("sr-");
71763
- }
71764
- function modelSwitchConfirmationLine(output) {
71765
- const line = output.split(`
71766
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l));
71767
- return line && line.length > 0 ? line : null;
71768
- }
71769
- var MODEL_SWITCH_ERROR_RE = /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model|(?:[\w'\u2019.\-]+\s+){0,4}(?:(?:is |are )?(?:not available|unavailable|not enabled|not supported)|access denied|requires\b[^\n]{0,40}\b(?:subscription|plan)|no access)\b)/i;
71770
- function modelSwitchErrorLine(output) {
71771
- const line = output.split(`
71772
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_ERROR_RE.test(l));
71773
- return line && line.length > 0 ? line : null;
71774
- }
71775
- function isKeptModelConfirmation(confirmation) {
71776
- return /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*Kept model as\b/i.test(confirmation.trim());
71777
- }
71778
72103
  function canonicalClaudeToken(label) {
71779
72104
  const l = label.trim();
71780
72105
  if (l.toLowerCase().startsWith("claude-"))
@@ -71789,21 +72114,6 @@ function canonicalClaudeToken(label) {
71789
72114
  function isRestartInFlight(err) {
71790
72115
  return !!err && typeof err === "object" && err.code === "restart_in_flight";
71791
72116
  }
71792
- var MODEL_SWITCH_CONFIRMATION_PREFIX = /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*(?:Set model to|Switched to|Kept model as)\b/i;
71793
- function sessionModelFromConfirmation(confirmation) {
71794
- const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(confirmation.trim());
71795
- const name = m?.[1]?.trim();
71796
- return name && name.length > 0 ? name : null;
71797
- }
71798
- async function menuWithBanner(deps, banner) {
71799
- const fresh = await buildModelMenu(deps);
71800
- return {
71801
- text: [banner, "", fresh.text].join(`
71802
- `),
71803
- html: true,
71804
- ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
71805
- };
71806
- }
71807
72117
  async function menuWithBannerStatic(deps, banner) {
71808
72118
  const v1 = await handleModelCommand({ kind: "show" }, deps);
71809
72119
  return {
@@ -71827,6 +72137,7 @@ var MODEL_CALLBACK_ALIAS2 = "mdl:alias:";
71827
72137
  // gateway/session-model-file.ts
71828
72138
  var SESSION_MODEL_FILE = ".session-model";
71829
72139
  var CONFIGURED_DEFAULT_MODEL_FILE = ".configured-default-model";
72140
+ var SESSION_MODEL_BOOT_ATTEMPTS_FILE = ".session-model-boot-attempts";
71830
72141
  function atomicWrite(path2, content3) {
71831
72142
  const tmp = `${path2}.tmp-${process.pid}-${Date.now()}`;
71832
72143
  writeFileSync21(tmp, content3, "utf8");
@@ -71858,6 +72169,14 @@ function clearSessionModelFile(agentDir) {
71858
72169
  rmSync4(join30(agentDir, SESSION_MODEL_FILE), { force: true });
71859
72170
  } catch {}
71860
72171
  }
72172
+ function consumeSessionModelCarrierOnHealthyBoot(agentDir) {
72173
+ try {
72174
+ rmSync4(join30(agentDir, SESSION_MODEL_FILE), { force: true });
72175
+ } catch {}
72176
+ try {
72177
+ rmSync4(join30(agentDir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), { force: true });
72178
+ } catch {}
72179
+ }
71861
72180
  function restoreSessionModelFileRaw(agentDir, raw) {
71862
72181
  if (raw == null) {
71863
72182
  clearSessionModelFile(agentDir);
@@ -72215,70 +72534,6 @@ async function discoverModels(agentName3, opts = {}) {
72215
72534
  return { ok: true, options: parsed.options, currentLabel: current?.label ?? null, ...tail };
72216
72535
  });
72217
72536
  }
72218
- async function selectModel(agentName3, targetLabel, opts = {}) {
72219
- const io = makeIo(agentName3, opts);
72220
- if (!io.runner.hasSession(io.socket, io.session)) {
72221
- return { ok: false, reason: "tmux session not found" };
72222
- }
72223
- return withPaneLock(`${io.socket}:${io.session}`, async () => {
72224
- io.startedAt = Date.now();
72225
- let selected = false;
72226
- try {
72227
- const parsed = await openPickerWithRetry(io);
72228
- if (!parsed || !parsed.footerSeen) {
72229
- return { ok: false, reason: "picker did not render \u2014 agent may be mid-turn" };
72230
- }
72231
- const target = parsed.options.find((o) => o.label === targetLabel);
72232
- if (!target) {
72233
- const have = parsed.options.map((o) => o.label).join(", ");
72234
- return { ok: false, reason: `option "${targetLabel}" not offered (have: ${have})` };
72235
- }
72236
- if (parsed.cursorIndex < 0) {
72237
- return { ok: false, reason: "cursor marker not visible \u2014 refusing blind navigation" };
72238
- }
72239
- const delta = target.index - parsed.cursorIndex;
72240
- const key = delta > 0 ? "Down" : "Up";
72241
- for (let i = 0;i < Math.abs(delta); i++) {
72242
- if (expired(io))
72243
- return { ok: false, reason: "timed out navigating picker" };
72244
- sendKey(io, key);
72245
- await io.sleep(Math.min(io.stepMs, 250));
72246
- }
72247
- await io.sleep(io.stepMs);
72248
- const verifyPane = io.runner.capture(io.socket, io.session) ?? "";
72249
- const verify = parseModelPicker(verifyPane);
72250
- const atRow = verify?.options.find((o) => o.index === verify.cursorIndex);
72251
- if (!verify || !atRow || atRow.label !== targetLabel) {
72252
- return {
72253
- ok: false,
72254
- reason: `cursor verification failed (on "${atRow?.label ?? "?"}", wanted "${targetLabel}")`
72255
- };
72256
- }
72257
- sendLiteral(io, "s");
72258
- selected = true;
72259
- await io.sleep(io.stepMs);
72260
- const after = io.runner.capture(io.socket, io.session) ?? "";
72261
- const confirmation = extractConfirmation(after) ?? `Switched to ${targetLabel} (session)`;
72262
- return { ok: true, confirmation };
72263
- } catch (err) {
72264
- return { ok: false, reason: err instanceof Error ? err.message : String(err) };
72265
- } finally {
72266
- if (!selected) {
72267
- await dismissOrWarn(io, "select");
72268
- }
72269
- }
72270
- });
72271
- }
72272
- function extractConfirmation(pane) {
72273
- const lines = pane.split(`
72274
- `);
72275
- for (let i = lines.length - 1;i >= 0; i--) {
72276
- const t = lines[i].replace(/^\s*\u23bf\s*/, "").trim();
72277
- if (/^(Set model to|Kept model as|Switched to)/i.test(t))
72278
- return t;
72279
- }
72280
- return null;
72281
- }
72282
72537
 
72283
72538
  // ../src/agents/scaffold.ts
72284
72539
  import { join as join33, resolve as resolve6 } from "node:path";
@@ -72295,14 +72550,7 @@ init_merge();
72295
72550
 
72296
72551
  // ../src/agents/scaffold.ts
72297
72552
  init_merge();
72298
-
72299
- // ../src/config/timezone.ts
72300
- var CONTAINER_DEFAULT_UTC_ZONES = new Set([
72301
- "UTC",
72302
- "Etc/UTC",
72303
- "Etc/Universal",
72304
- "Universal"
72305
- ]);
72553
+ init_timezone();
72306
72554
 
72307
72555
  // ../src/cli/agent-config.ts
72308
72556
  import { join as join31 } from "node:path";
@@ -73052,6 +73300,7 @@ init_schema();
73052
73300
  init_paths();
73053
73301
  init_overlay_loader();
73054
73302
  init_merge();
73303
+ init_timezone();
73055
73304
  var import_yaml4 = __toESM(require_dist(), 1);
73056
73305
  import { readFileSync as readFileSync27, existsSync as existsSync27 } from "node:fs";
73057
73306
  import { homedir as homedir11 } from "node:os";
@@ -73179,8 +73428,30 @@ function loadConfig2(configPath) {
73179
73428
  if (notionIssues.length > 0) {
73180
73429
  throw new ConfigError2(`Invalid notion_workspace configuration in ${filePath}`, notionIssues);
73181
73430
  }
73431
+ validateAllTimezones2(config, filePath);
73182
73432
  return config;
73183
73433
  }
73434
+ function validateAllTimezones2(config, filePath) {
73435
+ const issues = [];
73436
+ const check = (zone, where) => {
73437
+ if (zone == null)
73438
+ return;
73439
+ if (!isResolvableTimezone(zone)) {
73440
+ issues.push(` ${where}: "${zone}" is not a resolvable IANA timezone ` + `(shape is valid but no such zone exists \u2014 check for a typo, ` + `e.g. "Australia/Melbourne", "America/New_York", "UTC").`);
73441
+ }
73442
+ };
73443
+ check(config.switchroom?.timezone, "switchroom.timezone");
73444
+ check(config.defaults?.timezone, "defaults.timezone");
73445
+ for (const [profileName, profile] of Object.entries(config.profiles ?? {})) {
73446
+ check(profile?.timezone, `profiles.${profileName}.timezone`);
73447
+ }
73448
+ for (const [agentName3, agentRaw] of Object.entries(config.agents)) {
73449
+ check(agentRaw?.timezone, `agents.${agentName3}.timezone`);
73450
+ }
73451
+ if (issues.length > 0) {
73452
+ throw new ConfigError2(`Invalid timezone configuration in ${filePath}`, issues);
73453
+ }
73454
+ }
73184
73455
  function validateAllCronTopicAliases2(config, filePath) {
73185
73456
  const issues = [];
73186
73457
  for (const [agentName3, agentRaw] of Object.entries(config.agents)) {
@@ -77548,17 +77819,20 @@ function computeTurnStatus(turn) {
77548
77819
  return turn.finalAnswerDelivered ? "complete" : "no_reply";
77549
77820
  }
77550
77821
  }
77551
- function backstopSendOutcome(args) {
77822
+ function backstopSendOutcomeGated(args) {
77552
77823
  if (args.threw)
77553
77824
  return "failed";
77554
77825
  if (args.chunkCount === 0)
77555
77826
  return "failed";
77556
- if (args.sentCount < args.chunkCount)
77827
+ const freshCount = args.sentIds.filter((id) => args.cardMessageId == null || id !== args.cardMessageId).length;
77828
+ if (freshCount === 0)
77829
+ return "failed";
77830
+ if (freshCount < args.chunkCount)
77557
77831
  return "failed";
77558
77832
  return "delivered";
77559
77833
  }
77560
- function finalizeBackstopSend(turn, send) {
77561
- const outcome = backstopSendOutcome(send);
77834
+ function finalizeBackstopSendGated(turn, send) {
77835
+ const outcome = backstopSendOutcomeGated(send);
77562
77836
  turn.deliveryOutcome = outcome;
77563
77837
  return outcome;
77564
77838
  }
@@ -77573,6 +77847,120 @@ function buildTurnRecord(turn, endedAt) {
77573
77847
  };
77574
77848
  }
77575
77849
 
77850
+ // gateway/backstop-delivery.ts
77851
+ class BackstopDeliveryLedger {
77852
+ latched = new Set;
77853
+ chunks = new Map;
77854
+ pending = new Map;
77855
+ claim(turnId) {
77856
+ if (this.latched.has(turnId))
77857
+ return false;
77858
+ this.latched.add(turnId);
77859
+ return true;
77860
+ }
77861
+ release(turnId) {
77862
+ this.latched.delete(turnId);
77863
+ }
77864
+ markPending(turnId, index2) {
77865
+ let set = this.pending.get(turnId);
77866
+ if (set == null) {
77867
+ set = new Set;
77868
+ this.pending.set(turnId, set);
77869
+ }
77870
+ set.add(index2);
77871
+ }
77872
+ recordChunk(turnId, index2, messageIds) {
77873
+ let m = this.chunks.get(turnId);
77874
+ if (m == null) {
77875
+ m = new Map;
77876
+ this.chunks.set(turnId, m);
77877
+ }
77878
+ m.set(index2, messageIds.slice());
77879
+ this.pending.get(turnId)?.delete(index2);
77880
+ }
77881
+ hasChunk(turnId, index2) {
77882
+ return (this.chunks.get(turnId)?.get(index2)?.length ?? 0) > 0;
77883
+ }
77884
+ sentIds(turnId) {
77885
+ const m = this.chunks.get(turnId);
77886
+ if (m == null)
77887
+ return [];
77888
+ const out = [];
77889
+ for (const index2 of Array.from(m.keys()).sort((a, b) => a - b)) {
77890
+ out.push(...m.get(index2) ?? []);
77891
+ }
77892
+ return out;
77893
+ }
77894
+ entries(turnId) {
77895
+ const m = this.chunks.get(turnId);
77896
+ if (m == null)
77897
+ return [];
77898
+ return Array.from(m.keys()).sort((a, b) => a - b).map((index2) => ({ index: index2, messageIds: (m.get(index2) ?? []).slice() }));
77899
+ }
77900
+ unsentIndices(turnId, chunkCount) {
77901
+ const out = [];
77902
+ for (let i = 0;i < chunkCount; i++) {
77903
+ if (!this.hasChunk(turnId, i))
77904
+ out.push(i);
77905
+ }
77906
+ return out;
77907
+ }
77908
+ clear(turnId) {
77909
+ this.latched.delete(turnId);
77910
+ this.chunks.delete(turnId);
77911
+ this.pending.delete(turnId);
77912
+ }
77913
+ }
77914
+ function backstopReceiptIds(sentIds, cardMessageId) {
77915
+ return sentIds.filter((id) => cardMessageId == null || id !== cardMessageId);
77916
+ }
77917
+ async function runBackstopDelivery(ledger, turnId, chunks, cardMessageId, deps, maxAttempts = 3) {
77918
+ const stderr = deps.stderr ?? (() => {});
77919
+ const chunkCount = chunks.length;
77920
+ let attempts = 0;
77921
+ for (let attempt = 1;attempt <= Math.max(1, maxAttempts); attempt++) {
77922
+ attempts = attempt;
77923
+ const resume = ledger.unsentIndices(turnId, chunkCount);
77924
+ if (resume.length === 0)
77925
+ break;
77926
+ if (attempt > 1) {
77927
+ stderr(`telegram gateway: backstop delivery retry ${attempt}/${maxAttempts} \u2014 ` + `resuming at unsent chunk(s) [${resume.join(", ")}] for turn ${turnId}
77928
+ `);
77929
+ }
77930
+ let attemptThrew = false;
77931
+ try {
77932
+ for (const i of resume) {
77933
+ if (ledger.hasChunk(turnId, i))
77934
+ continue;
77935
+ ledger.markPending(turnId, i);
77936
+ const ids = await deps.sendChunk(i, chunks[i]);
77937
+ ledger.recordChunk(turnId, i, ids);
77938
+ }
77939
+ } catch (err) {
77940
+ attemptThrew = true;
77941
+ stderr(`telegram gateway: backstop delivery attempt ${attempt}/${maxAttempts} failed: ` + `${err instanceof Error ? err.message : String(err)}
77942
+ `);
77943
+ }
77944
+ if (!attemptThrew && ledger.unsentIndices(turnId, chunkCount).length === 0)
77945
+ break;
77946
+ }
77947
+ const sentIds = ledger.sentIds(turnId);
77948
+ const delivered = chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0 && backstopReceiptIds(sentIds, cardMessageId).length > 0;
77949
+ const exhausted = !delivered;
77950
+ if (deps.recordOutbound && sentIds.length > 0) {
77951
+ const texts = [];
77952
+ const ids = [];
77953
+ for (const { index: index2, messageIds } of ledger.entries(turnId)) {
77954
+ for (const id of messageIds) {
77955
+ ids.push(id);
77956
+ texts.push(chunks[index2] ?? "");
77957
+ }
77958
+ }
77959
+ deps.recordOutbound(ids, texts);
77960
+ }
77961
+ return { sentIds, chunkCount, delivered, attempts, exhausted };
77962
+ }
77963
+
77576
77964
  // gateway/inbound-delivery-confirm.ts
77577
77965
  function createDeliveryQueue() {
77578
77966
  return { pending: new Map };
@@ -84182,10 +84570,10 @@ function effectiveTurnAgeMs(markerAgeMs, turnStartedAt, now) {
84182
84570
  }
84183
84571
 
84184
84572
  // ../src/build-info.ts
84185
- var VERSION = "0.18.28";
84186
- var COMMIT_SHA = "ef0be9a2";
84187
- var COMMIT_DATE = "2026-07-16T14:42:35+10:00";
84188
- var LATEST_PR = 3274;
84573
+ var VERSION = "0.18.30";
84574
+ var COMMIT_SHA = "7729c674";
84575
+ var COMMIT_DATE = "2026-07-17T10:52:43+10:00";
84576
+ var LATEST_PR = 3293;
84189
84577
  var COMMITS_AHEAD_OF_TAG = 0;
84190
84578
 
84191
84579
  // gateway/boot-version.ts
@@ -86216,6 +86604,7 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
86216
86604
  var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
86217
86605
  var bot = new import_grammy13.Bot(TOKEN);
86218
86606
  installTgPostLogger(bot);
86607
+ installUpdateTap(bot, (line) => process.stderr.write(line));
86219
86608
  var lastGetUpdatesHeartbeatMs = Date.now();
86220
86609
  bot.api.config.use(async (prev, method, payload, signal) => {
86221
86610
  try {
@@ -86743,6 +87132,73 @@ var deferredDoneReactions = new DeferredDoneReactions({
86743
87132
  });
86744
87133
  var outboundDedup = new OutboundDedupCache;
86745
87134
  var flushedTurnSupersede = new FlushedTurnSupersedeRegistry;
87135
+ var backstopDeliveryLedger = new BackstopDeliveryLedger;
87136
+ var BACKSTOP_DELIVERY_MAX_ATTEMPTS = (() => {
87137
+ const raw = Number(process.env.SWITCHROOM_BACKSTOP_DELIVERY_MAX_ATTEMPTS);
87138
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
87139
+ })();
87140
+ async function deliverAnswer(args) {
87141
+ const { chatId, turnId } = args;
87142
+ const rendered = addParagraphSpacers2(args.text);
87143
+ const chunks = splitMarkdownChunks2(rendered, RICH_MESSAGE_MAX_CHARS2);
87144
+ const deps = {
87145
+ sendRich: (opts, body, tid) => robustApiCall(() => bot.api.sendRichMessage(chatId, body, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical" }),
87146
+ sendLiteral: (opts, txt, tid) => robustApiCall(() => bot.api.sendMessage(chatId, txt, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical" }),
87147
+ sendLiteralRaw: (opts, txt) => bot.api.sendMessage(chatId, txt, opts),
87148
+ sendRichRaw: (opts, body) => bot.api.sendRichMessage(chatId, body, opts),
87149
+ editPreview: (mid, body, opts, tid) => robustApiCall(() => bot.api.editMessageText(chatId, mid, body, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical", messageId: mid, editPayload: body }),
87150
+ richMessage: richMessage2,
87151
+ logOutbound,
87152
+ deleteStalePreview: async (id) => {
87153
+ await swallowingApiCall(() => bot.api.deleteMessage(chatId, id), { chat_id: chatId, verb: "deliverAnswer.deleteStalePreview" });
87154
+ },
87155
+ stderr: (s) => {
87156
+ process.stderr.write(s);
87157
+ }
87158
+ };
87159
+ let liveThreadId = args.threadId;
87160
+ const sendChunk = async (_chunkIndex, text5) => {
87161
+ const chunkIds = [];
87162
+ const res = await sendReplyChunks(deps, {
87163
+ chatId,
87164
+ chunks: [text5],
87165
+ literalText: false,
87166
+ suppressText: false,
87167
+ threadId: liveThreadId,
87168
+ previewMessageId: null,
87169
+ sentIds: chunkIds,
87170
+ buildSendOpts: (_i, _isLast, tid) => ({
87171
+ ...tid != null ? { message_thread_id: tid } : {},
87172
+ link_preview_options: { is_disabled: true }
87173
+ }),
87174
+ buildPreviewEditOpts: () => ({})
87175
+ });
87176
+ liveThreadId = res.threadId;
87177
+ return chunkIds;
87178
+ };
87179
+ const result = await runBackstopDelivery(backstopDeliveryLedger, turnId, chunks, args.cardMessageId, {
87180
+ sendChunk,
87181
+ recordOutbound: HISTORY_ENABLED ? (messageIds, texts) => {
87182
+ try {
87183
+ recordOutbound({
87184
+ chat_id: chatId,
87185
+ thread_id: args.threadId ?? null,
87186
+ message_ids: messageIds,
87187
+ texts
87188
+ });
87189
+ } catch {}
87190
+ } : undefined,
87191
+ stderr: (s) => {
87192
+ process.stderr.write(s);
87193
+ }
87194
+ }, BACKSTOP_DELIVERY_MAX_ATTEMPTS);
87195
+ return {
87196
+ sentIds: result.sentIds,
87197
+ chunkCount: result.chunkCount,
87198
+ delivered: result.delivered,
87199
+ exhausted: result.exhausted
87200
+ };
87201
+ }
86746
87202
  var chatAvailableReactions = new Map;
86747
87203
  var chatProbesInFlight = new Set;
86748
87204
  var activeTurnStartedAt = new Map;
@@ -87644,7 +88100,7 @@ function endCurrentTurnAtomic(turn, opts) {
87644
88100
  process.stderr.write(`telegram gateway: status-surface DEGRADED reason=${degraded.reason} turnId=${turn.turnId} chat=${turn.sessionChatId} thread=${turn.sessionThreadId ?? "-"} ${degraded.detail}
87645
88101
  `);
87646
88102
  }
87647
- if (OBLIGATION_LEDGER_ENABLED) {
88103
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
87648
88104
  if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === "close") {
87649
88105
  obligationLedger.close(turn.turnId);
87650
88106
  } else {
@@ -87658,6 +88114,7 @@ function endCurrentTurnAtomic(turn, opts) {
87658
88114
  turn.narrativeGate?.teardown();
87659
88115
  purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn);
87660
88116
  armNoReplyDrainTimer(turn);
88117
+ flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled);
87661
88118
  return turnEndedAt;
87662
88119
  }
87663
88120
  function maybeProactiveCompact() {
@@ -89103,7 +89560,12 @@ function emitGatewayOperatorEvent(event) {
89103
89560
  const opEventSupergroup = resolveAgentSupergroupChatId();
89104
89561
  process.stderr.write(`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` + (opEventTopic != null ? ` topic=${opEventTopic}` : "") + `
89105
89562
  `);
89106
- for (const chat_id of access.allowFrom) {
89563
+ const { operatorChats, userNoticeChats } = decideOperatorEventAudience(kind, access.allowFrom, access.allowFrom[0]);
89564
+ if (userNoticeChats.length > 0) {
89565
+ process.stderr.write(`telegram gateway: operator-event operator-only routing agent=${agent} kind=${kind} operatorChats=${operatorChats.length} userNoticeChats=${userNoticeChats.length}
89566
+ `);
89567
+ }
89568
+ for (const chat_id of operatorChats) {
89107
89569
  const opEventThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: opEventTopic, supergroupChatId: opEventSupergroup });
89108
89570
  const opts = {
89109
89571
  ...renderedKeyboard ? { reply_markup: renderedKeyboard } : {},
@@ -89123,6 +89585,38 @@ function emitGatewayOperatorEvent(event) {
89123
89585
  `);
89124
89586
  });
89125
89587
  }
89588
+ if (userNoticeChats.length > 0) {
89589
+ pendingUserNoticeGate.schedule({
89590
+ chatIds: userNoticeChats,
89591
+ text: renderUserFacingFailureNotice(),
89592
+ agent,
89593
+ kind,
89594
+ atMs: Date.now()
89595
+ });
89596
+ process.stderr.write(`telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length}
89597
+ `);
89598
+ }
89599
+ }
89600
+ function flushPendingUserFailureNotices(turnDeliveredReply) {
89601
+ const notices = pendingUserNoticeGate.resolveTurnEnd(turnDeliveredReply);
89602
+ if (notices.length === 0)
89603
+ return;
89604
+ const noticeTopic = resolveAgentOutboundTopic({ kind: "compact-watchdog" });
89605
+ const noticeSupergroup = resolveAgentSupergroupChatId();
89606
+ for (const notice of notices) {
89607
+ process.stderr.write(`telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}
89608
+ `);
89609
+ for (const chat_id of notice.chatIds) {
89610
+ const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup });
89611
+ const opts = {
89612
+ ...thread != null ? { message_thread_id: thread } : {}
89613
+ };
89614
+ bot.api.sendRichMessage(chat_id, richMessage2(notice.text), opts).catch((e) => {
89615
+ process.stderr.write(`telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}
89616
+ `);
89617
+ });
89618
+ }
89619
+ }
89126
89620
  }
89127
89621
  function postLegacyBanner(chatId, threadId, ackMessageId, ageSec, site) {
89128
89622
  const text5 = `\uD83C\uDF9B\uFE0F Switchroom restarted \u2014 ready. (took ~${ageSec}s)`;
@@ -89633,6 +90127,11 @@ function ensureIssuesCard(chatId, threadId) {
89633
90127
  }
89634
90128
  process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}
89635
90129
  `);
90130
+ {
90131
+ const carrierAgentDir = resolveAgentDirFromEnv();
90132
+ if (carrierAgentDir != null)
90133
+ consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir);
90134
+ }
89636
90135
  runBootPinCleanupAndDmSweep();
89637
90136
  } catch (err) {
89638
90137
  process.stderr.write(`telegram gateway: boot.lock_acquire_failed err=${err.message} agent=${SWITCHROOM_AGENT_NAME}
@@ -89641,6 +90140,11 @@ function ensureIssuesCard(chatId, threadId) {
89641
90140
  writePidFile(GATEWAY_PID_PATH, { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS });
89642
90141
  process.stderr.write(`telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS} (mutex-fallback)
89643
90142
  `);
90143
+ {
90144
+ const carrierAgentDir = resolveAgentDirFromEnv();
90145
+ if (carrierAgentDir != null)
90146
+ consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir);
90147
+ }
89644
90148
  runBootPinCleanupAndDmSweep();
89645
90149
  } catch (writeErr) {
89646
90150
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}
@@ -93156,7 +93660,7 @@ async function executeGetRecentMessages(args) {
93156
93660
  const rows = query({ chat_id, thread_id, limit, before_message_id });
93157
93661
  const summary = rows.map((r) => {
93158
93662
  const who = r.role === "user" ? r.user ?? "user" : "assistant";
93159
- const time = new Date(r.ts * 1000).toISOString();
93663
+ const time = fmtLocalStamp2(r.ts * 1000, resolveEnvTimezone2());
93160
93664
  const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : "";
93161
93665
  const replyCtx = r.reply_to_message_id != null ? ` \u21AA\uFE0F#${r.reply_to_message_id}${r.reply_to_text ? `:"${r.reply_to_text.slice(0, 60)}${r.reply_to_text.length > 60 ? "\u2026" : ""}"` : ""}` : "";
93162
93666
  const reactionTag = r.user_reaction ? ` [reaction: ${r.user_reaction}]` : "";
@@ -94288,16 +94792,15 @@ function handleSessionEvent(ev) {
94288
94792
  }
94289
94793
  turn.finalAnswerDelivered = true;
94290
94794
  turn.finalAnswerSubstantive = true;
94291
- if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
94292
- turn.answerDelivered = true;
94293
- }
94795
+ turn.answerDelivered = true;
94796
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId);
94294
94797
  const cardTakeover = progressDriver?.takeOverCard({
94295
94798
  chatId: backstopChatId,
94296
94799
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
94297
94800
  }) ?? { wasEmitted: false, turnKey: null };
94298
94801
  const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
94299
94802
  const backstopCardTurnKey = cardTakeover.turnKey;
94300
- const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true });
94803
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true });
94301
94804
  preambleSuppressor.dropNow();
94302
94805
  {
94303
94806
  const tKey = statusKey(chatId, threadId);
@@ -94316,80 +94819,47 @@ function handleSessionEvent(ev) {
94316
94819
  `);
94317
94820
  if (backstopTurnEndedAt != null) {
94318
94821
  turn.deliveryOutcome = "suppressed";
94822
+ if (OBLIGATION_LEDGER_ENABLED)
94823
+ obligationLedger.close(turn.turnId);
94319
94824
  emitTurnRecord(turn, backstopTurnEndedAt);
94320
94825
  }
94321
94826
  return;
94322
94827
  }
94323
94828
  } catch {}
94324
94829
  }
94830
+ if (!backstopLatchClaimed) {
94831
+ process.stderr.write(`telegram gateway: turn-flush skipped \u2014 turn ${turn.turnId} already claimed the delivery latch
94832
+ `);
94833
+ return;
94834
+ }
94325
94835
  process.stderr.write(`telegram gateway: turn-flush firing \u2014 ${capturedText.length} chars without reply tool (chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? "none"})
94326
94836
  `);
94327
- const sendOpts = {
94328
- ...backstopThreadId != null ? { message_thread_id: backstopThreadId } : {},
94329
- link_preview_options: { is_disabled: true }
94330
- };
94331
- const limit = RICH_MESSAGE_MAX_CHARS2;
94332
- let htmlChunks = [];
94333
- const sentIds = [];
94334
- let sendThrew = false;
94837
+ let sentIds = [];
94838
+ let chunkCount = 0;
94839
+ let delivered = false;
94335
94840
  try {
94336
- const renderedText = addParagraphSpacers2(capturedText);
94337
- htmlChunks = splitMarkdownChunks2(renderedText, limit);
94338
- let firstSendUsedEdit = false;
94339
- let liveThreadId = backstopThreadId;
94340
- if (backstopCardMessageId != null && htmlChunks.length > 0) {
94341
- try {
94342
- await robustApiCall(() => bot.api.editMessageText(backstopChatId, backstopCardMessageId, richMessage2(htmlChunks[0]), sendOpts), {
94343
- chat_id: backstopChatId,
94344
- verb: "turn-flush.editMessageText",
94345
- ...liveThreadId != null ? { threadId: liveThreadId } : {}
94346
- });
94347
- sentIds.push(backstopCardMessageId);
94348
- firstSendUsedEdit = true;
94349
- } catch (err) {
94350
- process.stderr.write(`telegram gateway: turn-flush card-takeover edit failed: ${err.message} \u2014 falling back to sendMessage
94351
- `);
94352
- if (err instanceof Error && err.message === "THREAD_NOT_FOUND") {
94353
- liveThreadId = undefined;
94354
- }
94355
- }
94356
- }
94357
- const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks;
94358
- for (const c of remainingChunks) {
94359
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
94360
- const opts = {
94361
- link_preview_options: { is_disabled: true },
94362
- ...tid != null ? { message_thread_id: tid } : {}
94363
- };
94364
- return bot.api.sendRichMessage(backstopChatId, richMessage2(c), opts);
94365
- }, {
94366
- threadId: liveThreadId,
94367
- chat_id: backstopChatId,
94368
- verb: "turn-flush.sendMessage"
94369
- });
94370
- if (liveThreadId != null) {
94371
- const sentMsg = sent;
94372
- if (sentMsg.message_thread_id == null)
94373
- liveThreadId = undefined;
94374
- }
94375
- sentIds.push(sent.message_id);
94376
- }
94377
- if (HISTORY_ENABLED && sentIds.length > 0) {
94378
- try {
94379
- recordOutbound({
94380
- chat_id: backstopChatId,
94381
- thread_id: backstopThreadId ?? null,
94382
- message_ids: sentIds,
94383
- texts: htmlChunks
94384
- });
94385
- } catch {}
94386
- }
94841
+ const delivery = await deliverAnswer({
94842
+ chatId: backstopChatId,
94843
+ threadId: backstopThreadId,
94844
+ text: capturedText,
94845
+ turnId: turn.turnId,
94846
+ cardMessageId: backstopCardMessageId
94847
+ });
94848
+ sentIds = delivery.sentIds;
94849
+ chunkCount = delivery.chunkCount;
94850
+ delivered = delivery.delivered;
94387
94851
  outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
94388
94852
  if (sentIds.length > 0) {
94389
94853
  flushedTurnSupersede.record(backstopChatId, backstopThreadId, { turnId: turn.turnId, messageIds: sentIds, text: capturedText }, Date.now());
94390
94854
  }
94391
- if (backstopCtrl)
94855
+ if (!delivered) {
94856
+ if (backstopCtrl)
94857
+ backstopCtrl.finalize("error");
94858
+ backstopDeliveryLedger.release(turn.turnId);
94859
+ turn.answerDelivered = false;
94860
+ } else if (backstopCtrl) {
94392
94861
  backstopCtrl.finalize("done");
94862
+ }
94393
94863
  if (backstopCardTurnKey != null) {
94394
94864
  completeProgressCardTurn?.({
94395
94865
  chatId: backstopChatId,
@@ -94400,21 +94870,33 @@ function handleSessionEvent(ev) {
94400
94870
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId);
94401
94871
  }
94402
94872
  } catch (err) {
94403
- sendThrew = true;
94404
- process.stderr.write(`telegram gateway: turn-flush send failed: ${err.message}
94873
+ process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${err.message}
94405
94874
  `);
94406
- turn.answerDelivered = false;
94407
- if (backstopCtrl)
94408
- backstopCtrl.finalize("error");
94875
+ if (!delivered) {
94876
+ turn.answerDelivered = false;
94877
+ backstopDeliveryLedger.release(turn.turnId);
94878
+ if (backstopCtrl)
94879
+ backstopCtrl.finalize("error");
94880
+ }
94409
94881
  } finally {
94410
94882
  if (backstopTurnEndedAt != null) {
94411
- finalizeBackstopSend(turn, {
94412
- threw: sendThrew,
94413
- sentCount: sentIds.length,
94414
- chunkCount: htmlChunks.length
94883
+ finalizeBackstopSendGated(turn, {
94884
+ threw: !delivered,
94885
+ sentIds,
94886
+ chunkCount,
94887
+ cardMessageId: backstopCardMessageId
94415
94888
  });
94889
+ if (OBLIGATION_LEDGER_ENABLED) {
94890
+ if (delivered) {
94891
+ obligationLedger.close(turn.turnId);
94892
+ } else {
94893
+ turn.finalAnswerDelivered = false;
94894
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now());
94895
+ }
94896
+ }
94416
94897
  emitTurnRecord(turn, backstopTurnEndedAt);
94417
94898
  }
94899
+ backstopDeliveryLedger.clear(turn.turnId);
94418
94900
  }
94419
94901
  })();
94420
94902
  return;
@@ -95469,7 +95951,7 @@ ${preBlock(write.output)}`;
95469
95951
  ...msgId != null ? { message_id: String(msgId) } : {},
95470
95952
  user: displayUser,
95471
95953
  user_id: String(from.id),
95472
- ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
95954
+ ts: fmtLocalStamp2((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone2()),
95473
95955
  ...messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {},
95474
95956
  ...originTurnId != null ? { origin_turn_id: originTurnId } : {},
95475
95957
  ...topicScope != null ? { topic_scope: topicScope } : {},
@@ -96256,9 +96738,10 @@ async function dispatchShortVerbViaHostd(ctx, req, label, legacyArgs) {
96256
96738
  await switchroomReply(ctx, `\u274C **${escapeHtmlForTg2(label)} failed via hostd** (result=${escapeHtmlForTg2(hostdResp.result)}):
96257
96739
  ` + preBlock(stripAnsi3(errBody)), { html: true });
96258
96740
  }
96259
- async function runSwitchroomCommand(ctx, args, label, classification = "query") {
96741
+ async function runSwitchroomCommand(ctx, args, label, classification = "query", transformOutput) {
96260
96742
  try {
96261
- const output = stripAnsi3(switchroomExec(args));
96743
+ const stripped = stripAnsi3(switchroomExec(args));
96744
+ const output = transformOutput ? transformOutput(stripped) : stripped;
96262
96745
  const formatted = formatSwitchroomOutput(output);
96263
96746
  if (formatted) {
96264
96747
  await switchroomReply(ctx, preBlock(formatted), { html: true, classification });
@@ -96611,7 +97094,6 @@ function buildModelDeps(restartCtx) {
96611
97094
  return [];
96612
97095
  }
96613
97096
  },
96614
- select: (a, label) => selectModel(a, label),
96615
97097
  isBusy: () => currentTurn !== null,
96616
97098
  getAgentName: getMyAgentName,
96617
97099
  getQuotaBrief: async () => {
@@ -96630,14 +97112,12 @@ function buildModelDeps(restartCtx) {
96630
97112
  } catch {}
96631
97113
  return null;
96632
97114
  },
96633
- inject: injectSlashCommand,
96634
97115
  getConfiguredModel: () => {
96635
97116
  const data = switchroomExecJson(["agent", "list"]);
96636
97117
  return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
96637
97118
  },
96638
97119
  escapeHtml: escapeHtmlForTg2,
96639
97120
  preBlock,
96640
- getActiveSessionModel: () => sessionModelSource.getOverride(),
96641
97121
  scheduleRestart: async (reason) => {
96642
97122
  const name = getMyAgentName();
96643
97123
  const existing = readRestartMarker();
@@ -96680,6 +97160,30 @@ function buildModelDeps(restartCtx) {
96680
97160
  const prevFileRaw = readSessionModelFileRaw(agentDir);
96681
97161
  writeSessionModelFile(agentDir, model, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
96682
97162
  sessionModelSource.setOverride(model);
97163
+ process.stderr.write(`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=${model} reason=${JSON.stringify(reason)}
97164
+ `);
97165
+ clearPremiumRecoveryOnManualSwitch(model);
97166
+ try {
97167
+ await deps.scheduleRestart(reason);
97168
+ } catch (err) {
97169
+ if (err?.code !== "restart_in_flight") {
97170
+ restoreSessionModelFileRaw(agentDir, prevFileRaw);
97171
+ sessionModelSource.setOverride(prevOverride);
97172
+ }
97173
+ throw err;
97174
+ }
97175
+ },
97176
+ scheduleModelDefaultRelaunch: async (reason) => {
97177
+ const agentDir = resolveAgentDirFromEnv();
97178
+ if (!agentDir)
97179
+ throw new Error("agent dir unresolvable \u2014 cannot clear session-model file");
97180
+ const prevOverride = sessionModelSource.getOverride();
97181
+ const prevFileRaw = readSessionModelFileRaw(agentDir);
97182
+ clearSessionModelFile(agentDir);
97183
+ sessionModelSource.setOverride(null);
97184
+ process.stderr.write(`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=(default) reason=${JSON.stringify(reason)}
97185
+ `);
97186
+ clearPremiumRecoveryOnManualSwitch(null);
96683
97187
  try {
96684
97188
  await deps.scheduleRestart(reason);
96685
97189
  } catch (err) {
@@ -96704,60 +97208,6 @@ function modelMenuReplyMarkup(reply) {
96704
97208
  }
96705
97209
  return kb;
96706
97210
  }
96707
- function recordTypedModelSwitch(reply, requestedModelArg, _deps) {
96708
- const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null;
96709
- if (requested?.toLowerCase() === "default") {
96710
- const smDir = resolveAgentDirFromEnv();
96711
- if (smDir)
96712
- clearSessionModelFile(smDir);
96713
- if (reply.selectedModel)
96714
- sessionModelSource.setOverride(null);
96715
- return "";
96716
- }
96717
- if (!reply.selectedModel)
96718
- return "";
96719
- sessionModelSource.setOverride(reply.selectedModel);
96720
- clearPremiumRecoveryOnManualSwitch(reply.selectedModel);
96721
- return "";
96722
- }
96723
- function recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel) {
96724
- if (outcome.selectedModel) {
96725
- sessionModelSource.setOverride(outcome.selectedModel);
96726
- clearPremiumRecoveryOnManualSwitch(outcome.selectedModel);
96727
- }
96728
- if (outcome.clearedDefault) {
96729
- const smDir = resolveAgentDirFromEnv();
96730
- if (smDir)
96731
- clearSessionModelFile(smDir);
96732
- }
96733
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
96734
- const agentName3 = getMyAgentName();
96735
- const agentDir = resolveAgentDirFromEnv();
96736
- const token = outcome.selectedModelToken;
96737
- if (agentDir && token) {
96738
- try {
96739
- writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
96740
- sessionModelSource.setOverride(token);
96741
- } catch (e) {
96742
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
96743
- `);
96744
- }
96745
- } else if (agentDir) {
96746
- clearSessionModelFile(agentDir);
96747
- }
96748
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
96749
- stampUserRestartReason("user: sr-to-claude model switch (menu)");
96750
- if (turnInFlightForGate()) {
96751
- pendingRestarts.set(agentName3, Date.now());
96752
- } else {
96753
- sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
96754
- }
96755
- return {
96756
- restartNotice: `\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`
96757
- };
96758
- }
96759
- return {};
96760
- }
96761
97211
  async function editPendingCommandCard(chatId, messageId, text5) {
96762
97212
  try {
96763
97213
  await robustApiCall(() => lockedBot.api.editMessageText(chatId, messageId, richMessage2(hardenCardBreaks2(text5)), {
@@ -96779,14 +97229,11 @@ function enqueueSessionCommand(cmd) {
96779
97229
  async function applyQueuedModelCommand(cmd) {
96780
97230
  const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId });
96781
97231
  if (cmd.origin === "menu") {
96782
- const prevSessionModel = sessionModelSource.getOverride();
96783
97232
  const outcome = await handleModelMenuCallback(cmd.arg, deps);
96784
- const { restartNotice } = recordModelMenuSideEffects(outcome, deps, cmd.chatId, cmd.threadId, prevSessionModel);
96785
- return restartNotice ?? outcome.reply.text;
97233
+ return outcome.reply.text;
96786
97234
  }
96787
97235
  const reply = await handleModelCommand({ kind: "set", model: cmd.arg }, deps);
96788
- const warning = recordTypedModelSwitch(reply, cmd.arg, deps);
96789
- return reply.text + warning;
97236
+ return reply.text;
96790
97237
  }
96791
97238
  async function applyQueuedEffortCommand(cmd) {
96792
97239
  const deps = buildEffortDeps();
@@ -96924,8 +97371,7 @@ bot.command("model", async (ctx) => {
96924
97371
  return;
96925
97372
  }
96926
97373
  const reply = await handleModelCommand(parsed, deps);
96927
- const persistWarning = recordTypedModelSwitch(reply, parsed.kind === "set" ? parsed.model : null, deps);
96928
- await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html });
97374
+ await switchroomReply(ctx, reply.text, { html: reply.html });
96929
97375
  });
96930
97376
  var sessionEffortOverride = null;
96931
97377
  function buildEffortDeps() {
@@ -98682,7 +99128,8 @@ bot.command("logs", async (ctx) => {
98682
99128
  }
98683
99129
  const lines = linesArg ? parseInt(linesArg, 10) : 20;
98684
99130
  const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200);
98685
- await runSwitchroomCommand(ctx, ["agent", "logs", name, "--lines", String(lineCount)], `logs ${name}`, "heavy");
99131
+ const tz = resolveEnvTimezone2();
99132
+ await runSwitchroomCommand(ctx, ["agent", "logs", name, "--lines", String(lineCount), "--timestamps"], `logs ${name}`, "heavy", (raw) => renderLogTimestampsLocal(raw, tz));
98686
99133
  });
98687
99134
  bot.command("memory", async (ctx) => {
98688
99135
  if (!isAuthorizedSender(ctx))
@@ -98979,31 +99426,10 @@ bot.on("callback_query:data", async (ctx) => {
98979
99426
  }
98980
99427
  const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN;
98981
99428
  await ctx.answerCallbackQuery({ text: isPageNav ? "Loading\u2026" : "Switching\u2026" }).catch(() => {});
98982
- if (data.startsWith(MODEL_CALLBACK_SR)) {
98983
- const srName = data.slice(MODEL_CALLBACK_SR.length);
98984
- const srLabel = escapeHtmlForTg2(srFriendlyLabel(srName));
98985
- if (!isValidModelArg(srName)) {
98986
- await ctx.editMessageText(richMessage2("\u274C Invalid model name"), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98987
- return;
98988
- }
98989
- await ctx.editMessageText(richMessage2(`\uD83D\uDD04 Switching session to **${srLabel}** \u2014 restarting (~30s). _Session-only; reverts to the configured default on the next restart._`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98990
- try {
98991
- await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
98992
- } catch (err) {
98993
- await ctx.editMessageText(richMessage2(`\u274C Could not switch to **${srLabel}**: ${escapeHtmlForTg2(err?.message ?? String(err))}`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98994
- }
98995
- return;
98996
- }
98997
99429
  try {
98998
- const prevSessionModel = sessionModelSource.getOverride();
98999
99430
  const outcome = await handleModelMenuCallback(data, modelDeps);
99000
99431
  if (outcome.toastOnly)
99001
99432
  return;
99002
- const { restartNotice } = recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel);
99003
- if (restartNotice) {
99004
- await ctx.editMessageText(richMessage2(restartNotice), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
99005
- return;
99006
- }
99007
99433
  await ctx.editMessageText(richMessage2(outcome.reply.text), {
99008
99434
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
99009
99435
  }).catch(() => {});
@@ -100099,6 +100525,7 @@ bot.on("message:pinned_message", async (ctx) => {
100099
100525
  `);
100100
100526
  }
100101
100527
  });
100528
+ installUnhandledMessageCatchAll(bot, (ctx, text5) => handleInboundCoalesced(ctx, text5, undefined), (line) => process.stderr.write(line));
100102
100529
  var reactionsCfg = null;
100103
100530
  var reactionHourCap = null;
100104
100531
  var reactionDebounce = null;
@@ -100708,6 +101135,8 @@ var didOneTimeSetup = false;
100708
101135
  `);
100709
101136
  }
100710
101137
  } catch {}
101138
+ let modelSwitchMarkerChat = null;
101139
+ let modelSwitchReason = null;
100711
101140
  try {
100712
101141
  const nowMs2 = Date.now();
100713
101142
  const marker = readRestartMarker();
@@ -100737,6 +101166,12 @@ var didOneTimeSetup = false;
100737
101166
  const ageSec = Math.max(1, Math.round(ageMs / 1000));
100738
101167
  process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 300000}
100739
101168
  `);
101169
+ if (ageMs < 300000) {
101170
+ modelSwitchMarkerChat = { chatId: marker.chat_id, threadId: marker.thread_id };
101171
+ if (typeof cleanMarker?.reason === "string" && cleanMarker.reason.startsWith("user: /model")) {
101172
+ modelSwitchReason = cleanMarker.reason;
101173
+ }
101174
+ }
100740
101175
  clearRestartMarker();
100741
101176
  }
100742
101177
  if (!isRealRestart) {
@@ -100762,7 +101197,11 @@ var didOneTimeSetup = false;
100762
101197
  firstSeenAt: new Date
100763
101198
  });
100764
101199
  }
100765
- if (target) {
101200
+ const suppressBootCardForModelSwitch = modelSwitchReason != null && modelSwitchMarkerChat != null;
101201
+ if (target && suppressBootCardForModelSwitch) {
101202
+ process.stderr.write(`telegram gateway: boot: suppressing generic boot card \u2014 /model switch apply-boot (reason=${JSON.stringify(modelSwitchReason)}); the confirmation card replaces it
101203
+ `);
101204
+ } else if (target) {
100766
101205
  const { chatId, threadId, ackMsgId } = target;
100767
101206
  const bootDedupe = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, "boot");
100768
101207
  if (bootDedupe.skip) {
@@ -100841,7 +101280,30 @@ var didOneTimeSetup = false;
100841
101280
  const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
100842
101281
  return resolveMainModel(raw ?? undefined);
100843
101282
  })();
100844
- sessionModelSource.setOverride(launched.length > 0 && launched !== configured ? launched : null);
101283
+ const isApplyBoot = launched.length > 0 && launched !== configured;
101284
+ sessionModelSource.setOverride(isApplyBoot ? launched : null);
101285
+ process.stderr.write(`telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || "(none)"} configured=${configured} override=${isApplyBoot ? "set" : "cleared"}
101286
+ `);
101287
+ if (modelSwitchReason != null && modelSwitchMarkerChat) {
101288
+ const chat = modelSwitchMarkerChat;
101289
+ const confirmation = classifyModelSwitchConfirmation({
101290
+ reason: modelSwitchReason,
101291
+ launched,
101292
+ configured
101293
+ });
101294
+ const hasSessionModelAlert = existsSync50(join55(smAgentDir, ".session-model-alert"));
101295
+ if (confirmation.kind === "not-applied" && hasSessionModelAlert) {
101296
+ process.stderr.write(`telegram gateway: gw /model relaunch applied \u2014 suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=${getMyAgentName()} target=${confirmation.target}
101297
+ `);
101298
+ } else {
101299
+ const body = confirmation.kind === "applied" ? `\u2705 Now running \`${confirmation.launched}\` \u2014 session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.` : confirmation.kind === "not-applied" ? `\u26A0\uFE0F Your switch to \`${confirmation.target}\` didn't apply \u2014 the agent reverted to \`${confirmation.revertedTo}\` (the apply-boot didn't complete). Re-issue \`/model ${confirmation.target}\` to try again.` : `\u2705 Now running \`${confirmation.launched}\` (the configured default) \u2014 fresh session; memory and the handoff briefing carry the context.`;
101300
+ lockedBot.api.sendMessage(chat.chatId, body, {
101301
+ parse_mode: "Markdown",
101302
+ ...chat.threadId != null ? { message_thread_id: chat.threadId } : {}
101303
+ }).catch((err) => process.stderr.write(`telegram gateway: model-switch confirmation send failed: ${err?.message ?? String(err)}
101304
+ `));
101305
+ }
101306
+ }
100845
101307
  } catch {}
100846
101308
  }
100847
101309
  const activeEffortPath = join55(smAgentDir, ".active-session-effort");