switchroom 0.12.16 → 0.12.18

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure transition selector for the proactive-compaction user
3
+ * notification (a single Telegram card edited START → FINISH). Kept
4
+ * side-effect-free so the lifecycle — the part prone to double-post /
5
+ * stale-edit / spurious-finish — is unit-testable in isolation. The
6
+ * impure shell (send/editMessageText, the wall-clock timeout) lives in
7
+ * gateway.ts.
8
+ *
9
+ * The "finished" signal is the proactive-compaction state machine's
10
+ * re-arm edge (`armed: false → true`, which the decider only produces
11
+ * when post-`/compact` occupancy drops below 0.6×cap — i.e. context
12
+ * verifiably shrank). This module does NOT own the wall-clock timeout
13
+ * (an idle agent produces no evaluations, so a dangling card must be
14
+ * resolved by a real timer in the shell, independent of this loop).
15
+ *
16
+ * Session reset/rotation (`session.max_idle` / `max_turns`) is
17
+ * deliberately silent and is not represented here.
18
+ */
19
+
20
+ export interface CompactNotifyState {
21
+ phase: "idle" | "awaiting";
22
+ /**
23
+ * The active session file when START was posted. FINISH is only
24
+ * accepted if the re-arm edge is observed against this SAME file —
25
+ * a sub-agent transcript briefly leading session-tail's currentFile
26
+ * can read a small occupancy and spuriously satisfy the re-arm
27
+ * condition; gating on the start-file rejects that false positive.
28
+ */
29
+ fileAtStart: string | null;
30
+ }
31
+
32
+ /**
33
+ * - `none` — no card action this evaluation.
34
+ * - `start` — post a fresh START card.
35
+ * - `start-superseding` — a prior card is still outstanding; mark it
36
+ * superseded, then post a fresh START card.
37
+ * - `finish` — edit the outstanding card to FINISHED.
38
+ */
39
+ export type CompactNotifyAction =
40
+ | "none"
41
+ | "start"
42
+ | "start-superseding"
43
+ | "finish";
44
+
45
+ export interface CompactNotifyEvent {
46
+ /** The proactive-compaction decider fired `/compact` this eval. */
47
+ fired: boolean;
48
+ /** Decider re-arm edge this eval (wasArmed=false → state.armed=true). */
49
+ rearmed: boolean;
50
+ /** session-tail's active file used for the occupancy read this eval. */
51
+ activeFile: string | null;
52
+ }
53
+
54
+ export function idleCompactNotifyState(): CompactNotifyState {
55
+ return { phase: "idle", fileAtStart: null };
56
+ }
57
+
58
+ /**
59
+ * Select the card action for this idle evaluation and the next state.
60
+ * Pure: same inputs → same outputs, no I/O.
61
+ *
62
+ * Precedence:
63
+ * 1. A fire always (re)starts a card. If one was still outstanding,
64
+ * supersede it first (single-outstanding-card invariant).
65
+ * 2. While awaiting, a re-arm edge ON THE SAME start-file → FINISH.
66
+ * 3. Otherwise hold (the shell's wall-clock timeout resolves a card
67
+ * that never gets a re-arm — e.g. a compaction that didn't shrink
68
+ * context, or an agent that went idle right after START).
69
+ */
70
+ export function nextCompactNotify(
71
+ state: CompactNotifyState,
72
+ ev: CompactNotifyEvent,
73
+ ): { state: CompactNotifyState; action: CompactNotifyAction } {
74
+ if (ev.fired) {
75
+ const superseding = state.phase === "awaiting";
76
+ return {
77
+ state: { phase: "awaiting", fileAtStart: ev.activeFile },
78
+ action: superseding ? "start-superseding" : "start",
79
+ };
80
+ }
81
+
82
+ if (state.phase === "awaiting") {
83
+ if (
84
+ ev.rearmed &&
85
+ ev.activeFile != null &&
86
+ ev.activeFile === state.fileAtStart
87
+ ) {
88
+ return { state: idleCompactNotifyState(), action: "finish" };
89
+ }
90
+ return { state, action: "none" };
91
+ }
92
+
93
+ return { state, action: "none" };
94
+ }
@@ -228,6 +228,9 @@ import { handleInjectCommand } from './inject-handler.js'
228
228
  import { type BannerState } from '../slot-banner.js'
229
229
  import { refreshBanner } from '../slot-banner-driver.js'
230
230
  import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
231
+ import { readTurnUsages } from '../../src/agents/perf.js'
232
+ import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
233
+ import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
231
234
  import {
232
235
  tryHostdDispatch,
233
236
  hostdRequestId,
@@ -1062,6 +1065,47 @@ const chatAvailableReactions = new Map<string, Set<string> | null>()
1062
1065
  const chatProbesInFlight = new Set<string>()
1063
1066
  const activeTurnStartedAt = new Map<string, number>()
1064
1067
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
1068
+
1069
+ // ─── Proactive context compaction (session.max_context_tokens) ──────────
1070
+ //
1071
+ // Opt-in: when the resolved agent config sets session.max_context_tokens,
1072
+ // we fire `/compact` once the live context-window occupancy of the latest
1073
+ // assistant turn reaches that many tokens. Evaluated ONLY at the
1074
+ // model-idle gate inside purgeReactionTracking (activeTurnStartedAt.size
1075
+ // === 0) — never mid-turn — mirroring the pendingRestarts drain. The
1076
+ // `/compact` verb is allowlisted in src/agents/inject.ts and runs via the
1077
+ // tmux send-keys path (the only path that actually executes the slash
1078
+ // command; inject_inbound would deliver it as literal text).
1079
+ //
1080
+ // `lastSessionActiveFile` is the session-tail's tracked currentFile,
1081
+ // forwarded by the bridge on every session_event — we read occupancy from
1082
+ // exactly that file (never an independent findActiveSessionFile re-scan).
1083
+ let lastSessionActiveFile: string | null = null
1084
+ // Anti-spam state machine lives in ./proactive-compact (pure, unit
1085
+ // tested). `compactDispatching` is a synchronous re-entrancy guard for
1086
+ // the async tmux send — purgeReactionTracking can run several times per
1087
+ // turn and we must not double-dispatch before the first send settles.
1088
+ let compactState: CompactState = initialCompactState()
1089
+ let compactDispatching = false
1090
+
1091
+ // User-facing proactive-compaction notification (single Telegram card
1092
+ // edited START → FINISH; transition selection is pure in
1093
+ // ./compact-notify). Session reset/rotation stays silent — not here.
1094
+ // The wall-clock timer is owned HERE (not the pure helper): an idle
1095
+ // agent produces no idle evaluations, so a card that never gets a
1096
+ // re-arm edge must be resolved by a real timer, independent of the
1097
+ // eval loop, or it would dangle forever.
1098
+ const COMPACT_CARD_TIMEOUT_MS = 15 * 60 * 1000
1099
+ interface OutstandingCompactCard {
1100
+ chatId: string
1101
+ threadId: number | undefined
1102
+ messageId: number
1103
+ occAtStart: number
1104
+ capAtStart: number
1105
+ timer: ReturnType<typeof setTimeout>
1106
+ }
1107
+ let compactNotifyState: CompactNotifyState = idleCompactNotifyState()
1108
+ let outstandingCompactCard: OutstandingCompactCard | null = null
1065
1109
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
1066
1110
  const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
1067
1111
  const suppressPtyPreview = new Set<string>()
@@ -1233,14 +1277,224 @@ function purgeReactionTracking(key: string): void {
1233
1277
  // survives us getting killed by our own restart. Fire-and-forget;
1234
1278
  // response to the client was already sent when the restart was
1235
1279
  // scheduled, so nobody is waiting on this.
1236
- if (activeTurnStartedAt.size === 0 && pendingRestarts.size > 0) {
1237
- for (const [agentName, _timestamp] of pendingRestarts.entries()) {
1238
- triggerSelfRestart(agentName, 'turn-complete-pending-restart');
1239
- pendingRestarts.delete(agentName);
1280
+ if (activeTurnStartedAt.size === 0) {
1281
+ if (pendingRestarts.size > 0) {
1282
+ for (const [agentName, _timestamp] of pendingRestarts.entries()) {
1283
+ triggerSelfRestart(agentName, 'turn-complete-pending-restart');
1284
+ pendingRestarts.delete(agentName);
1285
+ }
1286
+ } else {
1287
+ // Strictly lower priority than a pending restart: if we just
1288
+ // kicked a restart the process is going away and compacting is
1289
+ // moot, so only evaluate when no restart drained this pass.
1290
+ maybeProactiveCompact();
1240
1291
  }
1241
1292
  }
1242
1293
  }
1243
1294
 
1295
+ /**
1296
+ * Model-idle proactive-compaction check. Called ONLY from the
1297
+ * activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
1298
+ * the resolved agent config's session.max_context_tokens; a no-op when
1299
+ * unset, so a fresh `switchroom setup` is unchanged.
1300
+ *
1301
+ * Occupancy = the latest usage-bearing assistant turn's
1302
+ * input + cache_read + cache_creation tokens (the prefix the model
1303
+ * actually re-read this turn ≈ current window fill). readTurnUsages(_,1)
1304
+ * returns exactly that single turn and skips tool-only / usage-less
1305
+ * lines, so we never under-count off a sub-line.
1306
+ *
1307
+ * Note (accepted, benign): there is a check-to-send race — a new inbound
1308
+ * could set activeTurnStartedAt between this idle check and the async
1309
+ * tmux send. A `/compact` that lands as a new turn starts is queued in
1310
+ * claude's prompt buffer and runs at the next idle prompt (see the
1311
+ * FUTURE-GAP note in src/agents/inject.ts); it is not a mid-generation
1312
+ * injection. We do not claim size===0 is atomic.
1313
+ */
1314
+ function maybeProactiveCompact(): void {
1315
+ if (compactDispatching) return;
1316
+
1317
+ const agentName = process.env.SWITCHROOM_AGENT_NAME;
1318
+ if (!agentName) return;
1319
+
1320
+ let cap: number | undefined;
1321
+ try {
1322
+ const cfg = loadSwitchroomConfig();
1323
+ // Resolve through the cascade so a fleet-wide
1324
+ // `defaults.session.max_context_tokens` applies even when the agent
1325
+ // has no explicit per-agent session block (rawAgent → {}).
1326
+ const rawAgent = cfg.agents?.[agentName] ?? {};
1327
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent);
1328
+ cap = resolved.session?.max_context_tokens;
1329
+ } catch {
1330
+ // Best-effort — config may be unreadable in odd boot states; a
1331
+ // failed read just means "no proactive compaction this pass".
1332
+ return;
1333
+ }
1334
+ if (cap == null || cap <= 0) return; // opt-in: unset → native compaction only
1335
+
1336
+ const file = lastSessionActiveFile;
1337
+ if (!file) return;
1338
+
1339
+ const turns = readTurnUsages(file, 1);
1340
+ if (turns.length === 0) return;
1341
+ const t = turns[0];
1342
+ const occupancy = t.input + t.cacheRead + t.cacheCreate;
1343
+
1344
+ const wasArmed = compactState.armed;
1345
+ const decision = decideProactiveCompact(compactState, occupancy, cap);
1346
+ compactState = decision.state;
1347
+ // Re-arm edge: the decider only flips disarmed→armed when post-
1348
+ // /compact occupancy fell below 0.6×cap — i.e. context verifiably
1349
+ // shrank. That edge is the honest "compaction finished" signal.
1350
+ const rearmed = !wasArmed && decision.state.armed;
1351
+
1352
+ // User-facing notification transitions (pure). Resolved synchronously
1353
+ // here — before any await and before the !fire return — so a
1354
+ // re-entrant purge pass can't double-post (compactState was already
1355
+ // persisted/disarmed above). All card I/O is reached only past the
1356
+ // `cap == null` opt-in return earlier in this function, so a fleet
1357
+ // with the feature off never posts anything (Defaults preserved).
1358
+ const nt = nextCompactNotify(compactNotifyState, {
1359
+ fired: decision.fire,
1360
+ rearmed,
1361
+ activeFile: file,
1362
+ });
1363
+ compactNotifyState = nt.state;
1364
+ if (nt.action === 'finish') {
1365
+ void resolveCompactCard('finished', occupancy);
1366
+ } else if (nt.action === 'start' || nt.action === 'start-superseding') {
1367
+ if (nt.action === 'start-superseding') {
1368
+ void resolveCompactCard('superseded', occupancy);
1369
+ }
1370
+ void postCompactCard(occupancy, cap);
1371
+ }
1372
+
1373
+ if (!decision.fire) return;
1374
+
1375
+ // Set the re-entrancy guard synchronously BEFORE the await so a
1376
+ // re-entrant purge pass can't double-dispatch (the decider already
1377
+ // disarmed + armed the cooldown in decision.state).
1378
+ compactDispatching = true;
1379
+ process.stderr.write(
1380
+ `telegram gateway: proactive /compact for ${agentName} ` +
1381
+ `(occupancy=${occupancy} >= cap=${cap})\n`,
1382
+ );
1383
+ void injectSlashCommandImpl(agentName, '/compact')
1384
+ .catch((err: unknown) => {
1385
+ process.stderr.write(
1386
+ `telegram gateway: proactive /compact inject failed for ` +
1387
+ `${agentName}: ${err instanceof Error ? err.message : String(err)}\n`,
1388
+ );
1389
+ })
1390
+ .finally(() => {
1391
+ compactDispatching = false;
1392
+ });
1393
+ }
1394
+
1395
+ /**
1396
+ * Post the START card for a proactive compaction. Best-effort: a failed
1397
+ * send just means no card (the compaction itself still happens). The
1398
+ * outstanding record + a wall-clock timeout are armed so the card can
1399
+ * never dangle if the re-arm edge never arrives (failed/idle case).
1400
+ */
1401
+ async function postCompactCard(occ: number, cap: number): Promise<void> {
1402
+ try {
1403
+ const chatId = loadAccess().allowFrom[0];
1404
+ if (!chatId) return;
1405
+ const threadId = chatThreadMap.get(chatId);
1406
+ const text =
1407
+ `🗜️ <b>Context compaction</b>\n` +
1408
+ `Working context hit ~${occ.toLocaleString()} tokens ` +
1409
+ `(cap ${cap.toLocaleString()}) — running <code>/compact</code>. ` +
1410
+ `Older detail moves to Hindsight; I'll confirm here once the ` +
1411
+ `context has shrunk (may take a turn or two).`;
1412
+ const sent = await swallowingApiCall(
1413
+ () =>
1414
+ bot.api.sendMessage(chatId, text, {
1415
+ parse_mode: 'HTML',
1416
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
1417
+ }),
1418
+ { chat_id: chatId, verb: 'proactiveCompact.start' },
1419
+ );
1420
+ const messageId = (sent as { message_id?: number } | undefined)
1421
+ ?.message_id;
1422
+ if (typeof messageId !== 'number') return;
1423
+ const timer = setTimeout(() => {
1424
+ void resolveCompactCard('timeout', null);
1425
+ }, COMPACT_CARD_TIMEOUT_MS);
1426
+ timer.unref?.();
1427
+ outstandingCompactCard = {
1428
+ chatId,
1429
+ threadId,
1430
+ messageId,
1431
+ occAtStart: occ,
1432
+ capAtStart: cap,
1433
+ timer,
1434
+ };
1435
+ } catch (err) {
1436
+ process.stderr.write(
1437
+ `telegram gateway: proactive-compact start card failed: ` +
1438
+ `${err instanceof Error ? err.message : String(err)}\n`,
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * Resolve the outstanding START card to a terminal state by editing it
1445
+ * in place. `finished` is driven by the decider re-arm edge (context
1446
+ * verifiably shrank), `superseded` when a newer compaction starts first,
1447
+ * `timeout` by the wall-clock timer (re-arm never arrived). The
1448
+ * outstanding record + timer are cleared synchronously BEFORE the await
1449
+ * so a stale message_id can never be edited twice and the timer can't
1450
+ * double-fire. On `timeout` the pure notify state is also reset so a
1451
+ * future compaction starts a clean lifecycle.
1452
+ */
1453
+ async function resolveCompactCard(
1454
+ kind: 'finished' | 'superseded' | 'timeout',
1455
+ occNow: number | null,
1456
+ ): Promise<void> {
1457
+ const card = outstandingCompactCard;
1458
+ if (!card) return;
1459
+ outstandingCompactCard = null;
1460
+ clearTimeout(card.timer);
1461
+ if (kind === 'timeout') compactNotifyState = idleCompactNotifyState();
1462
+ let text: string;
1463
+ if (kind === 'finished') {
1464
+ text =
1465
+ `✅ <b>Context compacted</b>\n` +
1466
+ `Working context reduced` +
1467
+ (occNow != null
1468
+ ? ` (~${card.occAtStart.toLocaleString()} → ` +
1469
+ `~${occNow.toLocaleString()} tokens)`
1470
+ : '') +
1471
+ `. Hindsight retains the detail.`;
1472
+ } else if (kind === 'superseded') {
1473
+ text =
1474
+ `↩️ <b>Context compaction superseded</b>\n` +
1475
+ `A newer compaction started before this one confirmed.`;
1476
+ } else {
1477
+ text =
1478
+ `⚠️ <b>Compaction issued</b>\n` +
1479
+ `<code>/compact</code> was requested but the context isn't ` +
1480
+ `confirmed reduced yet. Native compaction and Hindsight still apply.`;
1481
+ }
1482
+ try {
1483
+ await swallowingApiCall(
1484
+ () =>
1485
+ bot.api.editMessageText(card.chatId, card.messageId, text, {
1486
+ parse_mode: 'HTML',
1487
+ }),
1488
+ { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
1489
+ );
1490
+ } catch (err) {
1491
+ process.stderr.write(
1492
+ `telegram gateway: proactive-compact ${kind} card edit failed: ` +
1493
+ `${err instanceof Error ? err.message : String(err)}\n`,
1494
+ );
1495
+ }
1496
+ }
1497
+
1244
1498
  function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
1245
1499
  const key = statusKey(chatId, threadId)
1246
1500
  const ctrl = activeStatusReactions.get(key)
@@ -2997,6 +3251,9 @@ const ipcServer: IpcServer = createIpcServer({
2997
3251
  },
2998
3252
 
2999
3253
  onSessionEvent(_client: IpcClient, msg: SessionEventForward) {
3254
+ // Track the session-tail's attached file for the proactive-
3255
+ // compaction occupancy read (see maybeProactiveCompact).
3256
+ if (msg.activeFile) lastSessionActiveFile = msg.activeFile
3000
3257
  const ev = msg.event as unknown as SessionEvent
3001
3258
  // Pass the envelope's chatId so non-enqueue events can route to the
3002
3259
  // correct card even when the driver's currentChatId is stale.
@@ -121,6 +121,15 @@ export interface SessionEventForward {
121
121
  event: Record<string, unknown>;
122
122
  chatId: string;
123
123
  threadId?: number;
124
+ /**
125
+ * The session-tail's currently-attached JSONL path (its tracked
126
+ * `currentFile`, not an independent re-scan). Forwarded so the
127
+ * gateway's proactive-compaction check reads occupancy from the
128
+ * exact file the tailer is on — avoids the sub-agent-mtime /
129
+ * stale-rotation wrong-file hazard. Absent until the tailer has
130
+ * attached a file.
131
+ */
132
+ activeFile?: string;
124
133
  }
125
134
 
126
135
  export interface PermissionRequestForward {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure decision core for proactive context compaction
3
+ * (`session.max_context_tokens`). Kept side-effect-free so the
4
+ * anti-spam state machine — the part most prone to livelock /
5
+ * double-fire — is unit-testable in isolation. The impure shell
6
+ * (config load, session-file read, tmux `/compact` inject) lives in
7
+ * gateway.ts and calls `decideProactiveCompact` at the model-idle gate.
8
+ *
9
+ * Occupancy fed in by the caller = the latest usage-bearing assistant
10
+ * turn's `input + cache_read + cache_creation` tokens (the prefix the
11
+ * model re-read this turn ≈ live context-window fill). Not cumulative.
12
+ */
13
+
14
+ /** Hysteresis lower band: re-arm only once occupancy < fraction × cap. */
15
+ export const COMPACT_REARM_FRACTION = 0.6;
16
+
17
+ /**
18
+ * Turn-count re-fire floor: after a fire, skip this many idle
19
+ * evaluations regardless of occupancy. Guards against a slow
20
+ * post-`/compact` JSONL rotation still reading the pre-compact turn and
21
+ * triggering an immediate second compaction.
22
+ */
23
+ export const COMPACT_COOLDOWN_TURNS = 3;
24
+
25
+ export interface CompactState {
26
+ /** False after a fire; re-armed only below the hysteresis band. */
27
+ armed: boolean;
28
+ /** Idle evaluations remaining before re-fire is even considered. */
29
+ cooldownTurns: number;
30
+ }
31
+
32
+ export interface CompactDecision {
33
+ fire: boolean;
34
+ /** Next state — caller must persist this verbatim. */
35
+ state: CompactState;
36
+ }
37
+
38
+ export function initialCompactState(): CompactState {
39
+ return { armed: true, cooldownTurns: 0 };
40
+ }
41
+
42
+ /**
43
+ * Decide whether to fire `/compact` this idle evaluation, given the
44
+ * current state, the measured occupancy, and the configured cap.
45
+ *
46
+ * Precedence (each returns early):
47
+ * 1. Cooldown floor — burn one turn, never fire.
48
+ * 2. Disarmed — re-arm iff occupancy < REARM_FRACTION × cap; never
49
+ * fire on the arming pass (so we can't arm and fire together).
50
+ * 3. Below cap — hold.
51
+ * 4. Armed and at/above cap — fire, disarm, start the cooldown.
52
+ *
53
+ * Livelock safety: once disarmed, step 2 is the ONLY path back to
54
+ * armed, and it requires occupancy to actually drop below the lower
55
+ * band. If a compaction fails to shrink context, the cap stays
56
+ * exceeded, occupancy never drops below 0.6×cap, and we stay disarmed
57
+ * — i.e. we degrade to "don't fire" rather than firing every turn.
58
+ */
59
+ export function decideProactiveCompact(
60
+ state: CompactState,
61
+ occupancy: number,
62
+ cap: number,
63
+ ): CompactDecision {
64
+ if (state.cooldownTurns > 0) {
65
+ return {
66
+ fire: false,
67
+ state: { armed: state.armed, cooldownTurns: state.cooldownTurns - 1 },
68
+ };
69
+ }
70
+
71
+ if (!state.armed) {
72
+ const armed = occupancy < cap * COMPACT_REARM_FRACTION;
73
+ return { fire: false, state: { armed, cooldownTurns: 0 } };
74
+ }
75
+
76
+ if (occupancy < cap) {
77
+ return { fire: false, state };
78
+ }
79
+
80
+ return {
81
+ fire: true,
82
+ state: { armed: false, cooldownTurns: COMPACT_COOLDOWN_TURNS },
83
+ };
84
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ nextCompactNotify,
4
+ idleCompactNotifyState,
5
+ type CompactNotifyState,
6
+ } from '../gateway/compact-notify.js'
7
+
8
+ const FILE_A = '/state/.../sess-a.jsonl'
9
+ const FILE_B = '/state/.../sess-b.jsonl'
10
+
11
+ describe('nextCompactNotify', () => {
12
+ it('idle + fired → start, awaiting with fileAtStart', () => {
13
+ const r = nextCompactNotify(idleCompactNotifyState(), {
14
+ fired: true,
15
+ rearmed: false,
16
+ activeFile: FILE_A,
17
+ })
18
+ expect(r.action).toBe('start')
19
+ expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_A })
20
+ })
21
+
22
+ it('idle + nothing → none', () => {
23
+ const r = nextCompactNotify(idleCompactNotifyState(), {
24
+ fired: false,
25
+ rearmed: false,
26
+ activeFile: FILE_A,
27
+ })
28
+ expect(r.action).toBe('none')
29
+ expect(r.state.phase).toBe('idle')
30
+ })
31
+
32
+ it('idle + spurious rearm (no card outstanding) → none', () => {
33
+ const r = nextCompactNotify(idleCompactNotifyState(), {
34
+ fired: false,
35
+ rearmed: true,
36
+ activeFile: FILE_A,
37
+ })
38
+ expect(r.action).toBe('none')
39
+ expect(r.state.phase).toBe('idle')
40
+ })
41
+
42
+ it('awaiting + rearm on SAME file → finish, back to idle', () => {
43
+ const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
44
+ const r = nextCompactNotify(awaiting, {
45
+ fired: false,
46
+ rearmed: true,
47
+ activeFile: FILE_A,
48
+ })
49
+ expect(r.action).toBe('finish')
50
+ expect(r.state).toEqual(idleCompactNotifyState())
51
+ })
52
+
53
+ it('awaiting + rearm on DIFFERENT file → none (sub-agent false-positive guard)', () => {
54
+ const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
55
+ const r = nextCompactNotify(awaiting, {
56
+ fired: false,
57
+ rearmed: true,
58
+ activeFile: FILE_B,
59
+ })
60
+ expect(r.action).toBe('none')
61
+ expect(r.state).toBe(awaiting) // unchanged, still awaiting
62
+ })
63
+
64
+ it('awaiting + rearm but activeFile null → none', () => {
65
+ const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
66
+ const r = nextCompactNotify(awaiting, {
67
+ fired: false,
68
+ rearmed: true,
69
+ activeFile: null,
70
+ })
71
+ expect(r.action).toBe('none')
72
+ expect(r.state.phase).toBe('awaiting')
73
+ })
74
+
75
+ it('awaiting + no rearm → none, stays awaiting (shell timeout owns dangling)', () => {
76
+ const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
77
+ const r = nextCompactNotify(awaiting, {
78
+ fired: false,
79
+ rearmed: false,
80
+ activeFile: FILE_A,
81
+ })
82
+ expect(r.action).toBe('none')
83
+ expect(r.state.phase).toBe('awaiting')
84
+ })
85
+
86
+ it('awaiting + fired again → start-superseding, awaiting with NEW fileAtStart', () => {
87
+ const awaiting: CompactNotifyState = { phase: 'awaiting', fileAtStart: FILE_A }
88
+ const r = nextCompactNotify(awaiting, {
89
+ fired: true,
90
+ rearmed: false,
91
+ activeFile: FILE_B,
92
+ })
93
+ expect(r.action).toBe('start-superseding')
94
+ expect(r.state).toEqual({ phase: 'awaiting', fileAtStart: FILE_B })
95
+ })
96
+
97
+ it('full healthy cycle: fire → hold → rearm(same file) → idle', () => {
98
+ let s = idleCompactNotifyState()
99
+ const fires: string[] = []
100
+ const seq = [
101
+ { fired: true, rearmed: false, activeFile: FILE_A }, // START
102
+ { fired: false, rearmed: false, activeFile: FILE_A }, // cooldown/hold
103
+ { fired: false, rearmed: false, activeFile: FILE_A }, // hold
104
+ { fired: false, rearmed: true, activeFile: FILE_A }, // FINISH
105
+ { fired: false, rearmed: false, activeFile: FILE_A }, // idle, nothing
106
+ ]
107
+ for (const ev of seq) {
108
+ const r = nextCompactNotify(s, ev)
109
+ s = r.state
110
+ fires.push(r.action)
111
+ }
112
+ expect(fires).toEqual(['start', 'none', 'none', 'finish', 'none'])
113
+ expect(s).toEqual(idleCompactNotifyState())
114
+ })
115
+
116
+ it('rearm that arrives only after a file flip never finishes the wrong card', () => {
117
+ // START on FILE_A; a sub-agent (FILE_B) leads and "rearms" → ignored;
118
+ // later the real drop is observed back on FILE_A → finish.
119
+ let s = nextCompactNotify(idleCompactNotifyState(), {
120
+ fired: true,
121
+ rearmed: false,
122
+ activeFile: FILE_A,
123
+ }).state
124
+ const mid = nextCompactNotify(s, {
125
+ fired: false,
126
+ rearmed: true,
127
+ activeFile: FILE_B,
128
+ })
129
+ expect(mid.action).toBe('none')
130
+ s = mid.state
131
+ const fin = nextCompactNotify(s, {
132
+ fired: false,
133
+ rearmed: true,
134
+ activeFile: FILE_A,
135
+ })
136
+ expect(fin.action).toBe('finish')
137
+ })
138
+ })