switchroom 0.12.17 → 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.
@@ -47243,8 +47243,8 @@ var {
47243
47243
  } = import__.default;
47244
47244
 
47245
47245
  // src/build-info.ts
47246
- var VERSION = "0.12.17";
47247
- var COMMIT_SHA = "0177d926";
47246
+ var VERSION = "0.12.18";
47247
+ var COMMIT_SHA = "8b848c7b";
47248
47248
 
47249
47249
  // src/cli/agent.ts
47250
47250
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.17",
3
+ "version": "0.12.18",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42472,6 +42472,27 @@ function decideProactiveCompact(state3, occupancy, cap) {
42472
42472
  };
42473
42473
  }
42474
42474
 
42475
+ // gateway/compact-notify.ts
42476
+ function idleCompactNotifyState() {
42477
+ return { phase: "idle", fileAtStart: null };
42478
+ }
42479
+ function nextCompactNotify(state3, ev) {
42480
+ if (ev.fired) {
42481
+ const superseding = state3.phase === "awaiting";
42482
+ return {
42483
+ state: { phase: "awaiting", fileAtStart: ev.activeFile },
42484
+ action: superseding ? "start-superseding" : "start"
42485
+ };
42486
+ }
42487
+ if (state3.phase === "awaiting") {
42488
+ if (ev.rearmed && ev.activeFile != null && ev.activeFile === state3.fileAtStart) {
42489
+ return { state: idleCompactNotifyState(), action: "finish" };
42490
+ }
42491
+ return { state: state3, action: "none" };
42492
+ }
42493
+ return { state: state3, action: "none" };
42494
+ }
42495
+
42475
42496
  // gateway/hostd-dispatch.ts
42476
42497
  import { existsSync as existsSync20 } from "node:fs";
42477
42498
  import { randomBytes as randomBytes3 } from "node:crypto";
@@ -46903,11 +46924,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
46903
46924
  }
46904
46925
 
46905
46926
  // ../src/build-info.ts
46906
- var VERSION = "0.12.17";
46907
- var COMMIT_SHA = "0177d926";
46908
- var COMMIT_DATE = "2026-05-19T20:39:14+10:00";
46927
+ var VERSION = "0.12.18";
46928
+ var COMMIT_SHA = "8b848c7b";
46929
+ var COMMIT_DATE = "2026-05-19T22:26:42+10:00";
46909
46930
  var LATEST_PR = null;
46910
- var COMMITS_AHEAD_OF_TAG = 27;
46931
+ var COMMITS_AHEAD_OF_TAG = 2;
46911
46932
 
46912
46933
  // gateway/boot-version.ts
46913
46934
  function formatRelativeAgo(iso) {
@@ -47815,6 +47836,9 @@ var pendingRestarts = new Map;
47815
47836
  var lastSessionActiveFile = null;
47816
47837
  var compactState = initialCompactState();
47817
47838
  var compactDispatching = false;
47839
+ var COMPACT_CARD_TIMEOUT_MS = 900000;
47840
+ var compactNotifyState = idleCompactNotifyState();
47841
+ var outstandingCompactCard = null;
47818
47842
  var activeDraftStreams = new Map;
47819
47843
  var activeDraftParseModes = new Map;
47820
47844
  var suppressPtyPreview = new Set;
@@ -47898,8 +47922,24 @@ function maybeProactiveCompact() {
47898
47922
  return;
47899
47923
  const t = turns[0];
47900
47924
  const occupancy = t.input + t.cacheRead + t.cacheCreate;
47925
+ const wasArmed = compactState.armed;
47901
47926
  const decision = decideProactiveCompact(compactState, occupancy, cap);
47902
47927
  compactState = decision.state;
47928
+ const rearmed = !wasArmed && decision.state.armed;
47929
+ const nt = nextCompactNotify(compactNotifyState, {
47930
+ fired: decision.fire,
47931
+ rearmed,
47932
+ activeFile: file
47933
+ });
47934
+ compactNotifyState = nt.state;
47935
+ if (nt.action === "finish") {
47936
+ resolveCompactCard("finished", occupancy);
47937
+ } else if (nt.action === "start" || nt.action === "start-superseding") {
47938
+ if (nt.action === "start-superseding") {
47939
+ resolveCompactCard("superseded", occupancy);
47940
+ }
47941
+ postCompactCard(occupancy, cap);
47942
+ }
47903
47943
  if (!decision.fire)
47904
47944
  return;
47905
47945
  compactDispatching = true;
@@ -47912,6 +47952,66 @@ function maybeProactiveCompact() {
47912
47952
  compactDispatching = false;
47913
47953
  });
47914
47954
  }
47955
+ async function postCompactCard(occ, cap) {
47956
+ try {
47957
+ const chatId = loadAccess().allowFrom[0];
47958
+ if (!chatId)
47959
+ return;
47960
+ const threadId = chatThreadMap.get(chatId);
47961
+ const text = `\uD83D\uDDDC\uFE0F <b>Context compaction</b>
47962
+ ` + `Working context hit ~${occ.toLocaleString()} tokens (cap ${cap.toLocaleString()}) \u2014 running <code>/compact</code>. ` + `Older detail moves to Hindsight; I'll confirm here once the context has shrunk (may take a turn or two).`;
47963
+ const sent = await swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
47964
+ parse_mode: "HTML",
47965
+ ...threadId != null ? { message_thread_id: threadId } : {}
47966
+ }), { chat_id: chatId, verb: "proactiveCompact.start" });
47967
+ const messageId = sent?.message_id;
47968
+ if (typeof messageId !== "number")
47969
+ return;
47970
+ const timer2 = setTimeout(() => {
47971
+ resolveCompactCard("timeout", null);
47972
+ }, COMPACT_CARD_TIMEOUT_MS);
47973
+ timer2.unref?.();
47974
+ outstandingCompactCard = {
47975
+ chatId,
47976
+ threadId,
47977
+ messageId,
47978
+ occAtStart: occ,
47979
+ capAtStart: cap,
47980
+ timer: timer2
47981
+ };
47982
+ } catch (err) {
47983
+ process.stderr.write(`telegram gateway: proactive-compact start card failed: ${err instanceof Error ? err.message : String(err)}
47984
+ `);
47985
+ }
47986
+ }
47987
+ async function resolveCompactCard(kind, occNow) {
47988
+ const card = outstandingCompactCard;
47989
+ if (!card)
47990
+ return;
47991
+ outstandingCompactCard = null;
47992
+ clearTimeout(card.timer);
47993
+ if (kind === "timeout")
47994
+ compactNotifyState = idleCompactNotifyState();
47995
+ let text;
47996
+ if (kind === "finished") {
47997
+ text = `\u2705 <b>Context compacted</b>
47998
+ ` + `Working context reduced` + (occNow != null ? ` (~${card.occAtStart.toLocaleString()} \u2192 ` + `~${occNow.toLocaleString()} tokens)` : "") + `. Hindsight retains the detail.`;
47999
+ } else if (kind === "superseded") {
48000
+ text = `\u21A9\uFE0F <b>Context compaction superseded</b>
48001
+ ` + `A newer compaction started before this one confirmed.`;
48002
+ } else {
48003
+ text = `\u26A0\uFE0F <b>Compaction issued</b>
48004
+ ` + `<code>/compact</code> was requested but the context isn't confirmed reduced yet. Native compaction and Hindsight still apply.`;
48005
+ }
48006
+ try {
48007
+ await swallowingApiCall(() => bot.api.editMessageText(card.chatId, card.messageId, text, {
48008
+ parse_mode: "HTML"
48009
+ }), { chat_id: card.chatId, verb: `proactiveCompact.${kind}` });
48010
+ } catch (err) {
48011
+ process.stderr.write(`telegram gateway: proactive-compact ${kind} card edit failed: ${err instanceof Error ? err.message : String(err)}
48012
+ `);
48013
+ }
48014
+ }
47915
48015
  function endStatusReaction(chatId, threadId, outcome) {
47916
48016
  const key = statusKey(chatId, threadId);
47917
48017
  const ctrl = activeStatusReactions.get(key);
@@ -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
+ }
@@ -230,6 +230,7 @@ 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
231
  import { readTurnUsages } from '../../src/agents/perf.js'
232
232
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
233
+ import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
233
234
  import {
234
235
  tryHostdDispatch,
235
236
  hostdRequestId,
@@ -1086,6 +1087,25 @@ let lastSessionActiveFile: string | null = null
1086
1087
  // turn and we must not double-dispatch before the first send settles.
1087
1088
  let compactState: CompactState = initialCompactState()
1088
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
1089
1109
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
1090
1110
  const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>()
1091
1111
  const suppressPtyPreview = new Set<string>()
@@ -1321,8 +1341,35 @@ function maybeProactiveCompact(): void {
1321
1341
  const t = turns[0];
1322
1342
  const occupancy = t.input + t.cacheRead + t.cacheCreate;
1323
1343
 
1344
+ const wasArmed = compactState.armed;
1324
1345
  const decision = decideProactiveCompact(compactState, occupancy, cap);
1325
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
+
1326
1373
  if (!decision.fire) return;
1327
1374
 
1328
1375
  // Set the re-entrancy guard synchronously BEFORE the await so a
@@ -1345,6 +1392,109 @@ function maybeProactiveCompact(): void {
1345
1392
  });
1346
1393
  }
1347
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
+
1348
1498
  function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
1349
1499
  const key = statusKey(chatId, threadId)
1350
1500
  const ctrl = activeStatusReactions.get(key)
@@ -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
+ })