switchroom 0.5.0 → 0.7.8
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/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +503 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +558 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- package/bin/bridge-watchdog.sh +0 -967
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
type SubAgentState,
|
|
27
27
|
} from './progress-card.js'
|
|
28
28
|
import { isTelegramReplyTool } from './tool-names.js'
|
|
29
|
+
import { emitCardEvent } from './card-event-log.js'
|
|
30
|
+
import { createHash } from 'crypto'
|
|
29
31
|
import {
|
|
30
32
|
applyCapped as fleetApplyCapped,
|
|
31
33
|
applyToolResult as fleetApplyToolResult,
|
|
@@ -217,9 +219,26 @@ export interface ProgressDriverConfig {
|
|
|
217
219
|
* starts (see `promoteOnSubAgent`) — long-running tool work and
|
|
218
220
|
* background dispatches stay visible without waiting the full delay.
|
|
219
221
|
*
|
|
220
|
-
* Default
|
|
222
|
+
* Default 45000 (45 seconds, #842). Set to 0 to disable.
|
|
221
223
|
*/
|
|
222
224
|
initialDelayMs?: number
|
|
225
|
+
/**
|
|
226
|
+
* First-render delay (ms) override for explicit background sub-agent
|
|
227
|
+
* dispatches (#842). When the agent calls
|
|
228
|
+
* `Agent({ run_in_background: true })`, the card is promoted out of
|
|
229
|
+
* the suppression window using this delay instead of `initialDelayMs`.
|
|
230
|
+
* Default 0 (immediate render — backgrounded work should be visible
|
|
231
|
+
* right away).
|
|
232
|
+
*
|
|
233
|
+
* Implementation: at `tool_use` ingest time the driver detects the
|
|
234
|
+
* background flag (existing `cs.backgroundParentToolUseIds` book-
|
|
235
|
+
* keeping). If `initialDelayMsBackground` is 0 the card promotes
|
|
236
|
+
* immediately via `promoteFirstEmit`. If positive, the deferred timer
|
|
237
|
+
* is rescheduled to fire that many ms from turn start (or now, if
|
|
238
|
+
* already past) — but only when shorter than what's currently
|
|
239
|
+
* scheduled. Never lengthens an in-flight delay.
|
|
240
|
+
*/
|
|
241
|
+
initialDelayMsBackground?: number
|
|
223
242
|
/**
|
|
224
243
|
* Promote the first emit immediately when a sub-agent transitions to
|
|
225
244
|
* running during the suppression window, when the watcher fires
|
|
@@ -417,6 +436,16 @@ interface PerChatState {
|
|
|
417
436
|
isFirstEmit: boolean
|
|
418
437
|
/** Timer for the deferred first emit (initial-delay suppression). */
|
|
419
438
|
deferredFirstEmitTimer: unknown
|
|
439
|
+
/**
|
|
440
|
+
* #842: per-chat first-emit delay budget in ms. Initialised to
|
|
441
|
+
* `config.initialDelayMs`; lowered to `config.initialDelayMsBackground`
|
|
442
|
+
* the first time the parent dispatches an Agent/Task with
|
|
443
|
+
* `run_in_background: true`. Never increases. flush() reads this
|
|
444
|
+
* (instead of the closure-level `initialDelayMs`) when scheduling the
|
|
445
|
+
* deferred first-emit timer so the background bypass takes effect on
|
|
446
|
+
* the next scheduling pass.
|
|
447
|
+
*/
|
|
448
|
+
effectiveInitialDelayMs: number
|
|
420
449
|
/**
|
|
421
450
|
* F3 fix (#553): timer for the time-based first-emit promotion.
|
|
422
451
|
* Scheduled on the first ingest event; fires after `promoteAfterMs`
|
|
@@ -751,6 +780,18 @@ export interface ProgressDriver {
|
|
|
751
780
|
* No-op if no card is currently tracking this `agentId`.
|
|
752
781
|
*/
|
|
753
782
|
onSubAgentStall(agentId: string, idleMs: number, description: string): void
|
|
783
|
+
/**
|
|
784
|
+
* Symmetric to `onSubAgentStall`. Fires when the watcher observes
|
|
785
|
+
* JSONL activity returning for a previously-stalled sub-agent. Forces
|
|
786
|
+
* a re-render so the ⚠ Stalled badge clears immediately, instead of
|
|
787
|
+
* waiting on the next heartbeat tick (which the diff-guard might
|
|
788
|
+
* suppress if no chat-level state otherwise changed). The render
|
|
789
|
+
* itself reads the now-current `sa.lastEventAt` (already bumped by
|
|
790
|
+
* the standard event path), so this method is purely a render-trigger.
|
|
791
|
+
*
|
|
792
|
+
* No-op if no card is currently tracking this `agentId`.
|
|
793
|
+
*/
|
|
794
|
+
onSubAgentUnstall(agentId: string, description: string): void
|
|
754
795
|
/**
|
|
755
796
|
* Test-only accessor exposing the driver's internal Maps so unit tests
|
|
756
797
|
* can assert TTL eviction and outer-base-key cleanup actually drop
|
|
@@ -799,20 +840,29 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
799
840
|
const editBudgetThreshold = config.editBudgetThreshold ?? 18
|
|
800
841
|
const editBudgetCoalesceMs = config.editBudgetCoalesceMs ?? 3000
|
|
801
842
|
const maxIdleMs = config.maxIdleMs ?? 30 * 60_000
|
|
802
|
-
// v2 card-gate (#553 PR 4): card visibility is `(elapsed >=
|
|
803
|
-
// (any sub-agent appeared)
|
|
804
|
-
//
|
|
805
|
-
//
|
|
843
|
+
// v2 card-gate (#553 PR 4 / #842): card visibility is `(elapsed >= 45s)
|
|
844
|
+
// OR (any sub-agent appeared) OR (explicit background dispatch)`.
|
|
845
|
+
// Tools alone never trigger the card.
|
|
846
|
+
// - initialDelayMs: 45s (was 60s, #842) — pushes the time-based gate
|
|
847
|
+
// to the spec value. The lower threshold means more turns flash a
|
|
848
|
+
// card; the explicit-background bypass below offsets that for the
|
|
849
|
+
// "fire-and-forget" case where the user always wants to see the
|
|
850
|
+
// card immediately.
|
|
851
|
+
// - initialDelayMsBackground: 0 (#842) — explicit
|
|
852
|
+
// `Agent({run_in_background:true})` dispatches promote the card
|
|
853
|
+
// immediately. Lets backgrounded work be visible right away
|
|
854
|
+
// without waiting for any other promotion path.
|
|
806
855
|
// - promoteOnParentToolCount: 0 (was 3) — disabled. The check below
|
|
807
856
|
// treats 0 (and Infinity) as "never promote on tool count".
|
|
808
857
|
// - promoteAfterMs: 0 (was 5_000) — disabled. ensureTimePromoteScheduled
|
|
809
858
|
// no-ops when this is 0, so the timer never schedules. The PR #570
|
|
810
859
|
// time-promote was a stop-gap when initialDelayMs was 30s; with
|
|
811
|
-
// initialDelayMs=
|
|
860
|
+
// initialDelayMs=45s and the sub-agent promote intact, it is no
|
|
812
861
|
// longer needed.
|
|
813
862
|
// - promoteOnSubAgent: true (unchanged) — sub-agents/background workers
|
|
814
863
|
// break the suppression immediately.
|
|
815
|
-
const initialDelayMs = config.initialDelayMs ??
|
|
864
|
+
const initialDelayMs = config.initialDelayMs ?? 45_000
|
|
865
|
+
const initialDelayMsBackground = config.initialDelayMsBackground ?? 0
|
|
816
866
|
const promoteOnSubAgent = config.promoteOnSubAgent ?? true
|
|
817
867
|
const promoteOnParentToolCount = config.promoteOnParentToolCount ?? 0
|
|
818
868
|
const promoteAfterMs = config.promoteAfterMs ?? 0
|
|
@@ -985,6 +1035,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
985
1035
|
}
|
|
986
1036
|
if (config.onTurnComplete) {
|
|
987
1037
|
process.stderr.write(`telegram gateway: progress-card: onTurnComplete firing turnKey=${cs.turnKey}\n`)
|
|
1038
|
+
emitCardEvent({
|
|
1039
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
1040
|
+
chatId: cs.chatId ?? '',
|
|
1041
|
+
turnKey: cs.turnKey,
|
|
1042
|
+
event: 'finalized',
|
|
1043
|
+
reason: 'onTurnComplete',
|
|
1044
|
+
})
|
|
988
1045
|
try {
|
|
989
1046
|
config.onTurnComplete({
|
|
990
1047
|
chatId: cs.chatId,
|
|
@@ -1050,6 +1107,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1050
1107
|
if (hasAnyRunningSubAgent(cs.state)) return
|
|
1051
1108
|
if (hasLiveBackground(cs.fleet)) return
|
|
1052
1109
|
process.stderr.write(`telegram gateway: progress-card: deferred completion firing turnKey=${cs.turnKey} (last sub-agent finished)\n`)
|
|
1110
|
+
emitCardEvent({
|
|
1111
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
1112
|
+
chatId: cs.chatId ?? '',
|
|
1113
|
+
turnKey: cs.turnKey,
|
|
1114
|
+
event: 'force-completed',
|
|
1115
|
+
reason: 'deferred-completion: last sub-agent finished',
|
|
1116
|
+
})
|
|
1053
1117
|
// Route through the unified close path (turn-end reason) so the
|
|
1054
1118
|
// prelude (silentEnd suppression, final flush, tail cleanup) matches
|
|
1055
1119
|
// every other completion site.
|
|
@@ -1505,27 +1569,45 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1505
1569
|
// Suppress the card entirely if the turn ends before the initial
|
|
1506
1570
|
// delay has elapsed — no point flashing a "Working…" card for a
|
|
1507
1571
|
// turn that completed in under initialDelayMs.
|
|
1508
|
-
|
|
1572
|
+
const effectiveDelayMs = chatState.effectiveInitialDelayMs
|
|
1573
|
+
if (chatState.isFirstEmit && effectiveDelayMs > 0 && chatState.deferredFirstEmitTimer !== DELAY_ELAPSED) {
|
|
1509
1574
|
if (forceDone || chatState.state.stage === 'done') {
|
|
1510
1575
|
// Turn ended before the card was ever shown — suppress it.
|
|
1511
1576
|
if (chatState.deferredFirstEmitTimer != null) {
|
|
1512
1577
|
clearT(chatState.deferredFirstEmitTimer)
|
|
1513
1578
|
chatState.deferredFirstEmitTimer = null
|
|
1514
1579
|
}
|
|
1515
|
-
process.stderr.write(`telegram gateway: progress-card: fast-turn suppression turnKey=${chatState.turnKey} (turn ended before initialDelayMs=${
|
|
1580
|
+
process.stderr.write(`telegram gateway: progress-card: fast-turn suppression turnKey=${chatState.turnKey} (turn ended before initialDelayMs=${effectiveDelayMs}ms)\n`)
|
|
1581
|
+
emitCardEvent({
|
|
1582
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
1583
|
+
chatId: chatState.chatId ?? '',
|
|
1584
|
+
turnKey: chatState.turnKey,
|
|
1585
|
+
event: 'suppressed',
|
|
1586
|
+
reason: `fast-turn: ended before initialDelayMs=${effectiveDelayMs}`,
|
|
1587
|
+
})
|
|
1516
1588
|
return
|
|
1517
1589
|
}
|
|
1518
|
-
// Defer the first emit — schedule it for
|
|
1519
|
-
// if not already scheduled.
|
|
1590
|
+
// Defer the first emit — schedule it for the per-chat budget from
|
|
1591
|
+
// turn start if not already scheduled. Uses
|
|
1592
|
+
// `chatState.effectiveInitialDelayMs` so the #842 background-
|
|
1593
|
+
// dispatch bypass (which lowers this number on tool_use) takes
|
|
1594
|
+
// effect on the very next flush.
|
|
1520
1595
|
if (chatState.deferredFirstEmitTimer == null) {
|
|
1521
1596
|
const capturedTurnKey = chatState.turnKey
|
|
1522
|
-
|
|
1597
|
+
// Schedule from turn start, not from now — multiple flush
|
|
1598
|
+
// attempts during the buffering window must not push the
|
|
1599
|
+
// first-emit clock back.
|
|
1600
|
+
const elapsed = chatState.state.turnStartedAt > 0
|
|
1601
|
+
? Math.max(0, now() - chatState.state.turnStartedAt)
|
|
1602
|
+
: 0
|
|
1603
|
+
const remaining = Math.max(0, effectiveDelayMs - elapsed)
|
|
1604
|
+
process.stderr.write(`telegram gateway: progress-card: scheduled initial-delay timer turnKey=${capturedTurnKey} delay=${remaining}ms budget=${effectiveDelayMs}ms\n`)
|
|
1523
1605
|
chatState.deferredFirstEmitTimer = setT(() => {
|
|
1524
1606
|
if (!chats.has(capturedTurnKey)) return
|
|
1525
1607
|
chatState.deferredFirstEmitTimer = DELAY_ELAPSED
|
|
1526
1608
|
process.stderr.write(`telegram gateway: progress-card: initial-delay timer fired turnKey=${capturedTurnKey}\n`)
|
|
1527
1609
|
flush(chatState, false)
|
|
1528
|
-
},
|
|
1610
|
+
}, remaining)
|
|
1529
1611
|
}
|
|
1530
1612
|
return
|
|
1531
1613
|
}
|
|
@@ -1613,6 +1695,18 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1613
1695
|
? { replyToMessageId: chatState.replyToMessageId }
|
|
1614
1696
|
: {}),
|
|
1615
1697
|
})
|
|
1698
|
+
// #card-audit-log: structured lifecycle entry for retroactive audit.
|
|
1699
|
+
// Mirrors the existing free-text traces but is grep-able by turnKey.
|
|
1700
|
+
emitCardEvent({
|
|
1701
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
1702
|
+
chatId: chatState.chatId ?? '',
|
|
1703
|
+
turnKey: chatState.turnKey,
|
|
1704
|
+
event: isFirst ? 'rendered' : (terminal ? 'finalized' : 'edited'),
|
|
1705
|
+
reason: terminal ? 'flush-terminal' : (isFirst ? 'flush-first' : 'flush-edit'),
|
|
1706
|
+
htmlHash: html.length > 0
|
|
1707
|
+
? createHash('sha1').update(html).digest('hex').slice(0, 12)
|
|
1708
|
+
: undefined,
|
|
1709
|
+
})
|
|
1616
1710
|
}
|
|
1617
1711
|
|
|
1618
1712
|
/**
|
|
@@ -1758,6 +1852,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1758
1852
|
role,
|
|
1759
1853
|
startedAt: now(),
|
|
1760
1854
|
originatingTurnKey: currentTurnKey ?? cs.turnKey,
|
|
1855
|
+
isBackgroundDispatch: isBackground,
|
|
1761
1856
|
})
|
|
1762
1857
|
cs.fleet.set(event.agentId, isBackground ? { ...member, status: 'background' } : member)
|
|
1763
1858
|
return
|
|
@@ -1813,6 +1908,9 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1813
1908
|
// a stuck condition. `originatingTurnKey` has no legacy
|
|
1814
1909
|
// counterpart — fall back to the current/active turn.
|
|
1815
1910
|
const startedAt = sa.startedAt > 0 ? sa.startedAt : now()
|
|
1911
|
+
const isBg =
|
|
1912
|
+
sa.parentToolUseId != null &&
|
|
1913
|
+
cs.backgroundParentToolUseIds.has(sa.parentToolUseId)
|
|
1816
1914
|
cs.fleet.set(
|
|
1817
1915
|
agentId,
|
|
1818
1916
|
createFleetMember({
|
|
@@ -1820,6 +1918,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1820
1918
|
role: sa.description ?? 'agent',
|
|
1821
1919
|
startedAt,
|
|
1822
1920
|
originatingTurnKey: currentTurnKey ?? cs.turnKey,
|
|
1921
|
+
isBackgroundDispatch: isBg,
|
|
1823
1922
|
}),
|
|
1824
1923
|
)
|
|
1825
1924
|
}
|
|
@@ -1979,6 +2078,7 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
1979
2078
|
pendingTimer: null,
|
|
1980
2079
|
isFirstEmit: true,
|
|
1981
2080
|
deferredFirstEmitTimer: null,
|
|
2081
|
+
effectiveInitialDelayMs: initialDelayMs,
|
|
1982
2082
|
timePromoteTimer: null,
|
|
1983
2083
|
lastEventAt: now(),
|
|
1984
2084
|
pendingCompletion: false,
|
|
@@ -2125,6 +2225,60 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
2125
2225
|
promoteFirstEmit(chatState, 'sub_agent_started')
|
|
2126
2226
|
}
|
|
2127
2227
|
|
|
2228
|
+
// #842: explicit background dispatch bypass. When the parent calls
|
|
2229
|
+
// `Agent({ run_in_background: true })`, swap the active delay
|
|
2230
|
+
// budget over to `initialDelayMsBackground` instead of the longer
|
|
2231
|
+
// `initialDelayMs`. Detection: an Agent/Task tool_use whose
|
|
2232
|
+
// `event.input.run_in_background === true` (the same flag
|
|
2233
|
+
// `updateFleetForEvent` uses to populate
|
|
2234
|
+
// `cs.backgroundParentToolUseIds` for fleet membership).
|
|
2235
|
+
//
|
|
2236
|
+
// - `initialDelayMsBackground === 0` (default) → promote now.
|
|
2237
|
+
// - `initialDelayMsBackground > 0` → set
|
|
2238
|
+
// `cs.effectiveInitialDelayMs` so the next flush() schedules
|
|
2239
|
+
// (or reschedules) the deferred timer at the lower budget.
|
|
2240
|
+
// Never lengthens an existing budget.
|
|
2241
|
+
if (
|
|
2242
|
+
event.kind === 'tool_use'
|
|
2243
|
+
&& (event.toolName === 'Agent' || event.toolName === 'Task')
|
|
2244
|
+
&& event.toolUseId != null
|
|
2245
|
+
&& event.input?.run_in_background === true
|
|
2246
|
+
&& chatState.isFirstEmit
|
|
2247
|
+
&& chatState.deferredFirstEmitTimer !== DELAY_ELAPSED
|
|
2248
|
+
&& !chatState.apiFailures.terminal
|
|
2249
|
+
) {
|
|
2250
|
+
if (initialDelayMsBackground <= 0) {
|
|
2251
|
+
promoteFirstEmit(chatState, 'background_dispatch')
|
|
2252
|
+
} else if (initialDelayMsBackground < chatState.effectiveInitialDelayMs) {
|
|
2253
|
+
chatState.effectiveInitialDelayMs = initialDelayMsBackground
|
|
2254
|
+
// If a longer-budget timer is already scheduled, cancel and
|
|
2255
|
+
// reschedule against the new budget. Compute the remaining
|
|
2256
|
+
// gap from turn start; if we're already past it, promote.
|
|
2257
|
+
if (chatState.deferredFirstEmitTimer != null) {
|
|
2258
|
+
const elapsed = now() - chatState.state.turnStartedAt
|
|
2259
|
+
const remaining = initialDelayMsBackground - elapsed
|
|
2260
|
+
clearT(chatState.deferredFirstEmitTimer)
|
|
2261
|
+
if (remaining <= 0) {
|
|
2262
|
+
chatState.deferredFirstEmitTimer = null
|
|
2263
|
+
promoteFirstEmit(chatState, 'background_dispatch_elapsed')
|
|
2264
|
+
} else {
|
|
2265
|
+
const capturedTurnKey = chatState.turnKey
|
|
2266
|
+
process.stderr.write(
|
|
2267
|
+
`telegram gateway: progress-card: rescheduled initial-delay timer turnKey=${capturedTurnKey} delay=${remaining}ms reason=background_dispatch\n`,
|
|
2268
|
+
)
|
|
2269
|
+
chatState.deferredFirstEmitTimer = setT(() => {
|
|
2270
|
+
if (!chats.has(capturedTurnKey)) return
|
|
2271
|
+
chatState.deferredFirstEmitTimer = DELAY_ELAPSED
|
|
2272
|
+
process.stderr.write(
|
|
2273
|
+
`telegram gateway: progress-card: initial-delay timer fired turnKey=${capturedTurnKey} reason=background_dispatch\n`,
|
|
2274
|
+
)
|
|
2275
|
+
flush(chatState, false)
|
|
2276
|
+
}, remaining)
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2128
2282
|
// #478 / #553 PR 4: promote the card when the agent has issued
|
|
2129
2283
|
// enough parent-side tool calls during the suppression window.
|
|
2130
2284
|
// Disabled by default in v2 (promoteOnParentToolCount=0 / Infinity)
|
|
@@ -2250,6 +2404,14 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
2250
2404
|
if (m.status === 'background' && m.terminalAt == null) background.push(k)
|
|
2251
2405
|
}
|
|
2252
2406
|
process.stderr.write(`telegram gateway: progress-card: turn_end deferred turnKey=${chatState.turnKey} reason=in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} background=${background.length} correlatedAgentIds=[${correlated.join(',')}] orphanAgentIds=[${orphans.join(',')}] backgroundAgentIds=[${background.join(',')}]\n`)
|
|
2407
|
+
emitCardEvent({
|
|
2408
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
2409
|
+
chatId: chatState.chatId ?? '',
|
|
2410
|
+
turnKey: chatState.turnKey,
|
|
2411
|
+
event: 'deferred',
|
|
2412
|
+
reason: `turn_end: in-flight-sub-agents correlated=${correlated.length} orphans=${orphans.length} background=${background.length}`,
|
|
2413
|
+
subagents: [...correlated, ...orphans, ...background],
|
|
2414
|
+
})
|
|
2253
2415
|
return
|
|
2254
2416
|
}
|
|
2255
2417
|
closePerChat(chatState, 'turn-end')
|
|
@@ -2363,6 +2525,13 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
2363
2525
|
// running sub-agents just because the final reply was sent.
|
|
2364
2526
|
if (target.completionFired) return
|
|
2365
2527
|
process.stderr.write(`telegram gateway: progress-card: forceCompleteTurn turnKey=${target.turnKey} (external completion signal, e.g. stream_reply done=true)\n`)
|
|
2528
|
+
emitCardEvent({
|
|
2529
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
2530
|
+
chatId: target.chatId ?? '',
|
|
2531
|
+
turnKey: target.turnKey,
|
|
2532
|
+
event: 'force-completed',
|
|
2533
|
+
reason: 'external completion signal (stream_reply done=true)',
|
|
2534
|
+
})
|
|
2366
2535
|
const durationMs = Math.max(0, now() - target.state.turnStartedAt)
|
|
2367
2536
|
beginTurnEnd(target, durationMs)
|
|
2368
2537
|
target.lastEventAt = now()
|
|
@@ -2678,6 +2847,26 @@ export function createProgressDriver(config: ProgressDriverConfig): ProgressDriv
|
|
|
2678
2847
|
}
|
|
2679
2848
|
},
|
|
2680
2849
|
|
|
2850
|
+
onSubAgentUnstall(agentId: string, _description: string) {
|
|
2851
|
+
// Symmetric to onSubAgentStall: watcher saw JSONL activity return.
|
|
2852
|
+
// The standard event path has already bumped sa.lastEventAt and
|
|
2853
|
+
// (for tool events) flipped fleet member status stuck→running via
|
|
2854
|
+
// applyToolUse. All this method needs to do is force a re-render
|
|
2855
|
+
// so the ⚠ badge clears immediately — the diff-guard can otherwise
|
|
2856
|
+
// suppress the heartbeat for several seconds if no chat-level
|
|
2857
|
+
// state changed, which manifests as the badge lingering even
|
|
2858
|
+
// though the underlying state is fresh.
|
|
2859
|
+
for (const cs of chats.values()) {
|
|
2860
|
+
if (!cs.state.subAgents.has(agentId)) continue
|
|
2861
|
+
const sa = cs.state.subAgents.get(agentId)!
|
|
2862
|
+
if (sa.state !== 'running') continue
|
|
2863
|
+
lastHeartbeatBucket.delete(cs.turnKey)
|
|
2864
|
+
lastSubAgentTickBucket.delete(cs.turnKey)
|
|
2865
|
+
if (chats.size > 0) startHeartbeatIfNeeded()
|
|
2866
|
+
break
|
|
2867
|
+
}
|
|
2868
|
+
},
|
|
2869
|
+
|
|
2681
2870
|
/**
|
|
2682
2871
|
* Test-only accessor. Returns the live internal Maps so tests can
|
|
2683
2872
|
* assert TTL eviction and outer-base-key cleanup actually drop
|
|
@@ -504,7 +504,7 @@ export function reduce(
|
|
|
504
504
|
id: state.items.length,
|
|
505
505
|
toolUseId: event.toolUseId ?? null,
|
|
506
506
|
tool: event.toolName,
|
|
507
|
-
label: toolLabel(event.toolName, event.input, preamble),
|
|
507
|
+
label: toolLabel(event.toolName, event.input, preamble, event.precomputedLabel),
|
|
508
508
|
humanAuthored: isHumanDescription(event.toolName, event.input),
|
|
509
509
|
state: 'running',
|
|
510
510
|
startedAt: now,
|
|
@@ -833,7 +833,7 @@ export function reduce(
|
|
|
833
833
|
currentTool: event.toolUseId
|
|
834
834
|
? {
|
|
835
835
|
tool: event.toolName,
|
|
836
|
-
label: toolLabel(event.toolName, event.input, preamble),
|
|
836
|
+
label: toolLabel(event.toolName, event.input, preamble, event.precomputedLabel),
|
|
837
837
|
humanAuthored: isHumanDescription(event.toolName, event.input),
|
|
838
838
|
toolUseId: event.toolUseId,
|
|
839
839
|
startedAt: now,
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Status transitions:
|
|
26
26
|
* running → stalled (via recordSubagentStall — no ended_at, may resume)
|
|
27
|
+
* stalled → running (via recordSubagentResume — JSONL activity returned
|
|
28
|
+
* before terminal; closes the resume edge the watcher
|
|
29
|
+
* documented but never wired)
|
|
27
30
|
* running → completed (via recordSubagentEnd)
|
|
28
31
|
* running → failed (via recordSubagentEnd)
|
|
29
32
|
* stalled → completed (via recordSubagentEnd — terminal beats stalled)
|
|
@@ -139,6 +142,14 @@ export interface BumpSubagentActivityArgs {
|
|
|
139
142
|
ts: number
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
export interface RecordSubagentResumeArgs {
|
|
146
|
+
id: string
|
|
147
|
+
/** Wall-clock when the resume was observed. Not stored — last_activity_at
|
|
148
|
+
* is updated separately by bumpSubagentActivity. Available for callers
|
|
149
|
+
* that want to log it. */
|
|
150
|
+
resumedAt: number
|
|
151
|
+
}
|
|
152
|
+
|
|
142
153
|
export interface ReapStuckRunningArgs {
|
|
143
154
|
/**
|
|
144
155
|
* Maximum age (ms since `last_activity_at`, or since `started_at` for rows
|
|
@@ -458,6 +469,32 @@ export function reapStuckRunningRows(
|
|
|
458
469
|
return { reaped: candidates.length, ids: candidates.map((r) => r.id) }
|
|
459
470
|
}
|
|
460
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Reverse the stalled→running edge when JSONL activity returns. Mirror of
|
|
474
|
+
* `recordSubagentStall` for the resume direction the schema doc has always
|
|
475
|
+
* promised but the watcher never implemented (the cause of "card freezes
|
|
476
|
+
* at ⚠ Stalled even after sub-agent resumes / completes" — see
|
|
477
|
+
* subagent-watcher.ts checkStalls + bumpSubagentActivity).
|
|
478
|
+
*
|
|
479
|
+
* Idempotent + safe:
|
|
480
|
+
* - Only flips rows where status is currently 'stalled'. A row that's
|
|
481
|
+
* already 'running' is untouched (no-op UPDATE). A terminal row
|
|
482
|
+
* ('completed' / 'failed') stays terminal — terminal beats both
|
|
483
|
+
* stalled and running.
|
|
484
|
+
* - No-ops gracefully if `id` is not found.
|
|
485
|
+
* - last_activity_at is NOT touched here — callers separately call
|
|
486
|
+
* bumpSubagentActivity for the activity bump on the same tick.
|
|
487
|
+
*/
|
|
488
|
+
export function recordSubagentResume(db: SqliteDatabase, args: RecordSubagentResumeArgs): void {
|
|
489
|
+
void args.resumedAt // available for log lines; not persisted (started_at + last_activity_at carry the timing)
|
|
490
|
+
db.prepare(`
|
|
491
|
+
UPDATE subagents
|
|
492
|
+
SET status = 'running'
|
|
493
|
+
WHERE id = ?
|
|
494
|
+
AND status = 'stalled'
|
|
495
|
+
`).run(args.id)
|
|
496
|
+
}
|
|
497
|
+
|
|
461
498
|
/**
|
|
462
499
|
* Bump `last_activity_at` for a subagent. Used by the watcher (Phase 3) each
|
|
463
500
|
* time the subagent's JSONL file mtime advances.
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
recordSubagentStart,
|
|
25
25
|
recordSubagentEnd,
|
|
26
26
|
recordSubagentStall,
|
|
27
|
+
recordSubagentResume,
|
|
27
28
|
bumpSubagentActivity,
|
|
28
29
|
getSubagent,
|
|
29
30
|
reapStuckRunningRows,
|
|
@@ -230,6 +231,69 @@ describe('start → stall → end', () => {
|
|
|
230
231
|
})
|
|
231
232
|
})
|
|
232
233
|
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Test 4b — recordSubagentResume (stalled → running edge)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
//
|
|
238
|
+
// The schema doc (subagents-schema.ts:26) has always promised
|
|
239
|
+
// "running → stalled (may resume)" but the resume edge wasn't
|
|
240
|
+
// implemented — leaving the registry stuck at 'stalled' even when
|
|
241
|
+
// JSONL activity returned. recordSubagentResume closes that gap.
|
|
242
|
+
|
|
243
|
+
describe('recordSubagentResume — stalled → running edge', () => {
|
|
244
|
+
it('flips a stalled row back to running', () => {
|
|
245
|
+
const db = openFreshSubagentsDbInMemory()
|
|
246
|
+
recordSubagentStart(db, { id: 'sa-r1', background: false, startedAt: 1000 })
|
|
247
|
+
recordSubagentStall(db, { id: 'sa-r1', stalledAt: 1500 })
|
|
248
|
+
expect(getSubagent(db, 'sa-r1')!.status).toBe('stalled')
|
|
249
|
+
recordSubagentResume(db, { id: 'sa-r1', resumedAt: 2000 })
|
|
250
|
+
const row = getSubagent(db, 'sa-r1')
|
|
251
|
+
expect(row!.status).toBe('running')
|
|
252
|
+
expect(row!.ended_at).toBeNull()
|
|
253
|
+
db.close()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('is a no-op on a row that is already running', () => {
|
|
257
|
+
// Idempotency: the watcher fires resume on the first activity tick
|
|
258
|
+
// after a stall, but the same code path is also reached during
|
|
259
|
+
// normal activity bumps where stallNotified is already false. We
|
|
260
|
+
// never want a redundant resume to spuriously demote a row.
|
|
261
|
+
const db = openFreshSubagentsDbInMemory()
|
|
262
|
+
recordSubagentStart(db, { id: 'sa-r2', background: false, startedAt: 1000 })
|
|
263
|
+
recordSubagentResume(db, { id: 'sa-r2', resumedAt: 2000 })
|
|
264
|
+
expect(getSubagent(db, 'sa-r2')!.status).toBe('running')
|
|
265
|
+
db.close()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('is a no-op on a completed row — terminal beats resume', () => {
|
|
269
|
+
const db = openFreshSubagentsDbInMemory()
|
|
270
|
+
recordSubagentStart(db, { id: 'sa-r3', background: false, startedAt: 1000 })
|
|
271
|
+
recordSubagentEnd(db, { id: 'sa-r3', endedAt: 2000, status: 'completed' })
|
|
272
|
+
recordSubagentResume(db, { id: 'sa-r3', resumedAt: 3000 })
|
|
273
|
+
const row = getSubagent(db, 'sa-r3')
|
|
274
|
+
expect(row!.status).toBe('completed')
|
|
275
|
+
expect(row!.ended_at).toBe(2000)
|
|
276
|
+
db.close()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('is a no-op on a failed row', () => {
|
|
280
|
+
const db = openFreshSubagentsDbInMemory()
|
|
281
|
+
recordSubagentStart(db, { id: 'sa-r4', background: false, startedAt: 1000 })
|
|
282
|
+
recordSubagentEnd(db, { id: 'sa-r4', endedAt: 2000, status: 'failed' })
|
|
283
|
+
recordSubagentResume(db, { id: 'sa-r4', resumedAt: 3000 })
|
|
284
|
+
expect(getSubagent(db, 'sa-r4')!.status).toBe('failed')
|
|
285
|
+
db.close()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('is a no-op on a missing row (graceful)', () => {
|
|
289
|
+
const db = openFreshSubagentsDbInMemory()
|
|
290
|
+
expect(() =>
|
|
291
|
+
recordSubagentResume(db, { id: 'sa-nope', resumedAt: 1000 }),
|
|
292
|
+
).not.toThrow()
|
|
293
|
+
db.close()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
233
297
|
// ---------------------------------------------------------------------------
|
|
234
298
|
// Test 5 — Duplicate start is a no-op
|
|
235
299
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +36,7 @@ import { homedir } from 'os'
|
|
|
36
36
|
import { basename, join } from 'path'
|
|
37
37
|
import { isMultiAgentEnabled } from './progress-card.js'
|
|
38
38
|
import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
|
|
39
|
+
import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
|
|
39
40
|
|
|
40
41
|
/** Match Claude Code's cli.js VX() function. */
|
|
41
42
|
export function sanitizeCwdToProjectName(cwd: string): string {
|
|
@@ -86,7 +87,7 @@ export type SessionEvent =
|
|
|
86
87
|
| { kind: 'enqueue'; chatId: string | null; messageId: string | null; threadId: string | null; rawContent: string; isSync?: boolean }
|
|
87
88
|
| { kind: 'dequeue' }
|
|
88
89
|
| { kind: 'thinking' }
|
|
89
|
-
| { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown
|
|
90
|
+
| { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
|
|
90
91
|
| { kind: 'text'; text: string }
|
|
91
92
|
| { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
|
|
92
93
|
| { kind: 'turn_end'; durationMs: number }
|
|
@@ -94,7 +95,7 @@ export type SessionEvent =
|
|
|
94
95
|
// filename stem (e.g. "aac6f1…"). Routed through the same ingest path
|
|
95
96
|
// as parent events; the reducer fans them out to per-sub-agent state.
|
|
96
97
|
| { kind: 'sub_agent_started'; agentId: string; firstPromptText: string; subagentType?: string }
|
|
97
|
-
| { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown
|
|
98
|
+
| { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
|
|
98
99
|
| { kind: 'sub_agent_text'; agentId: string; text: string }
|
|
99
100
|
| { kind: 'sub_agent_narrative'; agentId: string; text: string }
|
|
100
101
|
| { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
|
|
@@ -499,11 +500,53 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
499
500
|
const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
|
|
500
501
|
const rescanMs = config.rescanIntervalMs ?? 500
|
|
501
502
|
const log = config.log
|
|
502
|
-
const
|
|
503
|
+
const rawOnEvent = config.onEvent
|
|
503
504
|
const onOperatorEvent = config.onOperatorEvent
|
|
504
505
|
|
|
505
506
|
log?.(`session-tail: projectsDir=${projectsDir}`)
|
|
506
507
|
|
|
508
|
+
// PreToolUse sidecar readers (#783) keyed by sessionId. Created lazily
|
|
509
|
+
// the first time we observe a tool_use / sub_agent_tool_use whose
|
|
510
|
+
// toolUseId could be looked up. The hook writes to
|
|
511
|
+
// $TELEGRAM_STATE_DIR/tool-labels-<session_id>.jsonl. Each sub-agent
|
|
512
|
+
// has its OWN sessionId (its jsonl filename stem), so we key by that.
|
|
513
|
+
const sidecars = new Map<string, ToolLabelSidecar>()
|
|
514
|
+
const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null
|
|
515
|
+
function sessionIdForFile(file: string | null): string | null {
|
|
516
|
+
if (!file) return null
|
|
517
|
+
const b = file.endsWith('.jsonl') ? basename(file, '.jsonl') : null
|
|
518
|
+
return b && b.length > 0 ? b : null
|
|
519
|
+
}
|
|
520
|
+
function ensureSidecar(sessionId: string): ToolLabelSidecar | null {
|
|
521
|
+
if (!stateDirForSidecar) return null
|
|
522
|
+
const existing = sidecars.get(sessionId)
|
|
523
|
+
if (existing) return existing
|
|
524
|
+
try {
|
|
525
|
+
const s = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId })
|
|
526
|
+
sidecars.set(sessionId, s)
|
|
527
|
+
return s
|
|
528
|
+
} catch (err) {
|
|
529
|
+
log?.(`session-tail: sidecar create failed: ${(err as Error).message}`)
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function decorate(ev: SessionEvent, sessionId: string | null): SessionEvent {
|
|
534
|
+
if (!sessionId) return ev
|
|
535
|
+
if (ev.kind !== 'tool_use' && ev.kind !== 'sub_agent_tool_use') return ev
|
|
536
|
+
if (!ev.toolUseId) return ev
|
|
537
|
+
const s = ensureSidecar(sessionId)
|
|
538
|
+
if (!s) return ev
|
|
539
|
+
// One quick poll attempt before lookup — the hook is synchronous from
|
|
540
|
+
// Claude Code's perspective and the sidecar line is typically on disk
|
|
541
|
+
// before the JSONL row is appended, but the file watcher is on a
|
|
542
|
+
// 250ms tick. Forcing a poll closes the race for the common case.
|
|
543
|
+
s.poll()
|
|
544
|
+
const label = s.getLabel(ev.toolUseId)
|
|
545
|
+
if (!label) return ev
|
|
546
|
+
return { ...ev, precomputedLabel: label }
|
|
547
|
+
}
|
|
548
|
+
const onEvent = (ev: SessionEvent): void => rawOnEvent(ev)
|
|
549
|
+
|
|
507
550
|
let currentFile: string | null = null
|
|
508
551
|
let cursor = 0 // byte offset of next read
|
|
509
552
|
let watcher: FSWatcher | null = null
|
|
@@ -550,9 +593,10 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
550
593
|
for (const line of lines) {
|
|
551
594
|
if (!line) continue
|
|
552
595
|
const events = projectTranscriptLine(line)
|
|
596
|
+
const sid = sessionIdForFile(currentFile)
|
|
553
597
|
for (const ev of events) {
|
|
554
598
|
try {
|
|
555
|
-
onEvent(ev)
|
|
599
|
+
onEvent(decorate(ev, sid))
|
|
556
600
|
} catch (err) {
|
|
557
601
|
log?.(`session-tail: onEvent threw: ${(err as Error).message}`)
|
|
558
602
|
}
|
|
@@ -721,7 +765,12 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
721
765
|
t.hasSeenTerminal = true
|
|
722
766
|
}
|
|
723
767
|
try {
|
|
724
|
-
|
|
768
|
+
// Sub-agent JSONLs have their own sessionId (the file's stem
|
|
769
|
+
// — sub-agent files are typically named agent-<id>.jsonl).
|
|
770
|
+
// Hook fires inside the sub-agent process with that
|
|
771
|
+
// session_id, so we look up the sidecar by it.
|
|
772
|
+
const subSid = sessionIdForFile(t.file)
|
|
773
|
+
onEvent(decorate(ev, subSid))
|
|
725
774
|
} catch (err) {
|
|
726
775
|
log?.(`session-tail: sub onEvent threw: ${(err as Error).message}`)
|
|
727
776
|
}
|
|
@@ -872,6 +921,10 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
872
921
|
}
|
|
873
922
|
}
|
|
874
923
|
subTails.clear()
|
|
924
|
+
for (const s of sidecars.values()) {
|
|
925
|
+
try { s.stop() } catch { /* ignore */ }
|
|
926
|
+
}
|
|
927
|
+
sidecars.clear()
|
|
875
928
|
if (pollTimer) {
|
|
876
929
|
clearInterval(pollTimer)
|
|
877
930
|
pollTimer = null
|