switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFileSync, writeFileSync } from "node:fs";
20
- import { escapeMarkdown } from "./card-format.js";
20
+ import { escapeMarkdown, stackCardLines } from "./card-format.js";
21
21
  import type { IssueEvent, IssueSeverity } from "../src/issues/index.js";
22
22
 
23
23
  export interface BotApiForIssuesCard {
@@ -99,26 +99,31 @@ export function renderIssuesCard(opts: RenderIssuesCardOpts): string | null {
99
99
  const overflow = sorted.length - visible.length;
100
100
 
101
101
  const now = opts.now ?? Date.now();
102
- const rows = visible.map((e) => {
102
+ // Build a flat list of SINGLE lines (a row may contribute a head line plus a
103
+ // remediation line) so stackCardLines can promote every inter-line break to a
104
+ // GFM hard break — otherwise the rows collapse onto one visual line in the
105
+ // rich-message renderer (the same bug stackCardLines fixes for status cards).
106
+ const rowLines: string[] = [];
107
+ for (const e of visible) {
103
108
  const emoji = SEVERITY_EMOJI[e.severity];
104
109
  const occ = e.occurrences > 1 ? ` _(×${e.occurrences})_` : "";
105
110
  const ago = relTime(now - e.last_seen);
106
- const head = `${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`;
111
+ rowLines.push(`${emoji} \`${e.fingerprint}\` ${escapeMarkdown(e.summary)}${occ} — _${ago}_`);
107
112
  // Render the `detail` line below the summary when present and short
108
113
  // enough to be a remediation hint (not a multi-line stderr tail).
109
114
  // Convention from the cron prompt template: agents put "Fix: <cmd>"
110
115
  // or "→ <cmd>" in detail. Long stderr details are omitted from the
111
116
  // card to keep the layout tight; users can run /issues to see them.
112
117
  const remediation = formatRemediation(e.detail);
113
- return remediation == null ? head : `${head}\n → _${escapeMarkdown(remediation)}_`;
114
- });
118
+ if (remediation != null) rowLines.push(` → _${escapeMarkdown(remediation)}_`);
119
+ }
115
120
 
116
- const lines = [header, "", ...rows];
121
+ const lines = [header, "", ...rowLines];
117
122
  if (overflow > 0) {
118
123
  lines.push("");
119
124
  lines.push(`_+${overflow} more not shown — run \`/issues\`_`);
120
125
  }
121
- return lines.join("\n");
126
+ return stackCardLines(lines);
122
127
  }
123
128
 
124
129
  /**
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { formatResetRelative } from './quota-check.js'
30
+ import { escapeMarkdown } from './card-format.js'
30
31
 
31
32
  // ─── Public types ────────────────────────────────────────────────────────────
32
33
 
@@ -366,10 +367,10 @@ export function formatModelUnavailableCard(
366
367
  opts: FormatCardOptions = {},
367
368
  ): string {
368
369
  const now = opts.now ?? new Date()
369
- const slotPart = opts.slot ? ` (slot **${escHtml(opts.slot)}**)` : ''
370
+ const slotPart = opts.slot ? ` (slot **${escapeMarkdown(opts.slot)}**)` : ''
370
371
  const reason = formatReason(detection, now)
371
372
  const lines = [
372
- `⚠️ **Model unavailable** on agent **${escHtml(agent)}**${slotPart}`,
373
+ `⚠️ **Model unavailable** on agent **${escapeMarkdown(agent)}**${slotPart}`,
373
374
  `Reason: ${reason}`,
374
375
  '',
375
376
  ]
@@ -469,7 +470,3 @@ export function resolveModelUnavailableFromOperatorEvent(
469
470
  }
470
471
 
471
472
  // ─── HTML escape (mirrors operator-events.ts) ────────────────────────────────
472
-
473
- function escHtml(text: string): string {
474
- return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
475
- }
@@ -12,6 +12,8 @@
12
12
  * including the quota-exhausted strings migrated from auto-fallback.ts.
13
13
  */
14
14
 
15
+ import { escapeMarkdown } from './format.js'
16
+
15
17
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
16
18
 
17
19
  export type OperatorEventKind =
@@ -210,8 +212,8 @@ export interface RenderResult {
210
212
  * `:` or other delimiter characters.
211
213
  */
212
214
  export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
213
- const agent = escHtml(ev.agent)
214
- const detail = escHtml(ev.detail)
215
+ const agent = escapeMarkdown(ev.agent)
216
+ const detail = escapeMarkdown(ev.detail)
215
217
 
216
218
  switch (ev.kind) {
217
219
  case 'credentials-expired':
@@ -412,7 +414,3 @@ export function resetAllCooldowns(): void {
412
414
  }
413
415
 
414
416
  // ─── Markdown escape (#2669) ──────────────────────────────────────────────────
415
-
416
- function escHtml(text: string): string {
417
- return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
418
- }
@@ -27,7 +27,7 @@
27
27
  "dependencies": {
28
28
  "@grammyjs/runner": "^2.0.3",
29
29
  "@modelcontextprotocol/sdk": "^1.0.0",
30
- "@mtcute/node": "^0.27.0",
30
+ "@mtcute/node": "^0.30.1",
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@secretlint/types": "^12.2.0",
@@ -253,10 +253,10 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
253
253
  lines.push("**Claude plan quota**");
254
254
  lines.push("");
255
255
  lines.push(
256
- `**5h window** ${Math.round(q.fiveHourUtilizationPct)}% · ${formatResetRelative(q.fiveHourResetAt, now)}`,
256
+ `**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
257
257
  );
258
258
  lines.push(
259
- `**7d window** ${Math.round(q.sevenDayUtilizationPct)}% · ${formatResetRelative(q.sevenDayResetAt, now)}`,
259
+ `**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
260
260
  );
261
261
  if (q.representativeClaim) {
262
262
  lines.push("");
@@ -51,6 +51,7 @@ import {
51
51
  fmtPct,
52
52
  } from "./auth-snapshot-format.js";
53
53
  import type { QuotaUtilization } from "./quota-check.js";
54
+ import { escapeMarkdown, codeSpanSafe } from "./card-format.js";
54
55
 
55
56
  const STATE_FILE = "quota-watch.json";
56
57
 
@@ -511,7 +512,7 @@ function buildFleetRecoveredMessage(
511
512
  accounts: Array<{ label: string; exhausted: boolean }>,
512
513
  ): string {
513
514
  const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
514
- const which = healthy.length > 0 ? ` (\`${escapeHtml(healthy[0]!)}\`)` : "";
515
+ const which = healthy.length > 0 ? ` (\`${codeSpanSafe(healthy[0]!)}\`)` : "";
515
516
  return [
516
517
  `🟢 **Fleet recovered** — at least one account is healthy again${which}.`,
517
518
  ``,
@@ -521,7 +522,7 @@ function buildFleetRecoveredMessage(
521
522
 
522
523
  // ─── Message builders ─────────────────────────────────────────────────────────
523
524
 
524
- function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
525
+ export function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
525
526
  const q = snap.quota!; // classifyHealth returned throttling, so quota is non-null
526
527
  const fiveStr = fmtPct(q.fiveHourUtilizationPct);
527
528
  const sevenStr = fmtPct(q.sevenDayUtilizationPct);
@@ -535,14 +536,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
535
536
 
536
537
  const activeNote = snap.isActive
537
538
  ? ""
538
- : `\nThis is a non-active account. Consider \`/auth use ${escapeHtml(snap.label)}\` to switch, or keep it as a fallback reserve.`;
539
+ : `\nThis is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
539
540
 
540
541
  const altNote = snap.isActive
541
542
  ? `\nConsider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.`
542
543
  : "";
543
544
 
544
545
  return [
545
- `🟡 **Quota approaching limit** — \`${escapeHtml(snap.label)}\``,
546
+ `🟡 **Quota approaching limit** — \`${codeSpanSafe(snap.label)}\``,
546
547
  ``,
547
548
  `${fiveStr} of 5h · ${sevenStr} of 7d`,
548
549
  `Binding window: ${winLabel}${resetStr}`,
@@ -556,14 +557,14 @@ function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): strin
556
557
  .trim();
557
558
  }
558
559
 
559
- function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
560
+ export function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string {
560
561
  const q = snap.quota;
561
562
  const utilLine = q
562
563
  ? `Current: ${fmtPct(q.fiveHourUtilizationPct)} of 5h · ${fmtPct(q.sevenDayUtilizationPct)} of 7d`
563
564
  : "Current quota data unavailable.";
564
565
 
565
566
  return [
566
- `🟢 **Quota back in healthy range** — \`${escapeHtml(snap.label)}\``,
567
+ `🟢 **Quota back in healthy range** — \`${codeSpanSafe(snap.label)}\``,
567
568
  ``,
568
569
  utilLine,
569
570
  ``,
@@ -571,10 +572,6 @@ function buildRecoveryMessage(agentName: string, snap: AccountSnapshot): string
571
572
  ].join("\n");
572
573
  }
573
574
 
574
- function escapeHtml(s: string): string {
575
- return s.replace(/([\\`*_~=\[\]|])/g, "\\$1");
576
- }
577
-
578
575
  // ─── State persistence ────────────────────────────────────────────────────────
579
576
 
580
577
  export function loadQuotaWatchState(stateDir: string): QuotaWatchState {
@@ -30,7 +30,9 @@
30
30
  * - Robust error handling: 429 retry, thread-not-found fallback, network retry
31
31
  * - SQLite history buffer (history.ts) for cross-restart recovery
32
32
  * - Vault-grant inline-keyboard wizard
33
- * - Pinned progress card with sub-agent visibility
33
+ * - Live in-conversation status feed with sub-agent visibility (the pinned
34
+ * progress card was retired in #1122; the status message is now a regular
35
+ * in-chat message, silently pinned while in-flight — status-pin.ts)
34
36
  *
35
37
  * State (access.json / pairing / allowlists) lives at
36
38
  * ~/.claude/channels/telegram/ — managed by the /telegram:access skill.
@@ -239,6 +239,34 @@ export function projectAssistantTextBlocks(
239
239
  return out
240
240
  }
241
241
 
242
+ /**
243
+ * True iff this assistant message's `content` carries the "answer surface"
244
+ * — a `text` block, or a real (non-`Agent`/`Task`) `tool_use`. Used to gate
245
+ * the `stop_reason === 'end_turn'` sub-agent terminal so it never fires on a
246
+ * split-off thinking-only line (see the terminal comment in
247
+ * projectSubagentLine). A thinking-only or empty line returns false; the real
248
+ * terminal rides the following content line, which also carries `end_turn`.
249
+ */
250
+ export function assistantLineCarriesAnswerSurface(
251
+ content: Array<Record<string, unknown>> | undefined,
252
+ ): boolean {
253
+ if (!Array.isArray(content)) return false
254
+ for (const c of content) {
255
+ const ct = (c?.type as string | undefined) ?? ''
256
+ if (ct === 'text') {
257
+ // A non-empty text block is the answer surface.
258
+ const t = c.text as string | undefined
259
+ if (typeof t === 'string' && t.trim().length > 0) return true
260
+ } else if (ct === 'tool_use') {
261
+ // A real tool_use is content too. (An end_turn message rarely contains a
262
+ // tool_use, but if it does it is content-final, not a bare thinking split.)
263
+ const name = (c.name as string | undefined) ?? ''
264
+ if (name !== 'Agent' && name !== 'Task') return true
265
+ }
266
+ }
267
+ return false
268
+ }
269
+
242
270
  /**
243
271
  * Project a single transcript line into a SessionEvent (or null if it's
244
272
  * uninteresting noise). Caller is responsible for the JSON parse — if a
@@ -486,8 +514,26 @@ export function projectSubagentLine(
486
514
  // events so the final text/preamble still renders; the watcher's turn_end
487
515
  // handler is guarded on `state === 'running'`, so a later real
488
516
  // turn_duration line is a no-op.
517
+ //
518
+ // UPSTREAM-SHAPE HARDENING (Claude Code ≥2.1.x): one logical assistant
519
+ // message is now persisted as MULTIPLE JSONL lines sharing one
520
+ // `message.id`, one content-block per line, and the terminal `stop_reason`
521
+ // (`end_turn`) is stamped on EVERY split line — including the leading
522
+ // `[thinking]` line that precedes the `[text: final answer]` line. Firing
523
+ // the terminal on the thinking-only line marks the sub-agent `done` and
524
+ // hands back stale/empty text BEFORE the real handback `[text]` line is
525
+ // projected (the watcher's onProgress is `state==='running'`-gated, so the
526
+ // late text is dropped). Guard: only treat `end_turn` as terminal on a line
527
+ // that actually carries the message's answer surface (a `text` block, or a
528
+ // non-`Agent`/`Task` tool_use). A thinking-only `end_turn` line is a split
529
+ // preamble; its terminal + handback ride the following content line, which
530
+ // still carries `end_turn` and fires correctly AFTER the text event. The
531
+ // old single-line `[thinking, text](end_turn)` shape has a `text` block, so
532
+ // it fires exactly as before — graceful degradation on both shapes. A
533
+ // genuine thinking-only end with no answer still terminates via the
534
+ // `turn_duration` / capped-reaper / watcher stall nets.
489
535
  const stopReason = message?.stop_reason as string | undefined
490
- if (stopReason === 'end_turn') {
536
+ if (stopReason === 'end_turn' && assistantLineCarriesAnswerSurface(content)) {
491
537
  events.push({ kind: 'sub_agent_turn_end', agentId })
492
538
  }
493
539
  return events
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Status-pin driver — executes a PinAction against a Telegram Bot API.
3
+ *
4
+ * The pure decision lives in `status-pin.ts` (decidePinAction). This
5
+ * module is the side-effecting half: takes the previously-claimed state
6
+ * and the desired state, computes ONE action, executes it, and returns
7
+ * the next state. The state itself stays in the caller (the gateway holds
8
+ * a `Map<pinKey, PinState>` and re-passes the entry on every call).
9
+ *
10
+ * Copied from `slot-banner-driver.ts`. The load-bearing contract (see
11
+ * slot-banner-driver.ts:100-110):
12
+ *
13
+ * - Pins are SILENT (`disable_notification: true`) — a status pin must
14
+ * never buzz the device.
15
+ * - We pin an EXISTING message (the already-rendered per-turn status /
16
+ * `🛠 Worker` message). We NEVER send a new message to pin. This is
17
+ * the boundary that keeps us inside the one sanctioned exception on
18
+ * `chat-is-the-single-source-of-truth`.
19
+ * - On UNPIN we DROP THE CLAIM (clear state) EVEN IF `unpinChatMessage`
20
+ * throws. This is the whole point: pin state must never get stuck.
21
+ * The message may have been unpinned out-of-band (operator, or a
22
+ * crash) — re-claiming it would be more confusing than surfacing it
23
+ * again later, and a stuck claim would leave a permanent pin.
24
+ * - API failures are reported via `onError` but never throw; the caller
25
+ * decides logging cadence.
26
+ *
27
+ * See `reference/invariants.md` § `chat-is-the-single-source-of-truth`
28
+ * (the sanctioned silent-pin exception) and the `know-what-my-agent-is-doing`
29
+ * job spec.
30
+ */
31
+
32
+ import type { PinState, DesiredPin } from './status-pin.js'
33
+ import { decidePinAction } from './status-pin.js'
34
+
35
+ /** Minimal subset of grammy's `bot.api` the pin driver depends on.
36
+ * Lets tests swap in a fake without dragging in the full Bot type. */
37
+ export interface PinBotApi {
38
+ pinChatMessage(
39
+ chat_id: string | number,
40
+ message_id: number,
41
+ opts?: Record<string, unknown>,
42
+ ): Promise<unknown>
43
+ unpinChatMessage(
44
+ chat_id: string | number,
45
+ message_id: number,
46
+ ): Promise<unknown>
47
+ }
48
+
49
+ export interface ReconcilePinArgs {
50
+ api: PinBotApi
51
+ chatId: string
52
+ /** State the caller is holding from the last reconcile. `null` on first. */
53
+ prevState: PinState | null
54
+ /** What the caller wants pinned for this key right now. */
55
+ desired: DesiredPin
56
+ /** Optional API-failure observer. Default: silent. */
57
+ onError?: (phase: 'pin' | 'unpin', err: unknown) => void
58
+ }
59
+
60
+ /**
61
+ * Execute the next pin-state transition for one key. Returns the new
62
+ * `PinState` (or `null` when unpinned / nothing pinned). Always resolves;
63
+ * never throws — API errors route through `onError`.
64
+ *
65
+ * - `pin` : pins the existing message SILENTLY; on failure the claim is
66
+ * NOT taken (returns prevState) so the next reconcile retries
67
+ * rather than tracking a message it never pinned.
68
+ * - `unpin`: unpins best-effort and returns `null` — the claim is dropped
69
+ * EVEN IF the unpin throws (never leave state stuck pinned).
70
+ * - `noop` : returns prevState unchanged.
71
+ */
72
+ export async function reconcilePin(
73
+ args: ReconcilePinArgs,
74
+ ): Promise<PinState | null> {
75
+ const action = decidePinAction(args.prevState, args.desired)
76
+
77
+ if (action.kind === 'noop') return args.prevState
78
+
79
+ if (action.kind === 'unpin') {
80
+ try {
81
+ await args.api.unpinChatMessage(args.chatId, action.messageId)
82
+ } catch (err) {
83
+ args.onError?.('unpin', err)
84
+ }
85
+ // Drop the claim regardless of the unpin outcome. A stuck claim would
86
+ // leave a permanent pin on a crash / out-of-band unpin — the exact
87
+ // failure this driver exists to prevent (see slot-banner-driver.ts).
88
+ return null
89
+ }
90
+
91
+ // action.kind === 'pin' — pin an EXISTING message, silently.
92
+ try {
93
+ await args.api.pinChatMessage(args.chatId, action.messageId, {
94
+ disable_notification: true,
95
+ })
96
+ } catch (err) {
97
+ args.onError?.('pin', err)
98
+ // Don't claim a message we failed to pin — the next reconcile retries.
99
+ return args.prevState
100
+ }
101
+ return { messageId: action.messageId }
102
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Status-pin — pure decision logic.
3
+ *
4
+ * Keeps an already-rendered status message pinned while its work is
5
+ * in-flight, and unpins it the moment the work completes. This is the
6
+ * ONE sanctioned pin under the `chat-is-the-single-source-of-truth`
7
+ * invariant (see `reference/invariants.md` §
8
+ * `chat-is-the-single-source-of-truth` and the
9
+ * `know-what-my-agent-is-doing` job spec): it re-surfaces a message the
10
+ * conversation ALREADY owns — the per-turn activity/status message and
11
+ * the `🛠 Worker` background-worker message — and never renders a new
12
+ * parallel surface.
13
+ *
14
+ * Why a pin at all: fast answers, more background workers, and much
15
+ * longer turns mean the conversation moves on quickly in the feed and
16
+ * the user loses sight of in-flight work — work now routinely outlives
17
+ * the visible window. Pinning the message the feed already holds keeps
18
+ * it in view; it carries no new content.
19
+ *
20
+ * This module is dependency-free so it's provable in isolation; the
21
+ * gateway (via `status-pin-driver.ts`) translates a `PinAction` into
22
+ * actual Telegram API calls. The design mirrors `slot-banner.ts` +
23
+ * `slot-banner-driver.ts` — one pure decision, one side-effecting
24
+ * reconcile, the claim dropped on unpin even when the API throws.
25
+ */
26
+
27
+ /** The message the framework is currently claiming as pinned for a key. */
28
+ export type PinState = {
29
+ /** Telegram message_id we pinned. */
30
+ messageId: number
31
+ }
32
+
33
+ /** What the caller wants pinned for a key right now. */
34
+ export type DesiredPin =
35
+ /** Work is in-flight; this already-rendered message should be pinned. */
36
+ | { pinned: true; messageId: number }
37
+ /** Work is done (or never opened a message); nothing should be pinned. */
38
+ | { pinned: false }
39
+
40
+ export type PinAction =
41
+ | { kind: 'noop'; reason: string }
42
+ /** Pin an EXISTING message. Caller pins, then records the message_id
43
+ * back into PinState. No new message is sent — this pins a message the
44
+ * chat already rendered. */
45
+ | { kind: 'pin'; messageId: number }
46
+ /** Unpin + forget. Caller unpins (best-effort) and clears state EVEN IF
47
+ * the unpin call throws. */
48
+ | { kind: 'unpin'; messageId: number }
49
+
50
+ /**
51
+ * Decide the single pin action for a key given the previously-claimed
52
+ * state and what the caller now wants.
53
+ *
54
+ * Exactly one action per (prev, desired) — the whole point is that pin
55
+ * state can never get stuck: an in-flight → done transition always yields
56
+ * an `unpin`, and the driver drops the claim on unpin regardless of API
57
+ * outcome.
58
+ */
59
+ export function decidePinAction(
60
+ prev: PinState | null,
61
+ desired: DesiredPin,
62
+ ): PinAction {
63
+ if (!desired.pinned) {
64
+ if (prev) return { kind: 'unpin', messageId: prev.messageId }
65
+ return { kind: 'noop', reason: 'nothing pinned, nothing wanted' }
66
+ }
67
+ // desired.pinned === true
68
+ if (prev == null) return { kind: 'pin', messageId: desired.messageId }
69
+ if (prev.messageId === desired.messageId) {
70
+ return { kind: 'noop', reason: 'already pinned this message' }
71
+ }
72
+ // The message we want pinned changed (the feed re-posted after a stale
73
+ // edit dropped its id). Unpin the old claim; a subsequent desired-pin
74
+ // re-pins the new one on the next reconcile.
75
+ return { kind: 'unpin', messageId: prev.messageId }
76
+ }
@@ -158,6 +158,27 @@ export interface StreamReplyDeps {
158
158
  * the raw (repaired) text is sent unchanged.
159
159
  */
160
160
  normalizeParagraphBreaks?: (text: string) => string
161
+ /**
162
+ * Punctuation/bullet normalization (fleet-wide consistent formatting):
163
+ * em/en dashes → comma/hyphen, leading unicode bullets → `- `. Applied on
164
+ * code-masked text right after normalizeParagraphBreaks. Optional for
165
+ * backward compat; omitted → no normalization.
166
+ */
167
+ normalizePunctuation?: (text: string) => string
168
+ /**
169
+ * Over-bold tripwire: strips `**bold**` markers when a message is clearly
170
+ * over-bolded (>30% bold, or whole paragraphs/lists fully bolded). Applied
171
+ * after normalizePunctuation. Optional for backward compat.
172
+ */
173
+ stripExcessBold?: (text: string) => string
174
+ /**
175
+ * Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
176
+ * GFM renderer shows a real empty line between paragraphs (the rich engine
177
+ * otherwise renders `\n\n` tight — the post-#2669 paragraph-spacing
178
+ * regression). Applied only on the rich path (never on `format:'text'`).
179
+ * Optional for backward compat; omitted → no spacers added.
180
+ */
181
+ addParagraphSpacers?: (text: string) => string
161
182
  /** Validates the chat id against the access list. Throws on deny. */
162
183
  assertAllowedChat: (chatId: string) => void
163
184
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -308,9 +329,14 @@ export async function handleStreamReply(
308
329
  deps: StreamReplyDeps,
309
330
  ): Promise<StreamReplyResult> {
310
331
  const chat_id = args.chat_id
311
- const rawText = deps.normalizeParagraphBreaks
332
+ let rawText = deps.normalizeParagraphBreaks
312
333
  ? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
313
334
  : deps.repairEscapedWhitespace(args.text)
335
+ // Fleet-wide consistent formatting: dash/bullet normalization + over-bold
336
+ // tripwire, same order as the reply/edit paths (after paragraph
337
+ // normalization, before spacers). Both run on code-masked text internally.
338
+ if (deps.normalizePunctuation) rawText = deps.normalizePunctuation(rawText)
339
+ if (deps.stripExcessBold) rawText = deps.stripExcessBold(rawText)
314
340
  const done = Boolean(args.done)
315
341
  const format = args.format ?? deps.defaultFormat
316
342
  if (done) {
@@ -337,7 +363,12 @@ export async function handleStreamReply(
337
363
  // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
338
364
  // text IS the wire payload.
339
365
  const literalText = format === 'text'
340
- let effectiveText: string = rawText
366
+ // Paragraph-spacing fix (rich-message regression after #2669): inject a
367
+ // visible blank-line spacer into prose `\n\n` gaps on the rich path so
368
+ // multi-paragraph answers don't render jammed together. The literal
369
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
370
+ let effectiveText: string =
371
+ !literalText && deps.addParagraphSpacers ? deps.addParagraphSpacers(rawText) : rawText
341
372
 
342
373
  // Inline status-accent header (issue #320 fallback). Prepended so it
343
374
  // leads the body. Since stream_reply callers pass the full text snapshot
@@ -13,9 +13,12 @@
13
13
  * does not yet exist (Phase 2 Pre hook hasn't fired), the update is a no-op
14
14
  * and the event is logged — no INSERT here, identity belongs to Phase 2.
15
15
  *
16
- * Sub-agent state is surfaced to the user via the progress card's
17
- * [Sub-agents · N running] block (progress-card.ts), not a separate pinned
18
- * card. See issue #142.
16
+ * Sub-agent state is surfaced to the user in the conversation itself — a
17
+ * background worker via its in-chat `🛠 Worker` message (worker-activity-feed.ts),
18
+ * a foreground sub-agent nested in the parent turn's activity message. The
19
+ * pinned progress card was retired in #1122; the `🛠 Worker` message is now
20
+ * silently pinned while the worker runs (status-pin.ts), not a bespoke card.
21
+ * See issue #142.
19
22
  *
20
23
  * Architecture notes:
21
24
  * - Option B from the spec: filesystem-driven, no IPC contract.
@@ -121,8 +121,40 @@ describe('scope-commit — durable hostd persistence', () => {
121
121
  expect(commitBlock).toContain('readFileSync(')
122
122
  })
123
123
 
124
- it('passes a long timeout to tryHostdDispatch (apply+reconcile blocks)', () => {
125
- expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 60_000)')
124
+ it('passes a 12-min timeout to tryHostdDispatch (apply+reconcile can take 5-10 min)', () => {
125
+ expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 720_000)')
126
+ })
127
+
128
+ it('acks the tap BEFORE the hostd await (interim status, background persist)', () => {
129
+ const interimAckIdx = commitBlock.indexOf('saving durably in background')
130
+ const bgIdx = commitBlock.indexOf('void (async () => {')
131
+ const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
132
+ expect(interimAckIdx).toBeGreaterThan(-1)
133
+ expect(bgIdx).toBeGreaterThan(-1)
134
+ expect(hostdAwaitIdx).toBeGreaterThan(-1)
135
+ // interim ack fires before the background continuation opens, and the
136
+ // slow hostd await lives INSIDE the background continuation.
137
+ expect(interimAckIdx).toBeLessThan(bgIdx)
138
+ expect(bgIdx).toBeLessThan(hostdAwaitIdx)
139
+ })
140
+
141
+ it('background continuation body is wrapped in try/catch (a throw must not become an unhandledRejection → shutdown)', () => {
142
+ const bgIdx = commitBlock.indexOf('void (async () => {')
143
+ const tryIdx = commitBlock.indexOf('try {', bgIdx)
144
+ const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
145
+ expect(tryIdx).toBeGreaterThan(bgIdx)
146
+ // The top-level try opens before any work (including the hostd await
147
+ // and scheduleGrantRestart's sync fs writes) runs inside the IIFE.
148
+ expect(tryIdx).toBeLessThan(hostdAwaitIdx)
149
+ expect(commitBlock).toContain('always-allow background persist threw')
150
+ })
151
+
152
+ it('edits the card with the real outcome after the background persist', () => {
153
+ const bgIdx = commitBlock.indexOf('void (async () => {')
154
+ const outcomeEditIdx = commitBlock.indexOf('await ctx.editMessageText(', bgIdx)
155
+ expect(outcomeEditIdx).toBeGreaterThan(bgIdx)
156
+ // Outcome edit failure is logged, never thrown into the void continuation.
157
+ expect(commitBlock).toContain('always-allow outcome card edit failed')
126
158
  })
127
159
 
128
160
  it('registers + cleans up the single-tap correlation entry', () => {
@@ -93,8 +93,12 @@ describe('renderShowText — Format 2 vs legacy', () => {
93
93
  });
94
94
  expect(out).toContain('🔋 **Auth — fleet status**');
95
95
  expect(out).toContain('Recommendation:');
96
- expect(out).toContain('🔴 **BLOCKED**');
97
- expect(out).toContain('🟢 **HEALTHY**');
96
+ // GFM table card (#2700): State emoji per row, not group headers.
97
+ expect(out).toContain('| State | Account | 5h | 7d | Status |');
98
+ expect(out).toContain('| 🔴 |'); // the blocked account's State cell
99
+ expect(out).toContain('| 🟢 |'); // a healthy account's State cell
100
+ expect(out).not.toContain('**BLOCKED**');
101
+ expect(out).not.toContain('**HEALTHY**');
98
102
  // Legacy ASCII column headers should be absent
99
103
  expect(out).not.toContain('ACCOUNT STATUS');
100
104
  });
@@ -140,6 +140,24 @@ describe('parseAuthCommand — new verbs', () => {
140
140
  expect((p as { reason?: string }).reason).toMatch(/confirm/i)
141
141
  })
142
142
 
143
+ // Boundary-escaping (#2695 escaper de-dup): the help/reason strings now
144
+ // render dynamic user input via `codeSpanSafe` — the reason interpolates the
145
+ // value INSIDE a `code span`, where backslash escaping is wrong (#2695). A
146
+ // metacharacter-laden verb must come back LITERAL (no stray backslashes).
147
+ it('renders a metacharacter-laden unknown verb literally in the code span', () => {
148
+ const p = parseAuthCommand('/auth a_b*c')
149
+ expect(p?.kind).toBe('help')
150
+ expect((p as { reason?: string }).reason).toContain('`a_b*c`')
151
+ expect((p as { reason?: string }).reason).not.toContain('a\\_b\\*c')
152
+ })
153
+
154
+ it('renders a metacharacter-laden rm modifier literally in the code span', () => {
155
+ const p = parseAuthCommand('/auth rm spare x_y*z')
156
+ expect(p?.kind).toBe('help')
157
+ expect((p as { reason?: string }).reason).toContain('`x_y*z`')
158
+ expect((p as { reason?: string }).reason).not.toContain('x\\_y\\*z')
159
+ })
160
+
143
161
  it('rejects /auth rm with no label', () => {
144
162
  const p = parseAuthCommand('/auth rm')
145
163
  expect(p?.kind).toBe('help')