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.
- package/dist/agent-scheduler/index.js +2 -3
- package/dist/auth-broker/index.js +2 -3
- package/dist/cli/notion-write-pretool.mjs +2 -3
- package/dist/cli/switchroom.js +16 -8
- package/dist/host-control/main.js +2 -3
- package/dist/vault/approvals/kernel-server.js +2 -3
- package/dist/vault/broker/server.js +2 -3
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +11 -24
- package/profiles/_shared/telegram-style.md.hbs +2 -2
- package/profiles/default/CLAUDE.md.hbs +4 -1
- package/skills/switchroom-runtime/SKILL.md +6 -16
- package/telegram-plugin/agent-dir.ts +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +655 -514
- package/telegram-plugin/gateway/coalesce-attachments.ts +9 -0
- package/telegram-plugin/gateway/gateway.ts +246 -83
- package/telegram-plugin/gateway/inbound-spool.ts +15 -0
- package/telegram-plugin/gateway/interrupt-defer.ts +6 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +180 -0
- package/telegram-plugin/registry/turns-schema.ts +138 -33
- package/telegram-plugin/stream-reply-handler.ts +1 -11
- package/telegram-plugin/tests/agent-dir.test.ts +25 -0
- package/telegram-plugin/tests/coalesce-attachments.test.ts +24 -6
- package/telegram-plugin/tests/e2e.test.ts +2 -77
- package/telegram-plugin/tests/inbound-spool.test.ts +45 -0
- package/telegram-plugin/tests/interrupt-defer.test.ts +13 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
- package/telegram-plugin/tests/races.test.ts +0 -26
- package/telegram-plugin/tests/registry-turns.test.ts +106 -29
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +182 -0
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +0 -24
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tests/turns-writer.test.ts +16 -6
- package/telegram-plugin/tests/worker-activity-feed.test.ts +14 -0
- package/telegram-plugin/tool-activity-summary.ts +55 -0
- package/telegram-plugin/uat/assertions.ts +53 -0
- package/telegram-plugin/uat/driver.ts +30 -0
- package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
- package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
- package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
- package/telegram-plugin/worker-activity-feed.ts +11 -5
- package/telegram-plugin/handoff-continuity.ts +0 -206
- 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 {
|
|
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
|
-
|
|
420
|
+
markOrphanedWithTimeoutClassification,
|
|
422
421
|
recordTurnStart,
|
|
423
422
|
recordTurnEnd,
|
|
424
|
-
|
|
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
|
|
780
|
-
* (
|
|
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
|
|
784
|
-
* deferred until the in-flight tool finishes (bounded by
|
|
785
|
-
* interruptMaxWaitMs) before SIGINT + resume.
|
|
786
|
-
* synchronously
|
|
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
|
|
967
|
-
// On boot, open the per-agent registry.db and
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
//
|
|
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})
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
8731
|
-
//
|
|
8732
|
-
//
|
|
8733
|
-
//
|
|
8734
|
-
//
|
|
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
|
|
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
|
|
8789
|
-
// default cap
|
|
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
|
|
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).
|
|
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
|
|
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)
|
|
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
|
+
}
|