switchroom 0.13.33 → 0.13.36

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 (34) hide show
  1. package/bin/timezone-hook.sh +1 -1
  2. package/dist/agent-scheduler/index.js +8 -1
  3. package/dist/auth-broker/index.js +8 -1
  4. package/dist/cli/switchroom.js +176 -26
  5. package/dist/host-control/main.js +5222 -203
  6. package/dist/vault/approvals/kernel-server.js +9 -2
  7. package/dist/vault/broker/server.js +9 -2
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +1 -1
  10. package/telegram-plugin/dist/gateway/gateway.js +234 -31
  11. package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
  12. package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
  13. package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
  14. package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
  15. package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
  16. package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
  17. package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
  18. package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
  19. package/telegram-plugin/gateway/gateway.ts +112 -15
  20. package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
  21. package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
  22. package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
  24. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
  25. package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
  26. package/telegram-plugin/pending-work-progress.ts +37 -1
  27. package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
  28. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
  29. package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
  30. package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
  33. package/telegram-plugin/tests/silent-end.test.ts +227 -38
  34. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Render a one-tap unlock card for hostd error_envelopes that carry a
3
+ * `flip_yaml_flag` fix (#1758 Phase 1).
4
+ *
5
+ * CRITICAL safety: the `yaml_path` MUST be on the
6
+ * `UNLOCK_CARD_YAML_ALLOWLIST` exported from
7
+ * `src/host-control/config-edit-validator.ts`. A malformed or hostile
8
+ * envelope from any backend could otherwise nudge the operator into
9
+ * one-tap-approving an arbitrary flag flip. Non-allowlisted paths fall
10
+ * back to plain-text rendering (the caller surfaces `resp.error` as
11
+ * today).
12
+ *
13
+ * Phase 1 scope: ONLY `flip_yaml_flag`. `request_vault_grant` is
14
+ * explicitly deferred to a later phase (still plain-text rendered).
15
+ */
16
+
17
+ import type { HostdResponse } from "../../src/host-control/protocol.js";
18
+ import { isAllowlistedYamlPath } from "../../src/host-control/config-edit-validator.js";
19
+ import {
20
+ buildApprovalCard,
21
+ type BuiltApprovalCard,
22
+ } from "./approval-card.js";
23
+
24
+ export type UnlockCardOutcome =
25
+ | { kind: "card"; card: BuiltApprovalCard; yaml_path: string; to: unknown }
26
+ | { kind: "plain-text" };
27
+
28
+ /**
29
+ * Decide whether to render a one-tap unlock card for the given
30
+ * response. Returns `{kind: "plain-text"}` whenever the envelope
31
+ * lacks a `flip_yaml_flag` fix OR the path isn't on the allowlist.
32
+ *
33
+ * `approvalRequestId` is the 32-hex nonce minted by the approval
34
+ * kernel; caller is responsible for binding the card to that nonce
35
+ * and recording the apply-on-tap intent.
36
+ */
37
+ export function renderErrorEnvelopeCard(
38
+ resp: HostdResponse,
39
+ agentName: string,
40
+ approvalRequestId: string,
41
+ ): UnlockCardOutcome {
42
+ const env = resp.error_envelope;
43
+ if (!env || !env.fix) return { kind: "plain-text" };
44
+ if (env.fix.kind !== "flip_yaml_flag") {
45
+ // request_vault_grant is Phase-2 work; everything else has no
46
+ // unlock-card UX. Caller falls back to plain-text rendering.
47
+ return { kind: "plain-text" };
48
+ }
49
+ const { yaml_path, to } = env.fix;
50
+ if (!isAllowlistedYamlPath(yaml_path)) {
51
+ // Defense-in-depth: never render a one-tap card for a path the
52
+ // operator hasn't explicitly opted into.
53
+ return { kind: "plain-text" };
54
+ }
55
+ const card = buildApprovalCard({
56
+ request_id: approvalRequestId,
57
+ agent: agentName,
58
+ scope_humanized: `flip ${yaml_path} → ${JSON.stringify(to)}`,
59
+ why: env.human + (env.why ? ` — ${env.why}` : ""),
60
+ offer_always: false,
61
+ offer_ttl: false,
62
+ });
63
+ return { kind: "card", card, yaml_path, to };
64
+ }
@@ -319,12 +319,16 @@ import {
319
319
  import {
320
320
  writeCleanShutdownMarker,
321
321
  readCleanShutdownMarker,
322
- // clearCleanShutdownMarker is intentionally NOT imported here
323
- // the marker is a single self-overwriting file; staleness is bounded by
324
- // `shouldSuppressRecoveryBanner` (DEFAULT_MAX_AGE_MS), so leaving it on
325
- // disk is harmless. Pre-#142 the agent-side `session-greeting.sh` did
326
- // the cleanup after rendering its "Restarted <reason>" row; that script
327
- // was deleted in #142 PR 1.
322
+ // 2026-05-25 clearCleanShutdownMarker IS imported and called on every
323
+ // boot after the marker is read. The earlier "intentionally NOT imported"
324
+ // comment was wrong: it assumed every shutdown writes a fresh marker, but
325
+ // unhandledRejection / uncaughtException paths explicitly SKIP the write
326
+ // (gateway.ts:15107 "crash path"). A marker from a prior graceful
327
+ // shutdown then sits on disk for hours and triggers a misleading stale-
328
+ // marker crash banner on the next boot after an unhandled rejection.
329
+ // Clearing on boot collapses the marker to "describes the immediately
330
+ // preceding shutdown only" semantics.
331
+ clearCleanShutdownMarker,
328
332
  shouldSuppressRecoveryBanner,
329
333
  resolveShutdownMarker,
330
334
  DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
@@ -3434,6 +3438,14 @@ pendingProgress.startTimer({
3434
3438
  )
3435
3439
  },
3436
3440
  emitMetric: (event) => emitRuntimeMetric(event),
3441
+ // #1760 defense-in-depth: if a newer turn for this chat is active at
3442
+ // tick time, the prior turn's pending-progress is stale (the
3443
+ // canonical teardown was missed). Drop the ticker instead of editing
3444
+ // the old anchor — see pending-work-progress.ts's docblock.
3445
+ isActiveTurnNewerThan: (key, activatedAt) => {
3446
+ const turnStartedAt = activeTurnStartedAt.get(key)
3447
+ return turnStartedAt != null && turnStartedAt > activatedAt
3448
+ },
3437
3449
  })
3438
3450
 
3439
3451
  // Per-agent buffer for synthetic inbounds the gateway couldn't deliver
@@ -4109,6 +4121,24 @@ const ipcServer: IpcServer = createIpcServer({
4109
4121
  )
4110
4122
  }
4111
4123
  },
4124
+ // #1762: send the full diff as a `.patch` attachment when the
4125
+ // card body would exceed Telegram's 4096-char sendMessage limit.
4126
+ postAttachment: async (args) => {
4127
+ const input = new InputFile(Buffer.from(args.content, 'utf8'), args.filename)
4128
+ await robustApiCall(
4129
+ () =>
4130
+ bot.api.sendDocument(args.chatId, input, {
4131
+ ...(args.threadId !== undefined
4132
+ ? { message_thread_id: args.threadId }
4133
+ : {}),
4134
+ }),
4135
+ {
4136
+ chat_id: String(args.chatId),
4137
+ verb: 'config-approval-attachment',
4138
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
4139
+ },
4140
+ )
4141
+ },
4112
4142
  log: (m) =>
4113
4143
  process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
4114
4144
  })
@@ -4594,12 +4624,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4594
4624
  // #1122 KPI: a `reply` always produces a fresh user-visible outbound
4595
4625
  // message — count it for the outbound-gap / TTFO KPI AND reset the
4596
4626
  // silence-poke clock so the next poke is measured from this send.
4597
- // Also clear any silent-end state file so the Stop hook doesn't fire
4598
- // a stale block when the session ends (deterministic restore of the
4599
- // detection PR3 inadvertently removed).
4600
4627
  signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
4601
4628
  silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
4602
- clearSilentEndState(statusKey(chat_id, threadId))
4629
+ // #1741 — only clear silent-end state on a plausibly-final reply.
4630
+ // An interim ack (disable_notification:true, short text, no done)
4631
+ // must NOT clear the state file; otherwise a turn that ends with
4632
+ // ack-only + answer-as-transcript leaves no state for the Stop
4633
+ // hook to act on if `turn_end` never lands (the `turn_duration`
4634
+ // system event is unreliable for trivial-prompt turns — see the
4635
+ // executeReply finalize comments). Final-answer replies still
4636
+ // clear; the main turn-end path also re-writes the state when
4637
+ // finalAnswerDelivered=false, so this is a belt-and-braces gate
4638
+ // for the turn_end-missing case (#1741).
4639
+ if (isFinalAnswerReply({ text: rawText, disableNotification })) {
4640
+ clearSilentEndState(statusKey(chat_id, threadId))
4641
+ }
4603
4642
 
4604
4643
  if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
4605
4644
  await deleteStalePreview(previewMessageId)
@@ -4680,6 +4719,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4680
4719
  // - #1664 silent-end re-prompt fires even when the
4681
4720
  // accumulated silent content qualifies as substantive;
4682
4721
  // - retries within the dedup window may double-send.
4722
+ // #1760 primary fix — clear any stale prior-turn ticker
4723
+ // before re-anchoring on this silent-reply edit. See the
4724
+ // matching comment at the executeReply finalize site below.
4725
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
4683
4726
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4684
4727
  messageId: decision.messageId,
4685
4728
  text: decision.mergedText,
@@ -4873,6 +4916,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4873
4916
  if (sentIds.length === chunks.length && chunks.length > 0) {
4874
4917
  const anchorMsgId = sentIds[chunks.length - 1]
4875
4918
  if (typeof anchorMsgId === 'number') {
4919
+ // #1760 primary fix — clear any stale prior-turn ticker BEFORE
4920
+ // re-anchoring. The canonical teardown wires (turn_end,
4921
+ // subagent_handback, inbound) can be missed (e.g. SDK turn_end
4922
+ // event dropped, as in the #1760 live evidence). Tearing down on
4923
+ // every reply-finalize is idempotent and resilient: it's a no-op
4924
+ // when nothing is active, and drops a stale ambient before the
4925
+ // new turn captures its anchor.
4926
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
4876
4927
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4877
4928
  messageId: anchorMsgId,
4878
4929
  text: chunks[chunks.length - 1],
@@ -5170,7 +5221,19 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5170
5221
  const sKey = statusKey(streamChatId, streamThreadId)
5171
5222
  signalTracker.noteOutbound(sKey, Date.now())
5172
5223
  silencePoke.noteOutbound(sKey, Date.now())
5173
- clearSilentEndState(sKey)
5224
+ // #1741 — see executeReply for the rationale: only a plausibly-
5225
+ // final stream_reply clears the silent-end state. An interim
5226
+ // ack via stream_reply must NOT clear; the Stop hook needs
5227
+ // the state to persist if turn_end fails to land.
5228
+ if (
5229
+ isFinalAnswerReply({
5230
+ text: (args.text as string | undefined) ?? '',
5231
+ disableNotification: args.disable_notification === true,
5232
+ done: args.done === true,
5233
+ })
5234
+ ) {
5235
+ clearSilentEndState(sKey)
5236
+ }
5174
5237
  }
5175
5238
  }
5176
5239
 
@@ -5298,6 +5361,10 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5298
5361
  streamFormat === 'html' ? 'HTML'
5299
5362
  : streamFormat === 'markdownv2' ? 'MarkdownV2'
5300
5363
  : undefined
5364
+ // #1760 primary fix — clear any stale prior-turn ticker before
5365
+ // re-anchoring on stream_reply done. See the matching comment at
5366
+ // the executeReply finalize site.
5367
+ pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
5301
5368
  pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
5302
5369
  messageId: result.messageId,
5303
5370
  text: args.text as string,
@@ -5320,6 +5387,20 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5320
5387
  })
5321
5388
  ) {
5322
5389
  turn.finalAnswerDelivered = true
5390
+ // #1744 follow-up — stream_reply edge case. The first-emit gate at
5391
+ // L5178 only clears silent-end state on the FIRST emit of a stream.
5392
+ // If a stream's first emit was ack-shaped (disable_notification:true,
5393
+ // short text, no done) it correctly did NOT clear the state. But a
5394
+ // LATER emit in the same stream may flip `done=true` or carry
5395
+ // substantive text — that's the real final answer landing, and the
5396
+ // state file must be cleared here too. clearSilentEndState is
5397
+ // idempotent (no-op when the file is absent or the turnKey doesn't
5398
+ // match), so calling it unconditionally on every final-answer-shaped
5399
+ // emit is safe even if the first-emit path already cleared.
5400
+ const streamThreadIdForClear = args.message_thread_id != null
5401
+ ? Number(args.message_thread_id)
5402
+ : undefined
5403
+ clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear))
5323
5404
  }
5324
5405
  // v0.13.30 follow-up — release the buffer gate on every successful
5325
5406
  // stream_reply too. Same rationale as executeReply: short replies
@@ -15618,10 +15699,26 @@ void (async () => {
15618
15699
  } else {
15619
15700
  process.stderr.write(`telegram gateway: boot.clean_shutdown_marker_stale age=${ageSec}s signal=${cleanMarker.signal}${reasonTag}\n`)
15620
15701
  }
15621
- // No clearCleanShutdownMarker() call the marker is a single
15622
- // self-overwriting file, age-gated by shouldSuppressRecoveryBanner,
15623
- // so leaving it on disk is harmless. (Pre-#142 the agent-side
15624
- // session-greeting.sh did the cleanup; that script is deleted.)
15702
+ // 2026-05-25 clear the marker after this boot has read it.
15703
+ // Pre-fix the comment here claimed the file was "self-
15704
+ // overwriting, age-gated, harmless to leave on disk" that's
15705
+ // true ONLY for the cycle where every shutdown writes a fresh
15706
+ // marker. The unhandledRejection / uncaughtException paths
15707
+ // explicitly SKIP writing (gateway.ts:15107 — the "crash path")
15708
+ // so a marker from an earlier graceful shutdown sits on disk
15709
+ // for hours, then on the next boot looks stale-by-age and
15710
+ // fires a misleading agent-crashed banner with detail
15711
+ // `clean-shutdown marker stale age=39976s` (clerk 2026-05-25
15712
+ // 01:11). Clearing now means the marker only ever describes
15713
+ // the IMMEDIATELY PRECEDING shutdown, not "some shutdown in
15714
+ // the past". After this clear: a subsequent crash with no
15715
+ // marker write = no marker file = correctly classified
15716
+ // 'crash' via the sessionMarker fallback (boot-reason.ts:84);
15717
+ // a graceful shutdown writes a fresh marker that the next
15718
+ // boot reads + clears. The historical session-greeting.sh
15719
+ // ownership the old comment referred to is gone since #142
15720
+ // but the GC step was never re-homed — this is it.
15721
+ clearCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH)
15625
15722
  }
15626
15723
 
15627
15724
  if (marker) {
@@ -107,8 +107,17 @@ export interface ConfigApprovalResolvedEvent {
107
107
  /** Echoes the requestId from the originating request_config_approval. */
108
108
  requestId: string;
109
109
  verdict: "approve" | "deny" | "timeout";
110
- /** Diagnostic detail when present (currently unused; reserved). */
110
+ /** Diagnostic detail when present. */
111
111
  reason?: string;
112
+ /**
113
+ * Distinguishes an actual operator tap-deny (`"operator"`) from a
114
+ * gateway-side dispatch failure that auto-denied because the card
115
+ * never reached the operator (`"dispatch_failure"`). Only set on
116
+ * `verdict: "deny"` events. Caller (hostd) maps `dispatch_failure`
117
+ * to a distinct error code so the failure isn't misattributed to
118
+ * the operator. Issue #1762.
119
+ */
120
+ denySource?: "operator" | "dispatch_failure";
112
121
  }
113
122
 
114
123
  export type GatewayToClient =
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the shared truncateRawToFit helper (#1767).
3
+ *
4
+ * Covers:
5
+ * - No-op when the rendered body already fits.
6
+ * - Binary-search shrinks the raw slice; result fits under `cap`.
7
+ * - Line-snap when newlines exist; sentinel appended.
8
+ * - Char-truncation fallback when a single unbroken line exceeds
9
+ * the budget (no `\n` to snap to).
10
+ * - Defensive hard-cut when even the framing-alone overflows.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { truncateRawToFit } from "./oversize-card-body.js";
15
+
16
+ const SENTINEL = "\n[… truncated]";
17
+
18
+ function frame(escapeMultiplier: number) {
19
+ // Render closure that mimics a `<pre>`-wrapped HTML-escaped body
20
+ // where every `&` inflates `escapeMultiplier`-fold.
21
+ return (raw: string) => {
22
+ const inflated = raw.replace(/&/g, "&".repeat(escapeMultiplier));
23
+ return `<b>Hdr</b>\n<pre>${inflated}</pre>`;
24
+ };
25
+ }
26
+
27
+ describe("truncateRawToFit", () => {
28
+ it("returns the full body unchanged when it fits under cap", () => {
29
+ const raw = "small content";
30
+ const { body, truncated } = truncateRawToFit({
31
+ raw,
32
+ render: (s) => `<pre>${s}</pre>`,
33
+ cap: 100,
34
+ sentinel: SENTINEL,
35
+ });
36
+ expect(truncated).toBe(false);
37
+ expect(body).toBe("<pre>small content</pre>");
38
+ });
39
+
40
+ it("shrinks raw via binary-search until the rendered body fits the cap (5x escape inflation)", () => {
41
+ const raw = "&".repeat(2000); // 2000 chars raw, 10000 after 5x inflate
42
+ const { body, truncated } = truncateRawToFit({
43
+ raw,
44
+ render: frame(5),
45
+ cap: 1000,
46
+ sentinel: SENTINEL,
47
+ });
48
+ expect(truncated).toBe(true);
49
+ expect(body.length).toBeLessThanOrEqual(1000);
50
+ expect(body).toContain("truncated");
51
+ });
52
+
53
+ it("snaps to the last newline within the chosen raw prefix (no mid-line cut)", () => {
54
+ const raw = ["line-a", "line-b", "line-c", "line-d", "line-e"]
55
+ .map((l) => l.repeat(50))
56
+ .join("\n");
57
+ const { body, truncated } = truncateRawToFit({
58
+ raw,
59
+ render: (s) => `<pre>${s}</pre>`,
60
+ cap: 600,
61
+ sentinel: SENTINEL,
62
+ });
63
+ expect(truncated).toBe(true);
64
+ // The portion before the sentinel must end at a complete line —
65
+ // no partial `line-?` suffix mid-word.
66
+ const beforeSentinel = body.slice(
67
+ 0,
68
+ body.length - SENTINEL.length - "</pre>".length,
69
+ );
70
+ // Every line in the source repeated its label 50x — assert the
71
+ // tail of the kept content ends with a full repeated block, not
72
+ // a chopped one. Easiest check: the body must not end mid-word
73
+ // like `line-` (the dash followed by no letter).
74
+ expect(beforeSentinel).not.toMatch(/line-$/);
75
+ });
76
+
77
+ it("falls through to char-truncation when a single unbroken line exceeds cap", () => {
78
+ const raw = "x".repeat(5000); // no newlines at all
79
+ const { body, truncated } = truncateRawToFit({
80
+ raw,
81
+ render: (s) => `<pre>${s}</pre>`,
82
+ cap: 500,
83
+ sentinel: SENTINEL,
84
+ });
85
+ expect(truncated).toBe(true);
86
+ expect(body.length).toBeLessThanOrEqual(500);
87
+ // Some of the line content survives — the helper shouldn't
88
+ // collapse to "<pre>" + sentinel only.
89
+ expect(body).toMatch(/x{100,}/);
90
+ expect(body).toContain("truncated");
91
+ });
92
+
93
+ it("hard-cuts at hardLimit when framing alone overflows (defensive)", () => {
94
+ // Framing-alone is 5000 chars (way past cap), and the renderer
95
+ // ignores its input — so no slice of raw can ever fit. The
96
+ // helper should hard-cut to hardLimit rather than loop forever.
97
+ const huge = "Z".repeat(5000);
98
+ const { body, truncated } = truncateRawToFit({
99
+ raw: "ignored",
100
+ render: () => huge,
101
+ cap: 1000,
102
+ sentinel: SENTINEL,
103
+ hardLimit: 1100,
104
+ });
105
+ expect(truncated).toBe(true);
106
+ expect(body.length).toBeLessThanOrEqual(1100);
107
+ });
108
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared "render-and-fit" helper for approval cards that wrap
3
+ * user-supplied content in HTML framing. (#1762 / #1767)
4
+ *
5
+ * Telegram's `sendMessage` caps the body at 4096 chars and we render
6
+ * with `parse_mode=HTML`. Worst-case escape inflates raw content up
7
+ * to 5x (`&` → `&amp;`), so a naive raw-input cap is unsafe — the
8
+ * post-escape body can blow past the limit and `sendMessage` then
9
+ * returns a generic 400 that surfaces upstream as a silent
10
+ * `E_DENIED`.
11
+ *
12
+ * This helper binary-searches the largest prefix of the RAW content
13
+ * whose rendered body still fits under `cap`, snaps to the last
14
+ * newline so we don't cut mid-line (and never cut mid-entity like
15
+ * `&am|p;` — raw doesn't contain entities yet), and appends a
16
+ * sentinel pointing at the attached full content (if any).
17
+ *
18
+ * Callers own the framing: pass a `render(slice)` closure that
19
+ * embeds the slice in whatever escaped envelope they want, and the
20
+ * helper guarantees the returned `body` fits.
21
+ *
22
+ * Both `config-approval-handler.ts` (config-edit diffs) and
23
+ * `drive-write-approval.ts` (Drive write preview cards) use this.
24
+ */
25
+
26
+ export interface TruncateRawToFitInput {
27
+ /** Raw, un-escaped content to slice. */
28
+ raw: string;
29
+ /**
30
+ * Build the full rendered card body from a (possibly truncated)
31
+ * raw slice. The closure owns HTML escaping + all framing. Called
32
+ * O(log n) times during the binary search; keep it cheap.
33
+ */
34
+ render: (rawSlice: string) => string;
35
+ /**
36
+ * Maximum rendered length (chars). Should be set below Telegram's
37
+ * 4096 hard limit to leave margin for invisible framing wobble.
38
+ */
39
+ cap: number;
40
+ /**
41
+ * Marker appended to the truncated slice before re-rendering — e.g.
42
+ * `"\n[… diff continues, see attached file]"`. The render closure
43
+ * receives `rawSlice + sentinel` so the marker is visible inside
44
+ * the same envelope (code block etc.).
45
+ */
46
+ sentinel: string;
47
+ /** Absolute hard cap for the defensive last-resort raw cut. Default `cap + 196`. */
48
+ hardLimit?: number;
49
+ }
50
+
51
+ export interface TruncateRawToFitResult {
52
+ /** Rendered body, guaranteed to fit within `cap` (best-effort) or `hardLimit` (defensive). */
53
+ body: string;
54
+ /** True iff the helper had to truncate (raw was sliced or hard-cut). */
55
+ truncated: boolean;
56
+ }
57
+
58
+ /**
59
+ * Try the full content first; if it fits, return as-is. Otherwise
60
+ * binary-search the largest raw prefix whose rendered body fits,
61
+ * snap to the last newline boundary, append the sentinel, re-render
62
+ * and return.
63
+ *
64
+ * Defensive last resort: if even the empty-slice + sentinel render
65
+ * overflows (means the framing alone exceeds `cap` — caller bug or
66
+ * adversarial reason field that slipped past clipping), we hard-cut
67
+ * the rendered body to `hardLimit` chars. Should be unreachable in
68
+ * production but cheaper than crashing.
69
+ */
70
+ export function truncateRawToFit(
71
+ input: TruncateRawToFitInput,
72
+ ): TruncateRawToFitResult {
73
+ const { raw, render, cap, sentinel } = input;
74
+ const hardLimit = input.hardLimit ?? cap + 196;
75
+
76
+ const fullBody = render(raw);
77
+ if (fullBody.length <= cap) {
78
+ return { body: fullBody, truncated: false };
79
+ }
80
+
81
+ // Binary-search the largest raw prefix length whose rendered body
82
+ // fits (with sentinel suffixed before render). We track the best
83
+ // slice rather than just the length so we can snap after the loop.
84
+ let lo = 0;
85
+ let hi = raw.length;
86
+ let bestSliceLen = 0;
87
+ while (lo <= hi) {
88
+ const mid = (lo + hi) >>> 1;
89
+ const candidate = raw.slice(0, mid) + sentinel;
90
+ if (render(candidate).length <= cap) {
91
+ bestSliceLen = mid;
92
+ lo = mid + 1;
93
+ } else {
94
+ hi = mid - 1;
95
+ }
96
+ }
97
+
98
+ // Snap to the last newline within the chosen raw prefix so we
99
+ // never cut a line in half. If a single unbroken line exceeds
100
+ // the budget, fall through with the char-truncated slice — the
101
+ // caller's framing (e.g. `<pre>`) handles the visual gracefully.
102
+ let chosenRaw = raw.slice(0, bestSliceLen);
103
+ const lastNl = chosenRaw.lastIndexOf("\n");
104
+ if (lastNl > 0) chosenRaw = chosenRaw.slice(0, lastNl);
105
+
106
+ let body = render(chosenRaw + sentinel);
107
+
108
+ // Defensive: framing-alone overflow. Hard-cut to hardLimit so the
109
+ // outbound sendMessage at least has a chance of succeeding.
110
+ if (body.length > hardLimit) {
111
+ body = body.slice(0, hardLimit - 1);
112
+ }
113
+ return { body, truncated: true };
114
+ }
@@ -10,13 +10,15 @@
10
10
  * Pure helper so it can be tested without spinning up the gateway.
11
11
  */
12
12
 
13
- import { GrammyError } from 'grammy'
13
+ import { GrammyError, HttpError } from 'grammy'
14
14
 
15
15
  export type RejectionAction = 'shutdown' | 'log_only'
16
16
 
17
17
  export interface RejectionPolicyOptions {
18
18
  /** Allow tests to inject error type detection without depending on grammy. */
19
19
  isGrammyError?: (err: unknown) => boolean
20
+ /** Allow tests to inject HttpError detection without depending on grammy. */
21
+ isHttpError?: (err: unknown) => boolean
20
22
  }
21
23
 
22
24
  /**
@@ -42,9 +44,52 @@ export function classifyRejection(
42
44
  ? opts.isGrammyError(err)
43
45
  : err instanceof GrammyError
44
46
 
47
+ // Transient network-layer failures: grammy throws an `HttpError` wrapping
48
+ // the underlying fetch failure (ECONNRESET, ETIMEDOUT, fetch failed, DNS
49
+ // failures, etc.). These are the SAME class `retry-api-call.ts:146-162`
50
+ // already retries with exponential backoff — if one leaks past the retry
51
+ // policy (3 attempts exhausted, or a fire-and-forget callsite without
52
+ // robustApiCall wrapping), crashing the gateway turns one bad packet into
53
+ // a crash banner. log_only is the right posture: the request failed, the
54
+ // user-visible UX recovers on the next retry cycle, and a daemon that
55
+ // crashes on network errors isn't always-on.
56
+ //
57
+ // Surfaced 2026-05-25 on clerk via the boot-card sendMessage path: an
58
+ // HttpError leaked past the boot-card's try/catch (the async post-settle
59
+ // probe-loop IIFE at boot-card.ts:616 had no .catch on its outer void),
60
+ // triggering an unhandledRejection → shutdown → user-visible
61
+ // "agent-crashed" banner for what was really just a transient network hiccup.
62
+ const isHttp =
63
+ opts.isHttpError != null
64
+ ? opts.isHttpError(err)
65
+ : err instanceof HttpError
66
+ if (isHttp) return 'log_only'
67
+
45
68
  if (!isGrammy) return 'shutdown'
46
69
 
47
70
  const e = err as { error_code?: number; description?: string }
71
+
72
+ // 429 (Too Many Requests / flood-wait): grammy's flood-wait response.
73
+ // Already handled in retry-api-call.ts:100-108 with the
74
+ // `parameters.retry_after` backoff. If one leaks past — caller exceeded
75
+ // maxRetries=3 of sustained 429s, or didn't wrap in robustApiCall — the
76
+ // right posture is log_only (matches the HttpError rationale above).
77
+ // The bot is rate-limited; crashing makes it worse (boot fires more
78
+ // API calls that hit fresh 429s).
79
+ //
80
+ // Surfaced 2026-05-25 on clerk via a sendMessage that exceeded the 3-
81
+ // attempt retry budget; the rejection bubbled to this handler, triggered
82
+ // shutdown, and posted an "agent-crashed" operator-event banner.
83
+ if (e.error_code === 429) return 'log_only'
84
+
85
+ // 5xx (Bad Gateway / Service Unavailable / Gateway Timeout): Telegram
86
+ // intermittently returns these during their own load events. Same
87
+ // posture as 429 — retry policy already backs off and re-tries; if
88
+ // one leaks past, log don't crash.
89
+ if (typeof e.error_code === 'number' && e.error_code >= 500 && e.error_code < 600) {
90
+ return 'log_only'
91
+ }
92
+
48
93
  if (e.error_code !== 400) return 'shutdown'
49
94
 
50
95
  const desc = (e.description ?? '').toLowerCase()