switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -480,14 +480,167 @@ export function hardenCardBreaks(text: string): string {
480
480
  }
481
481
 
482
482
  // ---------------------------------------------------------------------------
483
- // Paragraph spacing note (#2669 follow-up): the NBSP paragraph-spacer
484
- // (PARAGRAPH_SPACER / addParagraphSpacers) was REMOVED. Its premise — that the
485
- // Bot API 10.1 rich (GFM) renderer collapses a `\n\n` gap TIGHT — is false for
486
- // the live renderer, which shows a `\n\n` gap as a normal single blank line.
487
- // The old U+00A0 spacer therefore injected a spurious SECOND blank line
488
- // (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph spacing now
489
- // relies on the plain `\n\n` that normalizeParagraphBreaks already guarantees
490
- // at block boundaries one correct single blank line, no spacer pass.
483
+ // Paragraph spacers restore a VISIBLE blank line between prose paragraphs
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /**
487
+ * The non-collapsible spacer paragraph injected between two prose paragraphs.
488
+ *
489
+ * Telegram's Bot API 10.1 rich-message renderer (the GFM/CommonMark engine
490
+ * behind `sendRichMessage` / `editMessageText({ markdown })`, and the in-repo
491
+ * IR renderer in `render/` that feeds it) renders a `\n\n` paragraph break
492
+ * TIGHT — the two paragraphs sit on adjacent lines with no visible empty line
493
+ * between them (live-confirmed: an outbound message with real `\n\n` gaps
494
+ * renders jammed; only list/table BLOCK boundaries produce a visible gap). The
495
+ * legacy markdown→HTML path (removed in #2669) sent `\n\n` literally with
496
+ * `parse_mode:"HTML"`, where two newlines render as a real blank line. That
497
+ * regression is the operator-confirmed "paragraphs jammed together" symptom
498
+ * (#2692).
499
+ *
500
+ * CommonMark discards blank lines made of ASCII whitespace, but a line whose
501
+ * only content is a NON-breaking space (U+00A0) is a genuine, non-empty
502
+ * paragraph — it renders as a visible empty line. So `A\n\n \n\nB` renders as
503
+ * three paragraphs: A, a blank-looking line, then B — the visible gap the HTML
504
+ * path used to produce. This survives the in-repo IR renderer's re-parse /
505
+ * re-render (verified: a U+00A0-only line round-trips intact, while an
506
+ * ASCII-space-only line is collapsed to a tight `\n\n`), so it reaches the wire
507
+ * whether `SWITCHROOM_RICH_RENDER` is on (default) or off.
508
+ */
509
+ export const PARAGRAPH_SPACER = ' '
510
+
511
+ /**
512
+ * Insert a visible blank-line spacer into each genuine `\n\n` paragraph gap so
513
+ * the rich GFM renderer shows a real empty line between paragraphs (matching
514
+ * the pre-#2669 HTML behaviour). See PARAGRAPH_SPACER for why a U+00A0 line is
515
+ * the reliable trick.
516
+ *
517
+ * Uniform-block-spacing contract: a spacer is inserted into EVERY `\n\n` gap
518
+ * that separates two DISTINCT blocks — prose→prose, paragraph→list,
519
+ * list→paragraph, heading→anything, blockquote/table/fence boundaries — so a
520
+ * mixed message renders with one identical visible blank line between blocks.
521
+ * The one exception is a gap INSIDE a block of the same structural kind (two
522
+ * items of a loose list, consecutive table rows/quotes/fences): those stay
523
+ * tight so the block's contiguity survives. Interiors joined by a single `\n`
524
+ * are never gaps at all and are untouched by construction.
525
+ *
526
+ * Runs on code-masked text (so a blank line inside a fenced block is never
527
+ * touched).
528
+ *
529
+ * IDEMPOTENT & DOUBLE-GAP-PROOF (the #3208 regression this restores fixes):
530
+ * each inter-block gap — whatever it originally holds (a bare `\n\n`, a
531
+ * pre-existing U+00A0 spacer `\n\n \n\n`, a stray extra blank line `\n\n\n`, an
532
+ * ASCII-space-only line) — is CANONICALISED to exactly one form: `\n\n`
533
+ * (tight) or `\n\n${PARAGRAPH_SPACER}\n\n` (spaced). It therefore inserts
534
+ * EXACTLY ONE spacer per spaced gap and can never stack a second, and running
535
+ * the pass twice yields the same output as running it once. #3208 removed the
536
+ * whole mechanism to kill a double-gap; the correct fix was to make the gap
537
+ * canonical (this), not to delete the spacer and reintroduce the jammed-
538
+ * paragraph symptom fleet-wide.
539
+ *
540
+ * Intended to run in the outbound send path AFTER normalizeParagraphBreaks
541
+ * (which has already collapsed 3+ newline runs to `\n\n` and guaranteed
542
+ * block-boundary blank lines) — but the canonicalisation above means it is
543
+ * safe on un-normalized text too (e.g. the edit path).
544
+ */
545
+ export function addParagraphSpacers(text: string): string {
546
+ if (!text.includes('\n\n')) return text
547
+
548
+ const nonce = Math.random().toString(36).slice(2)
549
+ const { masked, restore, placeholder } = maskCodeRegions(text, nonce)
550
+
551
+ if (!masked.includes('\n\n')) return restore(masked)
552
+
553
+ const SP = PARAGRAPH_SPACER
554
+
555
+ // ASCII-only trim (preserve U+00A0): `String.prototype.trim()` strips U+00A0,
556
+ // so a spacer line would read as "blank"; trimming ASCII whitespace only
557
+ // keeps the spacer line recognisable as content.
558
+ const asciiTrim = (line: string): string =>
559
+ line.replace(/^[ \t\r\f\v]+|[ \t\r\f\v]+$/g, '')
560
+
561
+ // "Blank-ish" = a line that renders as an empty paragraph: ASCII-whitespace-
562
+ // only, OR a line whose only non-ASCII-whitespace content is the U+00A0
563
+ // spacer. Coalescing BOTH kinds into one gap is what makes the pass
564
+ // idempotent and double-gap-proof — a pre-existing spacer or extra blank line
565
+ // is absorbed into the gap and re-emitted canonically, never stacked.
566
+ const isBlankish = (line: string): boolean => {
567
+ const t = asciiTrim(line)
568
+ return t === '' || t === SP
569
+ }
570
+
571
+ // Classify the block kind of a facing content line so the spacer decision can
572
+ // be made per BLOCK TRANSITION (#uniform-block-spacing). A spacer is inserted
573
+ // at every gap between two DIFFERENT block kinds and between two prose
574
+ // paragraphs, but NEVER inside a single block's interior (two items of the
575
+ // same loose list, two rows of a table, consecutive quotes/fences/dividers).
576
+ type BlockKind = 'list' | 'table' | 'quote' | 'heading' | 'fence' | 'divider' | 'prose'
577
+ const blockKind = (line: string): BlockKind => {
578
+ if (isFenceOpenLine(line, placeholder)) return 'fence'
579
+ if (isListItemLine(line)) return 'list'
580
+ if (isTableRowLine(line) || isTableDelimiterLine(line)) return 'table'
581
+ if (isBlockquoteLine(line)) return 'quote'
582
+ if (isHeadingLine(line)) return 'heading'
583
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trimStart())) return 'divider'
584
+ return 'prose'
585
+ }
586
+
587
+ // Same-kind structural pairs whose gap is a block INTERIOR — no spacer there.
588
+ const SAME_KIND_TIGHT: ReadonlySet<BlockKind> = new Set([
589
+ 'list',
590
+ 'table',
591
+ 'quote',
592
+ 'fence',
593
+ 'divider',
594
+ ])
595
+
596
+ const shouldSpaceGap = (above: string, below: string): boolean => {
597
+ const a = blockKind(above)
598
+ const b = blockKind(below)
599
+ if (a === b && SAME_KIND_TIGHT.has(a)) return false
600
+ // Everything else is a genuine block transition (incl. prose→prose,
601
+ // heading→anything, list↔paragraph, table/quote boundaries) — space it.
602
+ return true
603
+ }
604
+
605
+ // Tokenize into CONTENT runs (consecutive non-blank-ish lines, joined by a
606
+ // single `\n` — soft breaks stay inside a content block) and GAP runs
607
+ // (consecutive blank-ish lines). Tokens strictly alternate by construction.
608
+ type Tok = { kind: 'content' | 'gap'; lines: string[] }
609
+ const toks: Tok[] = []
610
+ for (const line of masked.split('\n')) {
611
+ const kind = isBlankish(line) ? 'gap' : 'content'
612
+ const last = toks[toks.length - 1]
613
+ if (last && last.kind === kind) last.lines.push(line)
614
+ else toks.push({ kind, lines: [line] })
615
+ }
616
+
617
+ // Rebuild. A CONTENT token re-emits its lines verbatim. An INTER-content GAP
618
+ // (a gap flanked by content on BOTH sides) is re-emitted in canonical form —
619
+ // one blank line (`''` → `\n\n`) for a tight gap, or `['', SP, '']`
620
+ // (→ `\n\n${SP}\n\n`) for a spaced gap — discarding whatever it held. A
621
+ // leading/trailing gap (no content on one side, only possible before the
622
+ // first / after the last content block) is preserved verbatim.
623
+ const out: string[] = []
624
+ for (let i = 0; i < toks.length; i++) {
625
+ const tok = toks[i]
626
+ if (tok.kind === 'content') {
627
+ out.push(...tok.lines)
628
+ continue
629
+ }
630
+ const prev = toks[i - 1]
631
+ const next = toks[i + 1]
632
+ if (prev?.kind === 'content' && next?.kind === 'content') {
633
+ const above = prev.lines[prev.lines.length - 1]
634
+ const below = next.lines[0]
635
+ if (shouldSpaceGap(above, below)) out.push('', SP, '')
636
+ else out.push('')
637
+ } else {
638
+ out.push(...tok.lines)
639
+ }
640
+ }
641
+
642
+ return restore(out.join('\n'))
643
+ }
491
644
 
492
645
  // ---------------------------------------------------------------------------
493
646
  // Punctuation / bullet normalization — fleet-wide consistent typography
@@ -1006,26 +1159,32 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
1006
1159
  }
1007
1160
 
1008
1161
  /**
1009
- * Strip stray blank lines off a chunk boundary so a cut that lands in a `\n\n`
1010
- * paragraph gap never leaves a continuation chunk that OPENS with a bare blank
1011
- * line, nor a prior chunk that ENDS with one.
1162
+ * Strip stray paragraph-spacer / blank lines off a chunk boundary so a cut that
1163
+ * lands inside an injected spacer gap (`\n\n${PARAGRAPH_SPACER}\n\n`, see
1164
+ * addParagraphSpacers) never leaves a continuation chunk that OPENS with a bare
1165
+ * U+00A0 spacer line, nor a prior chunk that ENDS with one.
1012
1166
  *
1013
- * (Pre-#2669-follow-up this also peeled the NBSP paragraph spacer that
1014
- * addParagraphSpacers injected into every gap. That spacer pass was removed
1015
- * gaps are now plain `\n\n` so this reduces to the original behaviour: strip
1016
- * a run of ASCII-whitespace-only blank lines off the boundary.) Idempotent: a
1017
- * chunk already trimmed has nothing left to strip.
1167
+ * A "boundary blank run" is any sequence of newlines and spacer-only lines (a
1168
+ * line whose only content is the U+00A0 spacer, optionally surrounded by ASCII
1169
+ * spaces/tabs) in ANY interleaving `\n \n`, ` \n\n`, `\n\n \n`, etc. The
1170
+ * legacy behaviour (strip leading ASCII `\n+` only) is a strict subset, so a
1171
+ * boundary with NO spacer is unaffected. Idempotent: a chunk already trimmed
1172
+ * has nothing left to strip.
1018
1173
  *
1019
1174
  * - `'leading'` → strip the run from the START (the continuation chunk).
1020
1175
  * - `'trailing'` → strip the run from the END (the just-emitted prior chunk).
1021
1176
  */
1022
1177
  function stripBoundarySpacers(chunk: string, side: 'leading' | 'trailing'): string {
1023
- // A boundary blank run is one-or-more lines that render empty (ASCII
1024
- // whitespace only). Peel it off the requested side.
1178
+ // One blank-or-spacer line: optional ASCII ws, optional one U+00A0, optional
1179
+ // ASCII ws — i.e. a line that renders empty. A run of these (joined by \n,
1180
+ // with leading/trailing \n) is what we peel off the boundary.
1181
+ const sp = PARAGRAPH_SPACER
1025
1182
  if (side === 'leading') {
1026
- return chunk.replace(/^(?:[ \t]*\n)+/, '')
1183
+ // Leading: one-or-more newlines, optionally with spacer-only lines mixed in.
1184
+ return chunk.replace(new RegExp(`^(?:[ \\t]*${sp}?[ \\t]*\\n+)+`), '')
1027
1185
  }
1028
- return chunk.replace(/(?:\n[ \t]*)+$/, '')
1186
+ // Trailing: a newline run, optionally with spacer-only lines, at the very end.
1187
+ return chunk.replace(new RegExp(`(?:\\n+[ \\t]*${sp}?[ \\t]*)+$`), '')
1029
1188
  }
1030
1189
 
1031
1190
  /**
@@ -35,6 +35,38 @@ export function baseAgent(name: string): string {
35
35
  return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
36
36
  }
37
37
 
38
+ /**
39
+ * True iff an inject_inbound fire is a scheduled cron fire — Tier-1 cheap-cron
40
+ * (`meta.session='cron'`, routed to the derived `<agent>-cron` bridge) OR a
41
+ * Tier-2 full-session cron (`meta.source='cron'`, lands on the main bridge).
42
+ *
43
+ * #3114 — such a fire must NOT stamp the MAIN session's idle-clear clock at
44
+ * inject time. Before #3113, `onInjectInbound` stamped unconditionally so a
45
+ * "working scheduled agent isn't wiped after 3h of no inbound". That is now
46
+ * redundant AND harmful: a cron cadence shorter than `idle_clear_after`
47
+ * re-arms the timer on every fire and keeps idle-clear permanently suppressed
48
+ * for that agent. After #3113 a cron fire that does REAL work already stamps
49
+ * the main clock through `handleSessionEvent` on every genuine session event,
50
+ * so the blanket inject-time stamp buys nothing for main-bridge fires — and a
51
+ * cheap-cron fire (whose session events are dropped for the cron identity in
52
+ * `onSessionEvent`) correctly stops warming the main clock once it is gone.
53
+ *
54
+ * Only cron fires are gated: other synthetic-source injects (reaction, vault
55
+ * grant, resume) reflect genuine operator/session presence and still stamp.
56
+ *
57
+ * DOCUMENTED RESIDUAL (#3114, operator-approved): a Tier-2 cron pinned to the
58
+ * MAIN session (`context:'agent'`) that replies NO_REPLY still runs a real
59
+ * turn on the main bridge, which emits session events → stamps via
60
+ * `handleSessionEvent`. So "a NO_REPLY poll isn't presence" is fully closed
61
+ * only for cheap/derived-bridge crons; an expensive main-session poll still
62
+ * warms the clock because the model genuinely ran. This predicate governs only
63
+ * the inject-time stamp, not the session-event stamp — closing the main-
64
+ * session-poll case would need a separate weaker clock, out of scope here.
65
+ */
66
+ export function isCronInjectFire(meta: Record<string, string> | undefined): boolean {
67
+ return meta?.source === "cron" || meta?.session === "cron";
68
+ }
69
+
38
70
  /**
39
71
  * Resolve the IPC routing target for an inject_inbound. When the fire
40
72
  * carries `meta.session='cron'` it goes to the derived cron bridge; every