switchroom 0.14.20 → 0.14.22

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 (53) hide show
  1. package/dist/agent-scheduler/index.js +2 -3
  2. package/dist/auth-broker/index.js +2 -3
  3. package/dist/cli/notion-write-pretool.mjs +2 -3
  4. package/dist/cli/switchroom.js +16 -8
  5. package/dist/host-control/main.js +2 -3
  6. package/dist/vault/approvals/kernel-server.js +2 -3
  7. package/dist/vault/broker/server.js +2 -3
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +11 -24
  10. package/profiles/_shared/telegram-style.md.hbs +2 -2
  11. package/profiles/default/CLAUDE.md.hbs +4 -1
  12. package/skills/switchroom-runtime/SKILL.md +6 -16
  13. package/telegram-plugin/agent-dir.ts +15 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +655 -514
  15. package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
  16. package/telegram-plugin/gateway/gateway.ts +246 -83
  17. package/telegram-plugin/gateway/inbound-spool.ts +15 -0
  18. package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
  19. package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
  20. package/telegram-plugin/registry/turns-schema.ts +138 -33
  21. package/telegram-plugin/stream-reply-handler.ts +1 -11
  22. package/telegram-plugin/tests/agent-dir.test.ts +25 -0
  23. package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
  24. package/telegram-plugin/tests/e2e.test.ts +2 -77
  25. package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
  26. package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
  27. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  28. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  29. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  30. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
  31. package/telegram-plugin/tests/races.test.ts +0 -26
  32. package/telegram-plugin/tests/registry-turns.test.ts +106 -29
  33. package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
  34. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  35. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  36. package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
  37. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  38. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  39. package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
  40. package/telegram-plugin/tests/turns-writer.test.ts +16 -6
  41. package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
  42. package/telegram-plugin/tool-activity-summary.ts +55 -0
  43. package/telegram-plugin/uat/assertions.ts +53 -0
  44. package/telegram-plugin/uat/driver.ts +30 -0
  45. package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
  46. package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
  47. package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
  48. package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
  50. package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
  51. package/telegram-plugin/worker-activity-feed.ts +11 -5
  52. package/telegram-plugin/handoff-continuity.ts +0 -206
  53. package/telegram-plugin/tests/handoff-continuity.test.ts +0 -262
@@ -36,6 +36,15 @@ export interface ResolvedExtraAttachment {
36
36
  * `maxAttachments` is floored at 1 — a cap of 0 or negative would strip the
37
37
  * primary, silently dropping the only attachment.
38
38
  */
39
+ /** Default attachments folded into one coalesced turn: a full Telegram album
40
+ * (media_group caps at 10). Floored at 1 so the only attachment is never
41
+ * stripped. Set channels.telegram.coalesce.max_attachments to override. */
42
+ export const DEFAULT_MAX_ATTACHMENTS = 10
43
+
44
+ export function resolveCoalesceMaxAttachments(configured: number | undefined): number {
45
+ return Math.max(1, configured ?? DEFAULT_MAX_ATTACHMENTS)
46
+ }
47
+
39
48
  export function splitCoalescedAttachments<T>(
40
49
  entries: T[],
41
50
  hasAttachment: (e: T) => boolean,
@@ -39,6 +39,7 @@ import {
39
39
  ToolFlightTracker,
40
40
  decideInterruptTiming,
41
41
  resolveInterruptMaxWaitMs,
42
+ resolveSafeBoundaryEnabled,
42
43
  } from './interrupt-defer.js'
43
44
  import {
44
45
  resolveStickerSendArgs,
@@ -56,12 +57,16 @@ import {
56
57
  } from '../telegraph.js'
57
58
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
58
59
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
59
- import { splitCoalescedAttachments, buildExtraAttachmentMeta } from './coalesce-attachments.js'
60
+ import {
61
+ splitCoalescedAttachments,
62
+ buildExtraAttachmentMeta,
63
+ resolveCoalesceMaxAttachments,
64
+ } from './coalesce-attachments.js'
60
65
  import { StatusReactionController } from '../status-reactions.js'
61
66
  import { DeferredDoneReactions } from '../reaction-defer.js'
62
- import { createWorkerActivityFeed } from '../worker-activity-feed.js'
67
+ import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
63
68
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
64
- import { appendActivityLabel } from '../tool-activity-summary.js'
69
+ import { appendActivityLabel, renderActivityFeedWithNested } from '../tool-activity-summary.js'
65
70
  import { toolLabel } from '../tool-labels.js'
66
71
  import { createTypingWrapper } from '../typing-wrap.js'
67
72
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -205,14 +210,7 @@ import {
205
210
  isTurnFlushSafetyEnabled,
206
211
  } from '../turn-flush-safety.js'
207
212
  // #1122 PR3: turn-flush-prose-recovery removed with the progress card.
208
- import {
209
- resolveAgentDirFromEnv,
210
- consumeHandoffTopic,
211
- shouldShowHandoffLine,
212
- formatHandoffLine,
213
- writeLastTurnSummary,
214
- type HandoffFormat,
215
- } from '../handoff-continuity.js'
213
+ import { resolveAgentDirFromEnv } from '../agent-dir.js'
216
214
  import {
217
215
  addActiveReaction,
218
216
  removeActiveReaction,
@@ -391,6 +389,7 @@ import {
391
389
  touchTurnActiveMarker,
392
390
  removeTurnActiveMarker,
393
391
  sweepStaleTurnActiveMarker,
392
+ TURN_ACTIVE_MARKER_FILE,
394
393
  } from './turn-active-marker.js'
395
394
  import {
396
395
  VERSION,
@@ -418,12 +417,17 @@ import {
418
417
  import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
419
418
  import {
420
419
  openTurnsDb,
421
- markOrphanedAsRestarted,
420
+ markOrphanedWithTimeoutClassification,
422
421
  recordTurnStart,
423
422
  recordTurnEnd,
424
- findMostRecentInterruptedTurn,
423
+ findLatestTurnIfInterrupted,
425
424
  findRecentTurnsForChat,
426
425
  } from '../registry/turns-schema.js'
426
+ import {
427
+ buildResumeInterruptedInbound,
428
+ buildResumeWatchdogReportInbound,
429
+ selectResumeBuilder,
430
+ } from './resume-inbound-builder.js'
427
431
  import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
428
432
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
429
433
  import { formatIdleFooter } from '../idle-footer.js'
@@ -776,14 +780,15 @@ type Access = {
776
780
  parseMode?: 'html' | 'markdownv2' | 'text'
777
781
  disableLinkPreview?: boolean
778
782
  coalescingGapMs?: number
779
- /** A2: max media attachments folded into one coalesced turn. Default 1
780
- * (single-attachment behaviour). Projected from
783
+ /** A2: max media attachments folded into one coalesced turn. Default 10
784
+ * (a full Telegram album / forwarded burst arrives as one turn). Set 1 to
785
+ * restore single-attachment behaviour. Projected from
781
786
  * channels.telegram.coalesce.max_attachments by scaffold. */
782
787
  coalesceMaxAttachments?: number
783
- /** Problem B: when true, a `!` interrupt that lands mid-tool-call is
784
- * deferred until the in-flight tool finishes (bounded by
785
- * interruptMaxWaitMs) before SIGINT + resume. Default false (fire
786
- * synchronously). Projected from channels.telegram.interrupt.safe_boundary. */
788
+ /** Problem B: when true (the default), a `!` interrupt that lands
789
+ * mid-tool-call is deferred until the in-flight tool finishes (bounded by
790
+ * interruptMaxWaitMs) before SIGINT + resume. Set false to fire
791
+ * synchronously. Projected from channels.telegram.interrupt.safe_boundary. */
787
792
  interruptSafeBoundary?: boolean
788
793
  /** Upper bound (ms) to wait for a safe boundary before firing a deferred
789
794
  * interrupt anyway. Default 8000. Projected from
@@ -963,13 +968,26 @@ if (HISTORY_ENABLED) {
963
968
  }
964
969
  }
965
970
 
966
- // ─── Turn-tracking registry (Stage 3a of simplify-restart, Phase 0 of #250) ─
967
- // On boot, open the per-agent registry.db and stamp any rows that never got
968
- // an ended_at as ended_via='restart'. Those are turns where the previous
969
- // gateway died mid-flight (SIGKILL / OOM / hard reboot — any path that
970
- // skipped the SIGTERM handler). Stages 3b/3c will populate new rows during
971
- // turn enqueue/end and on graceful shutdown; Stage 4 reads on cold start.
971
+ // ─── Turn-tracking registry + honest-restart-resume ────────────────────────
972
+ // On boot, open the per-agent registry.db and reap any turn that never got an
973
+ // ended_at those were killed mid-flight (operator restart, SIGKILL, OOM,
974
+ // hard reboot). The reaper CLASSIFIES each orphan from the on-disk
975
+ // turn-active marker's age:
976
+ // - marker older than the hang-watchdog window 'timeout' (the turn
977
+ // stalled with no tool progress; report it, don't blindly resume).
978
+ // - otherwise → 'restart' (a clean interrupt; resume it).
979
+ // Then, if the LATEST turn was interrupted, we build a synthetic resume /
980
+ // report inbound and (further down, once the inbound spool exists) inject it
981
+ // so the agent wakes on its own and either picks the work back up or tells
982
+ // the user why it stopped — no human nudge required.
983
+ //
984
+ // The classifier MUST read the marker before the boot-cleanup sweep removes
985
+ // it (the sweep runs much later, in the bridge-registration path). This block
986
+ // runs at module top, so the marker is still present here.
972
987
  let turnsDb: ReturnType<typeof openTurnsDb> | null = null
988
+ // Stashed here; pushed to the spool once it's constructed below. The spool's
989
+ // turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
990
+ let bootResumeInbound: { agent: string; msg: InboundMessage } | null = null
973
991
  try {
974
992
  // STATE_DIR is `<agentDir>/telegram` in production. openTurnsDb expects
975
993
  // the parent (agent dir) and joins `telegram/registry.db` itself.
@@ -981,23 +999,88 @@ try {
981
999
  // schema; subagents lives alongside in registry.db. Idempotent — safe on
982
1000
  // pre-existing DBs (handles the jsonl_agent_id column migration).
983
1001
  applySubagentsSchema(turnsDb)
984
- const reaped = markOrphanedAsRestarted(turnsDb)
1002
+
1003
+ // Read the turn-active marker (the in-flight turn the watchdog tracks)
1004
+ // BEFORE classifying — its mtime is "ms since last tool progress" and its
1005
+ // payload carries the in-flight turn_key.
1006
+ let markerTurnKey: string | null = null
1007
+ let markerAgeMs: number | null = null
1008
+ try {
1009
+ const markerPath = join(STATE_DIR, TURN_ACTIVE_MARKER_FILE)
1010
+ if (existsSync(markerPath)) {
1011
+ const st = statSync(markerPath)
1012
+ markerAgeMs = Date.now() - st.mtimeMs
1013
+ try {
1014
+ const payload = JSON.parse(readFileSync(markerPath, 'utf8')) as { turnKey?: unknown }
1015
+ if (typeof payload.turnKey === 'string' && payload.turnKey.length > 0) {
1016
+ markerTurnKey = payload.turnKey
1017
+ }
1018
+ } catch { /* unreadable/torn marker — age alone still classifies */ }
1019
+ }
1020
+ } catch { /* stat failure — treat as no marker (plain restart) */ }
1021
+
1022
+ // TURN_HANG_SECS is the watchdog's hang threshold (default 300s); the
1023
+ // classifier uses the same signal so "would the watchdog have killed it"
1024
+ // is answered identically whether or not the watchdog is live (it's
1025
+ // disabled under Docker, but the staleness judgement still holds).
1026
+ const hangSecs = Number(process.env.TURN_HANG_SECS)
1027
+ const hangThresholdMs = (Number.isFinite(hangSecs) && hangSecs > 0 ? hangSecs : 300) * 1000
1028
+ const reasonSnapshot =
1029
+ markerAgeMs != null ? JSON.stringify({ idleMs: Math.round(markerAgeMs) }) : null
1030
+
1031
+ const { reaped, timeoutTurnKey } = markOrphanedWithTimeoutClassification(turnsDb, {
1032
+ markerTurnKey,
1033
+ markerAgeMs,
1034
+ hangThresholdMs,
1035
+ reasonSnapshot,
1036
+ })
985
1037
  if (reaped > 0) {
986
- process.stderr.write(`telegram gateway: turn-registry boot-reaper stamped ${reaped} orphaned turn(s) as ended_via='restart'\n`)
1038
+ process.stderr.write(
1039
+ `telegram gateway: turn-registry boot-reaper stamped ${reaped} orphaned turn(s)` +
1040
+ `${timeoutTurnKey ? ` (turnKey=${timeoutTurnKey} as 'timeout', markerAgeMs=${markerAgeMs})` : " as 'restart'"}\n`,
1041
+ )
987
1042
  } else {
988
1043
  process.stderr.write(`telegram gateway: turn-registry initialized at ${join(agentDir, 'telegram', 'registry.db')}\n`)
989
1044
  }
990
1045
 
991
- // Stage 4: surface the most-recently-interrupted turn to start.sh as a
992
- // shell-sourceable env file. The agent's start.sh reads this on next
993
- // boot, exports the env vars to the spawned `claude` process, and
994
- // deletes the file (one-shot — only ever applies to the immediately
995
- // following session). If there's no interrupted turn (clean previous
996
- // shutdown), we delete any stale file so the resume protocol doesn't
997
- // mis-fire.
1046
+ // Build the boot resume/report inbound for the LATEST turn if it was
1047
+ // interrupted. selectResumeBuilder owns the resume-vs-report policy.
1048
+ const pending = findLatestTurnIfInterrupted(turnsDb)
1049
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1050
+ if (pending != null && selfAgent) {
1051
+ const kind = selectResumeBuilder(pending.ended_via)
1052
+ if (kind === 'resume') {
1053
+ bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1054
+ } else if (kind === 'report') {
1055
+ // idleMs: this boot's measured marker age if it just classified this
1056
+ // turn; otherwise recover it from the persisted interrupt_reason (a
1057
+ // later boot, marker already swept); else fall back to total runtime.
1058
+ let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1059
+ if (idleMs == null && pending.interrupt_reason) {
1060
+ try {
1061
+ const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1062
+ if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1063
+ } catch { /* malformed snapshot — fall through */ }
1064
+ }
1065
+ if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1066
+ bootResumeInbound = {
1067
+ agent: selfAgent,
1068
+ msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
1069
+ }
1070
+ }
1071
+ if (bootResumeInbound != null) {
1072
+ process.stderr.write(
1073
+ `telegram gateway: boot-resume queued kind=${kind} turnKey=${pending.turn_key} ` +
1074
+ `endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1075
+ )
1076
+ }
1077
+ }
1078
+
1079
+ // Diagnostic env file (one-shot, sourced by start.sh) — kept for the
1080
+ // wake-audit context. The injected inbound above is the real wake signal;
1081
+ // these vars are passive context only.
998
1082
  const pendingEnvPath = join(agentDir, '.pending-turn.env')
999
1083
  try {
1000
- const pending = findMostRecentInterruptedTurn(turnsDb)
1001
1084
  if (pending != null) {
1002
1085
  const lines = [
1003
1086
  `SWITCHROOM_PENDING_TURN=true`,
@@ -1007,14 +1090,12 @@ try {
1007
1090
  pending.last_user_msg_id != null ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}` : `SWITCHROOM_PENDING_USER_MSG_ID=`,
1008
1091
  `SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
1009
1092
  `SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
1093
+ pending.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
1010
1094
  ]
1011
1095
  // Atomic write: tmp + rename. Without this, a crash mid-write
1012
1096
  // (power loss, OOM, panic) leaves a truncated `.pending-turn.env`
1013
1097
  // that start.sh `source`s — partial SWITCHROOM_PENDING_* vars
1014
- // half-trigger the resume protocol with incomplete context, or
1015
- // a malformed line breaks shell parsing inside the source.
1016
- // Same pattern used by the access-file write a few hundred lines
1017
- // above and by src/issues/store.ts.
1098
+ // or a malformed line break shell parsing inside the source.
1018
1099
  const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
1019
1100
  writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
1020
1101
  renameSync(pendingEnvTmp, pendingEnvPath)
@@ -1024,7 +1105,7 @@ try {
1024
1105
  process.stderr.write(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
1025
1106
  }
1026
1107
  } catch (err) {
1027
- process.stderr.write(`telegram gateway: pending-turn env write failed (${(err as Error).message}) — resume protocol may not fire\n`)
1108
+ process.stderr.write(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
1028
1109
  }
1029
1110
  } catch (err) {
1030
1111
  process.stderr.write(`telegram gateway: turn-registry init failed (${(err as Error).message}) — turn tracking disabled\n`)
@@ -1393,6 +1474,13 @@ type CurrentTurn = {
1393
1474
  // (via `renderActivityFeed`) as a capped chronological list into the
1394
1475
  // in-place edited activity message and clears on reply. Reset per turn.
1395
1476
  mirrorLines: string[]
1477
+ // Model A — foreground sub-agent nesting. A foreground sub-agent (Task/Agent
1478
+ // with no run_in_background) runs INSIDE this turn while the parent blocks at
1479
+ // the Task tool, so its live steps nest under the parent's activity feed
1480
+ // rather than a separate message. Keyed by jsonl agent id; value = the
1481
+ // sub-agent's accumulated narrative lines (oldest→newest, deduped + capped).
1482
+ // Background workers are NOT here — they get the standalone worker feed.
1483
+ foregroundSubAgents: Map<string, string[]>
1396
1484
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1397
1485
  // event of a turn (once enough text has accumulated, the stream itself
1398
1486
  // gates on minInitialChars). Materialized and cleared at turn_end.
@@ -2123,23 +2211,6 @@ function probeAvailableReactions(chatId: string): void {
2123
2211
  })()
2124
2212
  }
2125
2213
 
2126
- // ─── Handoff continuity ───────────────────────────────────────────────────
2127
- let pendingHandoffTopic: string | null = null
2128
-
2129
- function initHandoffContinuity(): void {
2130
- if (!shouldShowHandoffLine()) { pendingHandoffTopic = null; return }
2131
- const agentDir = resolveAgentDirFromEnv()
2132
- if (agentDir == null) { pendingHandoffTopic = null; return }
2133
- pendingHandoffTopic = consumeHandoffTopic(agentDir)
2134
- }
2135
-
2136
- function takeHandoffPrefix(format: HandoffFormat): string {
2137
- if (pendingHandoffTopic == null) return ''
2138
- const line = formatHandoffLine(pendingHandoffTopic, format)
2139
- pendingHandoffTopic = null
2140
- return line
2141
- }
2142
-
2143
2214
  // ─── Text chunking ────────────────────────────────────────────────────────
2144
2215
  const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
2145
2216
 
@@ -3137,13 +3208,13 @@ type CoalescePayload = {
3137
3208
 
3138
3209
  // Count of attachment-bearing entries currently buffered per coalesce key.
3139
3210
  // A new attachment for a key whose count has reached the per-agent cap
3140
- // (coalesce.max_attachments, default 1) bypasses coalescing (see
3211
+ // (coalesce.max_attachments, default 10) bypasses coalescing (see
3141
3212
  // handleInboundCoalesced) so no media is dropped past the cap. Cleared on
3142
3213
  // flush (below) and on the synchronous bypass path.
3143
3214
  const bufferedAttachmentKeys = new Map<string, number>()
3144
3215
 
3145
3216
  function coalesceMaxAttachments(): number {
3146
- return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1)
3217
+ return resolveCoalesceMaxAttachments(loadAccess().coalesceMaxAttachments)
3147
3218
  }
3148
3219
 
3149
3220
  const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
@@ -3936,6 +4007,21 @@ const inboundSpool = STATIC
3936
4007
  },
3937
4008
  })
3938
4009
  const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
4010
+ // Honest-restart-resume: inject the boot resume/report inbound built by the
4011
+ // registry classifier above. When the spool exists we only PUT it (the
4012
+ // boot-replay loop below pulls it into the in-memory buffer exactly once via
4013
+ // liveEntries — pushing here too would double-queue). The turn_key-keyed
4014
+ // spoolId makes this a no-op if a prior restart already queued the same turn
4015
+ // and it hasn't been delivered yet — so a multi-restart sequence resumes a
4016
+ // given turn once, not N times. When there's no spool (STATIC mode) push
4017
+ // straight to the in-memory buffer.
4018
+ if (bootResumeInbound != null) {
4019
+ if (inboundSpool != null) {
4020
+ inboundSpool.put(bootResumeInbound.agent, bootResumeInbound.msg)
4021
+ } else {
4022
+ pendingInboundBuffer.push(bootResumeInbound.agent, bootResumeInbound.msg)
4023
+ }
4024
+ }
3939
4025
  // Boot-replay: re-queue every un-acked spooled inbound into the
3940
4026
  // in-memory buffer so the existing drain triggers (onClientRegistered
3941
4027
  // / silence-poke #1546 / idle-drain #1549) deliver them. push →
@@ -5243,13 +5329,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
5243
5329
  effectiveText = text
5244
5330
  }
5245
5331
 
5246
- {
5247
- const prefix = takeHandoffPrefix(
5248
- format === 'html' ? 'html' : format === 'markdownv2' ? 'markdownv2' : 'text',
5249
- )
5250
- if (prefix.length > 0) effectiveText = prefix + effectiveText
5251
- }
5252
-
5253
5332
  assertAllowedChat(chat_id)
5254
5333
 
5255
5334
  let threadId = resolveThreadId(chat_id, args.message_thread_id as string | undefined)
@@ -5983,7 +6062,6 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5983
6062
  markdownToHtml,
5984
6063
  escapeMarkdownV2,
5985
6064
  repairEscapedWhitespace,
5986
- takeHandoffPrefix,
5987
6065
  assertAllowedChat,
5988
6066
  resolveThreadId,
5989
6067
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -7152,6 +7230,27 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
7152
7230
  }
7153
7231
  }
7154
7232
 
7233
+ /** Accumulation cap for a foreground sub-agent's nested narrative lines.
7234
+ * Slightly larger than NESTED_MAX_LINES so the render's "↳ +N earlier…"
7235
+ * header is meaningful without growing unbounded on a long sub-agent. */
7236
+ const FOREGROUND_SUBAGENT_ACCUM_MAX = 12
7237
+
7238
+ /**
7239
+ * Render this turn's activity feed, nesting any active foreground sub-agent's
7240
+ * narrative beneath the parent's own steps (Model A). With no active
7241
+ * foreground sub-agent this is exactly the flat feed. Multiple concurrent
7242
+ * foreground sub-agents (rare — parallel Task dispatch) flatten in insertion
7243
+ * order; the single-sub-agent common case nests precisely under its
7244
+ * Delegating line.
7245
+ */
7246
+ function composeTurnActivity(turn: CurrentTurn): string | null {
7247
+ const childLines: string[] = []
7248
+ for (const narrative of turn.foregroundSubAgents.values()) {
7249
+ childLines.push(...narrative)
7250
+ }
7251
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines)
7252
+ }
7253
+
7155
7254
  /**
7156
7255
  * Drain the tool-activity summary's pending render queue. Single-flight
7157
7256
  * by construction (caller assigns the returned promise to
@@ -7318,6 +7417,7 @@ function handleSessionEvent(ev: SessionEvent): void {
7318
7417
  activityPendingRender: null,
7319
7418
  activityLastSentRender: null,
7320
7419
  mirrorLines: [],
7420
+ foregroundSubAgents: new Map(),
7321
7421
  answerStream: null,
7322
7422
  isDm: isDmChatId(ev.chatId),
7323
7423
  }
@@ -7495,7 +7595,10 @@ function handleSessionEvent(ev: SessionEvent): void {
7495
7595
  if (turn.replyCalled) return
7496
7596
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
7497
7597
  if (rendered != null) {
7498
- turn.activityPendingRender = rendered
7598
+ // Recompose so any active foreground sub-agent's nested block (Model A)
7599
+ // is preserved when the parent appends its own step. composeTurnActivity
7600
+ // == the flat render when no foreground sub-agent is active.
7601
+ turn.activityPendingRender = composeTurnActivity(turn) ?? rendered
7499
7602
  if (turn.activityInFlight == null) {
7500
7603
  turn.activityInFlight = drainActivitySummary(turn)
7501
7604
  }
@@ -8502,7 +8605,6 @@ function handlePtyActivity(text: string): void {
8502
8605
  markdownToHtml,
8503
8606
  escapeMarkdownV2,
8504
8607
  repairEscapedWhitespace,
8505
- takeHandoffPrefix: () => '',
8506
8608
  assertAllowedChat,
8507
8609
  resolveThreadId,
8508
8610
  disableLinkPreview: access.disableLinkPreview !== false,
@@ -8727,11 +8829,11 @@ async function handleInboundCoalesced(
8727
8829
  const maxAttachments = coalesceMaxAttachments()
8728
8830
 
8729
8831
  // Albums (media_group_id): coalesce only when the cap allows >1 attachment
8730
- // (A2). At the default cap of 1 each album part keeps its own turn exactly
8731
- // as before — the single-attachment merge can't carry sibling photos, so
8732
- // bypassing avoids dropping them. With a raised cap the parts share the
8733
- // coalesce key and fold into one multi-attachment turn (the cap-overflow
8734
- // bypass below catches parts past the cap).
8832
+ // (A2). At the default cap of 10 the parts share the coalesce key and fold
8833
+ // into one multi-attachment turn (the cap-overflow bypass below catches
8834
+ // parts past the cap). With the cap lowered to 1 each album part keeps its
8835
+ // own turn the single-attachment merge can't carry sibling photos, so
8836
+ // bypassing avoids dropping them.
8735
8837
  if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
8736
8838
  return handleInbound(ctx, text, downloadImage, attachment)
8737
8839
  }
@@ -8741,7 +8843,8 @@ async function handleInboundCoalesced(
8741
8843
 
8742
8844
  // An attachment past the per-agent cap would be dropped by the capped merge.
8743
8845
  // Bypass it to its own turn so no media is silently lost. At the default
8744
- // cap of 1 this fires on the SECOND attachment, preserving A1 behaviour.
8846
+ // cap of 10 this fires on the 11th attachment; with the cap lowered to 1 it
8847
+ // fires on the SECOND, preserving A1 behaviour.
8745
8848
  if (hasAttachment) {
8746
8849
  const probeKey = inboundCoalesceKey(
8747
8850
  String(ctx.chat!.id),
@@ -8785,9 +8888,9 @@ async function handleInboundCoalesced(
8785
8888
  // Coalescing disabled (window <= 0): flush immediately, preserving any
8786
8889
  // media this message carried.
8787
8890
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
8788
- // Count the open window's attachments so a third+ (or second, at the
8789
- // default cap) bypasses rather than overflows the capped merge (cleared
8790
- // in onFlush).
8891
+ // Count the open window's attachments so any part past the cap (the 11th
8892
+ // at the default cap of 10, or the second when lowered to 1) bypasses
8893
+ // rather than overflows the capped merge (cleared in onFlush).
8791
8894
  if (hasAttachment) bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1)
8792
8895
  }
8793
8896
 
@@ -8998,7 +9101,7 @@ async function handleInbound(
8998
9101
  deferInterrupt =
8999
9102
  !interrupt.emptyBody &&
9000
9103
  decideInterruptTiming({
9001
- safeBoundaryEnabled: access.interruptSafeBoundary === true,
9104
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
9002
9105
  midToolCall: toolFlightTracker.isMidToolCall(),
9003
9106
  }) === 'defer'
9004
9107
  process.stderr.write(
@@ -16975,7 +17078,6 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
16975
17078
 
16976
17079
 
16977
17080
  // ─── Startup ──────────────────────────────────────────────────────────────
16978
- initHandoffContinuity()
16979
17081
 
16980
17082
  // Top-level error handlers route through shutdown() so the startup lock is
16981
17083
  // released cleanly. Without this, a top-level throw would leave the lock
@@ -17565,10 +17667,17 @@ void (async () => {
17565
17667
  // and edits it in place as work happens (current tool + elapsed),
17566
17668
  // finalizing on completion — the same "live, growing message"
17567
17669
  // shape the main agent's answer uses, NOT card chrome (the pinned
17568
- // card was deleted in #1126). Flag-gated; when ON it also
17670
+ // card was deleted in #1126). On by default (set
17671
+ // SWITCHROOM_WORKER_ACTIVITY_FEED=0 to disable); when ON it also
17569
17672
  // supersedes the coarse 5-min bucket relay below to avoid
17570
17673
  // double-surfacing the same progress beat.
17571
- const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED === '1'
17674
+ const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED)
17675
+ // Model A — foreground sub-agent nesting in the parent's live
17676
+ // activity draft. ON by default; this edits the SAME activity-
17677
+ // summary message the tool_label feed already owns (not the
17678
+ // compose draft, so no answer-stream contention). The kill-switch
17679
+ // disables only the nesting; the parent's own feed is unaffected.
17680
+ const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'
17572
17681
  const workerActivityFeed = createWorkerActivityFeed({
17573
17682
  bot: {
17574
17683
  sendMessage: async (cid, text, sendOpts) => {
@@ -17727,6 +17836,28 @@ void (async () => {
17727
17836
  } catch { /* best-effort */ }
17728
17837
  }
17729
17838
  const isBackground = dispatch.isBackground
17839
+ if (!isBackground) {
17840
+ // Model A — a foreground sub-agent finished. Collapse its
17841
+ // nested child block from the parent's activity draft; the
17842
+ // parent resumes and its result returns inline as the Task
17843
+ // tool result, so there's no handback to deliver. Reaction
17844
+ // promotion already ran above.
17845
+ const turn = currentTurn
17846
+ if (
17847
+ turn != null &&
17848
+ turn.foregroundSubAgents.delete(agentId) &&
17849
+ !turn.replyCalled
17850
+ ) {
17851
+ const rendered = composeTurnActivity(turn)
17852
+ if (rendered != null) {
17853
+ turn.activityPendingRender = rendered
17854
+ if (turn.activityInFlight == null) {
17855
+ turn.activityInFlight = drainActivitySummary(turn)
17856
+ }
17857
+ }
17858
+ }
17859
+ return
17860
+ }
17730
17861
  // #PR2 live worker-feed: force the terminal recap edit on
17731
17862
  // the worker's live message. No-op when no message was ever
17732
17863
  // posted (trivial workers stay silent; handback covers them).
@@ -17835,7 +17966,39 @@ void (async () => {
17835
17966
  } catch { /* best-effort */ }
17836
17967
  }
17837
17968
  const isBackground = dispatch.isBackground
17838
- if (!isBackground) return // skip overhead for foreground
17969
+ if (!isBackground) {
17970
+ // Model A — a foreground sub-agent runs inside the parent's
17971
+ // turn, so its live narrative nests under the parent's
17972
+ // activity draft rather than a separate worker message. Pure
17973
+ // jsonl-tail → render (no model call), inside the
17974
+ // subscription-honest boundary.
17975
+ if (!foregroundNestingEnabled) return // kill-switch: skip overhead
17976
+ const turn = currentTurn
17977
+ if (turn == null || turn.replyCalled) return
17978
+ const child = latestSummary.trim().slice(0, 120)
17979
+ if (child.length === 0) return
17980
+ let narrative = turn.foregroundSubAgents.get(agentId)
17981
+ if (narrative == null) {
17982
+ narrative = []
17983
+ turn.foregroundSubAgents.set(agentId, narrative)
17984
+ }
17985
+ // Dedup against the immediately-preceding line — the watcher
17986
+ // re-emits the same narrative across ticks while a tool runs.
17987
+ if (narrative[narrative.length - 1] !== child) {
17988
+ narrative.push(child)
17989
+ if (narrative.length > FOREGROUND_SUBAGENT_ACCUM_MAX) {
17990
+ narrative.splice(0, narrative.length - FOREGROUND_SUBAGENT_ACCUM_MAX)
17991
+ }
17992
+ }
17993
+ const rendered = composeTurnActivity(turn)
17994
+ if (rendered != null) {
17995
+ turn.activityPendingRender = rendered
17996
+ if (turn.activityInFlight == null) {
17997
+ turn.activityInFlight = drainActivitySummary(turn)
17998
+ }
17999
+ }
18000
+ return
18001
+ }
17839
18002
 
17840
18003
  // #PR2 live worker-feed: when ON, the worker's live chat
17841
18004
  // message owns the progress beat. Push a running cue and
@@ -79,6 +79,21 @@ export function spoolId(msg: InboundMessage): string {
79
79
  ) {
80
80
  return `s:progress:${msg.meta.subagent_jsonl_id}:${msg.meta.bucket_idx}`
81
81
  }
82
+ // Boot-resume inbounds (honest-restart-resume): deterministic per
83
+ // interrupted turn so a multi-restart sequence (operator restarts again
84
+ // before the agent drains the first resume) collapses to ONE resume of
85
+ // a given turn instead of stacking N. Keyed on the synthetic messageId
86
+ // (=ts, fresh every boot) would re-fire each boot; the turn_key is the
87
+ // stable identity. Both resume sources share the namespace because a
88
+ // given turn can only be one or the other.
89
+ if (
90
+ (msg.meta?.source === 'resume_interrupted' ||
91
+ msg.meta?.source === 'resume_watchdog_timeout') &&
92
+ typeof msg.meta?.resume_turn_key === 'string' &&
93
+ msg.meta.resume_turn_key.length > 0
94
+ ) {
95
+ return `s:resume:${msg.meta.resume_turn_key}`
96
+ }
82
97
  if (typeof msg.messageId === 'number' && msg.messageId > 0) {
83
98
  return `m:${msg.chatId}:${msg.messageId}`
84
99
  }
@@ -98,3 +98,9 @@ export function resolveInterruptMaxWaitMs(configured: number | undefined): numbe
98
98
  if (typeof configured === 'number' && configured > 0) return configured
99
99
  return DEFAULT_INTERRUPT_MAX_WAIT_MS
100
100
  }
101
+
102
+ /** safe_boundary defaults ON: a `!` mid-tool-call is deferred to a clean
103
+ * boundary unless the operator explicitly sets it false. */
104
+ export function resolveSafeBoundaryEnabled(configured: boolean | undefined): boolean {
105
+ return configured !== false
106
+ }