@agfpd/iapeer 0.2.50 → 0.2.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ import { addRuntime, compactPeer, defaultRuntime, formatListTable, listPeers, ne
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
12
  import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
- import { ok } from '../core/errors.ts'
14
+ import { err, ok } from '../core/errors.ts'
15
15
 
16
16
  let root: string
17
17
  let laDir: string
@@ -463,6 +463,7 @@ describe('compact (resume sleeping dialogue)', () => {
463
463
  setIdleReaped(cfg, 'codex-sleepy')
464
464
  const wakeCalls: unknown[] = []
465
465
  const controlCalls: unknown[] = []
466
+ const doneCalls: unknown[] = []
466
467
  const o = await compactPeer('sleepy', 'codex', {
467
468
  env: e,
468
469
  wakeFn: async args => {
@@ -473,13 +474,34 @@ describe('compact (resume sleeping dialogue)', () => {
473
474
  controlCalls.push({ personality, runtime, command })
474
475
  return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
475
476
  },
477
+ compactDoneFn: (target, cwd, baseline) => {
478
+ doneCalls.push({ target, cwd, runtime: baseline.runtime })
479
+ return ok({ ok: true, ms: 42, signal: 'transcript+ready' })
480
+ },
476
481
  })
477
482
  expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
478
483
  expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
479
484
  expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
485
+ expect(doneCalls).toHaveLength(1)
486
+ expect(doneCalls[0]).toMatchObject({ cwd: '/tmp/sleepy', runtime: 'codex', target: { runtime: 'codex', personality: 'sleepy' } })
480
487
  expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
481
488
  })
482
489
 
490
+ test('compact done gate failure surfaces as failed (no false success)', async () => {
491
+ await register('slow', 'codex')
492
+ const e = env()
493
+ const cfg = loadLifecycleConfig(e)
494
+ setIdleReaped(cfg, 'codex-slow')
495
+ const o = await compactPeer('slow', 'codex', {
496
+ env: e,
497
+ wakeFn: async args => ({ status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }),
498
+ controlFn: (personality, runtime, command) => ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' }),
499
+ compactDoneFn: () => err('compact did not complete within test'),
500
+ })
501
+ expect(o.action).toBe('failed')
502
+ expect(o.reason).toContain('did not complete')
503
+ })
504
+
483
505
  test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
484
506
  await register('freshy', 'codex')
485
507
  let woke = false
package/src/cli/index.ts CHANGED
@@ -24,7 +24,7 @@ import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } f
24
24
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
25
25
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
26
26
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
27
- import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
27
+ import { compactDoneBaseline, isPeerLive, routeControl, routeSend, waitForCompactDone, type WakeFn } from '../transport/index.ts'
28
28
  import {
29
29
  attachPeer,
30
30
  clearIdleReaped,
@@ -515,9 +515,11 @@ export interface CompactPeerOutcome {
515
515
 
516
516
  /**
517
517
  * compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
518
- * session. A live target receives the in-session `/compact` control as before. A
519
- * cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
520
- * A crashed / never-run / non-resumable target returns an honest "nothing to compact"
518
+ * session. A live target receives the in-session `/compact` control; a cleanly
519
+ * idle-reaped target is first resumed into the same dialogue, then controlled.
520
+ * SUCCESS is gated on actual compaction completion (structured transcript marker
521
+ * after the command + input surface ready), not on keystrokes being accepted. A
522
+ * crashed / never-run / non-resumable target returns an honest "nothing to compact"
521
523
  * instead of starting a fresh empty session and compacting that.
522
524
  */
523
525
  export async function compactPeer(
@@ -526,6 +528,7 @@ export async function compactPeer(
526
528
  opts: CliEnvOptions & {
527
529
  wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
528
530
  controlFn?: typeof routeControl
531
+ compactDoneFn?: typeof waitForCompactDone
529
532
  } = {},
530
533
  ): Promise<CompactPeerOutcome> {
531
534
  const env = opts.env ?? process.env
@@ -551,10 +554,39 @@ export async function compactPeer(
551
554
  }
552
555
 
553
556
  const control = opts.controlFn ?? routeControl
557
+ const compactDone = opts.compactDoneFn ?? waitForCompactDone
558
+ const waitDone = (rt: Runtime, baseline: ReturnType<typeof compactDoneBaseline>, woke: boolean): CompactPeerOutcome | null => {
559
+ const done = compactDone(
560
+ {
561
+ personality,
562
+ runtime: rt,
563
+ address: buildProcessAddress(rt, personality),
564
+ socketPath: buildSocketPath(rt, personality, cfg.sockDir),
565
+ },
566
+ peer.cwd,
567
+ baseline,
568
+ { env },
569
+ )
570
+ if (!done.ok) return { personality, runtime: rt, action: 'failed', woke, reason: done.error.message }
571
+ return null
572
+ }
554
573
  const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
555
574
  if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
556
- const r = control(personality, runtime, { name: 'compact' })
557
- if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
575
+ const rt = (runtime as Runtime | undefined)
576
+ ?? (liveRuntimes.includes(peer.runtime) ? peer.runtime : liveRuntimes.length === 1 ? liveRuntimes[0] : undefined)
577
+ if (!rt) {
578
+ return {
579
+ personality,
580
+ runtime: peer.runtime,
581
+ action: 'failed',
582
+ reason: `${personality} is online in multiple runtimes (${liveRuntimes.join(', ')}) — specify runtime`,
583
+ }
584
+ }
585
+ const baseline = compactDoneBaseline(rt, peer.cwd, { env })
586
+ const r = control(personality, rt, { name: 'compact' })
587
+ if (!r.ok) return { personality, runtime: rt, action: 'failed', reason: r.error.message }
588
+ const waited = waitDone(r.value.controlled.runtime as Runtime, baseline, false)
589
+ if (waited) return waited
558
590
  return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
559
591
  }
560
592
 
@@ -589,8 +621,11 @@ export async function compactPeer(
589
621
  return { personality, runtime: rt, action: 'failed', reason }
590
622
  }
591
623
  clearIdleReaped(cfg, identity)
624
+ const baseline = compactDoneBaseline(rt, peer.cwd, { env })
592
625
  const r = control(personality, rt, { name: 'compact' })
593
626
  if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
627
+ const waited = waitDone(rt, baseline, true)
628
+ if (waited) return waited
594
629
  return { personality, runtime: rt, action: 'compacted', woke: true }
595
630
  }
596
631
 
@@ -69,7 +69,8 @@ describe('canary script (pure)', () => {
69
69
  // proven live); the SERVER's liveness decides, after a dismissal grace sleep.
70
70
  expect(s).not.toContain(`[ "$rc" -eq 0 ] || [ "$rc" -ge 128 ]; then exit 0`) // the v1 hole
71
71
  expect(s).toContain('sleep 2') // dismissal grace window
72
- expect(s).toContain(`has-session 2>/dev/null; then exit 0`) // server alive → nothing to record
72
+ expect(s).toContain(`kill -0 "$SPID" 2>/dev/null; then exit 0`) // original server alive → nothing to record
73
+ expect(s).toContain(`[ -z "$SPID" ]`) // fallback only when the original pid was unavailable
73
74
  expect(s).toContain('cause=server-vanished') // connection drop (SIGKILL/OOM class)
74
75
  expect(s).toContain('cause=signaled-server-gone') // rc=0: channel/client-TERM, server died
75
76
  expect(s).toContain('cause=client-killed-server-gone') // rc≥128: client took a hard kill
@@ -119,6 +120,7 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
119
120
  'notifier-canatear',
120
121
  'claude-canasweep',
121
122
  'claude-canaclient',
123
+ 'claude-canareplaced',
122
124
  ]
123
125
 
124
126
  afterAll(() => {
@@ -285,4 +287,44 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
285
287
  },
286
288
  20000,
287
289
  )
290
+
291
+ test(
292
+ 'dirty server death followed by immediate replacement on the same socket still records the ORIGINAL death',
293
+ async () => {
294
+ const identity = 'claude-canareplaced'
295
+ const { sock, logDir } = bringUp(identity)
296
+ const pid = serverPid(sock)
297
+ expect(pid).toBeGreaterThan(0)
298
+ expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
299
+ expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
300
+ await sleep(500)
301
+
302
+ process.kill(pid, 'SIGKILL')
303
+ // Reuse the same socket before the canary's 2s dismissal-grace window
304
+ // expires. The old socket-level probe treated this successor as "server
305
+ // alive" and lost the predecessor's death; the canary must key on SPID.
306
+ let started = false
307
+ for (let i = 0; i < 20; i++) {
308
+ const r = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '120'], {
309
+ stdio: 'ignore',
310
+ })
311
+ if (r.status === 0) {
312
+ started = true
313
+ break
314
+ }
315
+ await sleep(50)
316
+ }
317
+ expect(started).toBe(true)
318
+
319
+ const log = exitLogPath(logDir)
320
+ expect(await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 8000)).toBe(
321
+ true,
322
+ )
323
+ const line = readFileSync(log, 'utf8')
324
+ expect(line).toContain(`ev=server-exit identity=${identity}`)
325
+ expect(line).toContain(`server_pid=${pid}`)
326
+ expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1)
327
+ },
328
+ 20000,
329
+ )
288
330
  })
@@ -19,14 +19,18 @@
19
19
  // sh recorder (`dismissCanary` → TERM → trap → silent exit). POSIX trap
20
20
  // semantics make this race-free: the trap runs before any recording.
21
21
  // • When wait-for returns — ANY code — the script sleeps 2 s (a concurrent
22
- // dismissal TERM wins here), then probes SERVER LIVENESS: alive → exit
23
- // silently (a lost canary is re-armed and logged by the supervise retrofit
24
- // within a tick); dead with nobody having dismissed us → the death is real
25
- // and unclaimed → ONE logfmt line `ev=server-exit` into exits.log (the
26
- // per-peer death-cause home, next to pane-died's `ev=session-exit`) + a
27
- // forensics snapshot (vm_stat / swap / top-RSS ps / fresh DiagnosticReports)
28
- // captured within seconds — the evidence the 60 s supervise tick can never
29
- // recover ("системных следов ноль" was the recurring investigation outcome).
22
+ // dismissal TERM wins here), then probes the ORIGINAL SERVER PID captured at
23
+ // arm time: still alive → exit silently (a lost canary is re-armed and logged
24
+ // by the supervise retrofit within a tick); gone with nobody having dismissed
25
+ // us → the death is real and unclaimed → ONE logfmt line `ev=server-exit`
26
+ // into exits.log (the per-peer death-cause home, next to pane-died's
27
+ // `ev=session-exit`) + a forensics snapshot (vm_stat / swap / top-RSS ps /
28
+ // fresh DiagnosticReports) captured within seconds — the evidence the 60 s
29
+ // supervise tick can never recover ("системных следов ноль" was the recurring
30
+ // investigation outcome). PID identity matters: a wake/attach can replace the
31
+ // tmux server on the same socket before the 2 s grace elapses; probing only
32
+ // the socket would mistake the successor for the dead original and lose the
33
+ // canary record (live canary-gap 11.06).
30
34
  // The raw wait_rc still ATTRIBUTES the death (cause=server-vanished /
31
35
  // signaled-server-gone / client-killed-server-gone).
32
36
  // Residual blind spot (structural): a killer that SIGKILLs the sh recorder
@@ -98,12 +102,16 @@ export function canaryScript(o: CanaryScriptOptions): string {
98
102
  // `rc=0 || rc>=128 → silent` was exactly that hole). v2 contract instead:
99
103
  // • deliberate teardowns DISMISS this sh (TERM → trap above) — the sleep
100
104
  // below gives a concurrently-delivered dismissal time to win;
101
- // • then the SERVER's liveness, not the exit code, decides: alive →
105
+ // • then the ORIGINAL SERVER PID's liveness, not the exit code, decides: alive →
102
106
  // nothing to record (a lost canary is re-armed and logged by the
103
- // supervise retrofit within a tick); dead and nobody dismissed us →
104
- // the death is real and unclaimed → record it.
107
+ // supervise retrofit within a tick); original server dead and nobody
108
+ // dismissed us → the death is real and unclaimed → record it. IMPORTANT:
109
+ // check the ORIGINAL SPID, not merely "some server answers on the same
110
+ // socket". A wake/attach can replace the server inside this 2 s window;
111
+ // socket-liveness would then silently swallow the predecessor's death.
105
112
  `sleep 2`,
106
- `if '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
113
+ `if [ -n "$SPID" ] && kill -0 "$SPID" 2>/dev/null; then exit 0; fi`,
114
+ `if [ -z "$SPID" ] && '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
107
115
  // The exit code still ATTRIBUTES the recorded death (raw wait_rc is kept):
108
116
  // rc=0 → signaled-server-gone (channel signal or client-TERM, server died)
109
117
  // rc≥128 → client-killed-server-gone (client took a non-TERM kill)
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
6
+
7
+ function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
8
+ const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
9
+ try {
10
+ fn(home, { ...process.env, HOME: home })
11
+ } finally {
12
+ rmSync(home, { recursive: true, force: true })
13
+ }
14
+ }
15
+
16
+ function claudeSlug(cwd: string): string {
17
+ return realpathSync(cwd).replace(/[^a-zA-Z0-9]/g, '-')
18
+ }
19
+
20
+ describe('compact done transcript gate', () => {
21
+ test('codex: ignores old markers and detects a new context_compacted event', () => withHome((home, env) => {
22
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-codex-cwd-'))
23
+ try {
24
+ const dir = join(home, '.codex', 'sessions', '2026', '06', '13')
25
+ mkdirSync(dir, { recursive: true })
26
+ const file = join(dir, 'rollout-test.jsonl')
27
+ writeFileSync(file,
28
+ JSON.stringify({ type: 'session_meta', payload: { cwd } }) + '\n' +
29
+ JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n',
30
+ )
31
+ const baseline = compactDoneBaseline('codex', cwd, { env })
32
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
33
+ appendFileSync(file, JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n')
34
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
35
+ } finally {
36
+ rmSync(cwd, { recursive: true, force: true })
37
+ }
38
+ }))
39
+
40
+ test('claude: detects a new compact_boundary and ignores raw text mentions', () => withHome((home, env) => {
41
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-claude-cwd-'))
42
+ try {
43
+ const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
44
+ mkdirSync(dir, { recursive: true })
45
+ const file = join(dir, 'session.jsonl')
46
+ writeFileSync(file,
47
+ JSON.stringify({ type: 'user', message: { role: 'user', content: 'context_compacted is only text' } }) + '\n' +
48
+ JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' }) + '\n',
49
+ )
50
+ const baseline = compactDoneBaseline('claude', cwd, { env })
51
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
52
+ appendFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'Conversation compacted text only' } }) + '\n')
53
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
54
+ appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { durationMs: 12 } }) + '\n')
55
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
56
+ } finally {
57
+ rmSync(cwd, { recursive: true, force: true })
58
+ }
59
+ }))
60
+ })
@@ -13,8 +13,9 @@
13
13
  // runtime's adapter.deliveryMarkers. The poll/re-press logic is untouched; only the
14
14
  // marker SOURCE moved, so the timing behaviour is identical.
15
15
 
16
- import { readdirSync } from 'fs'
16
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
17
17
  import { join } from 'path'
18
+ import { homedir } from 'os'
18
19
  import { spawnSync } from 'child_process'
19
20
  import {
20
21
  MAX_ATTACHMENTS,
@@ -309,6 +310,229 @@ export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: s
309
310
  return ok(undefined)
310
311
  }
311
312
 
313
+
314
+ // ─── Compact done gate (dialogue control must report FACT, not keystroke delivery) ──
315
+
316
+ export interface CompactDoneBaseline {
317
+ runtime: string
318
+ cwd: string
319
+ capturedAtMs: number
320
+ files: { path: string; size: number }[]
321
+ }
322
+
323
+ export interface CompactDoneWaitResult {
324
+ ok: true
325
+ ms: number
326
+ signal: 'transcript+ready'
327
+ }
328
+
329
+ function envHome(env: NodeJS.ProcessEnv | undefined): string {
330
+ return env?.HOME?.trim() || homedir()
331
+ }
332
+
333
+ function canonicalPath(p: string): string {
334
+ try {
335
+ return realpathSync(p)
336
+ } catch {
337
+ return p
338
+ }
339
+ }
340
+
341
+ function claudeTranscriptDirForCwd(cwd: string, env?: NodeJS.ProcessEnv): string {
342
+ const slug = canonicalPath(cwd).replace(/[^a-zA-Z0-9]/g, '-')
343
+ return join(envHome(env), '.claude', 'projects', slug)
344
+ }
345
+
346
+ function codexSessionCwd(file: string): string | null {
347
+ try {
348
+ const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
349
+ if (!firstLine) return null
350
+ const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
351
+ return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string' ? entry.payload.cwd : null
352
+ } catch {
353
+ return null
354
+ }
355
+ }
356
+
357
+ function compactCandidateFiles(runtime: string, cwd: string, env?: NodeJS.ProcessEnv): string[] {
358
+ const target = canonicalPath(cwd)
359
+ const out: string[] = []
360
+ if (runtime === 'claude') {
361
+ const dir = claudeTranscriptDirForCwd(cwd, env)
362
+ let entries
363
+ try {
364
+ entries = readdirSync(dir, { withFileTypes: true })
365
+ } catch {
366
+ return []
367
+ }
368
+ for (const entry of entries) {
369
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(join(dir, entry.name))
370
+ }
371
+ } else if (runtime === 'codex') {
372
+ const root = join(envHome(env), '.codex', 'sessions')
373
+ function visit(dir: string): void {
374
+ let entries
375
+ try {
376
+ entries = readdirSync(dir, { withFileTypes: true })
377
+ } catch {
378
+ return
379
+ }
380
+ for (const entry of entries) {
381
+ const path = join(dir, entry.name)
382
+ if (entry.isDirectory()) {
383
+ visit(path)
384
+ continue
385
+ }
386
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
387
+ if (canonicalPath(codexSessionCwd(path) ?? '') === target) out.push(path)
388
+ }
389
+ }
390
+ visit(root)
391
+ }
392
+ return out.sort()
393
+ }
394
+
395
+ function compactDoneFileSize(path: string): number {
396
+ try {
397
+ return statSync(path).size
398
+ } catch {
399
+ return 0
400
+ }
401
+ }
402
+
403
+ export function compactDoneBaseline(runtime: string, cwd: string, opts: { env?: NodeJS.ProcessEnv } = {}): CompactDoneBaseline {
404
+ return {
405
+ runtime,
406
+ cwd,
407
+ capturedAtMs: monotonicMs(),
408
+ files: compactCandidateFiles(runtime, cwd, opts.env).map(path => ({ path, size: compactDoneFileSize(path) })),
409
+ }
410
+ }
411
+
412
+ function isStructuredCompactDone(runtime: string, obj: unknown): boolean {
413
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
414
+ const rec = obj as Record<string, unknown>
415
+ const payload = rec.payload && typeof rec.payload === 'object' && !Array.isArray(rec.payload)
416
+ ? rec.payload as Record<string, unknown>
417
+ : undefined
418
+
419
+ // Codex writes an explicit structured completion event after slash-/compact.
420
+ // The adjacent top-level `compacted` record is accepted too: older/newer Codex
421
+ // builds can emit it immediately before/without the event_msg wrapper.
422
+ if (runtime === 'codex') {
423
+ if (rec.type === 'event_msg' && payload?.type === 'context_compacted') return true
424
+ if (rec.type === 'compacted') return true
425
+ }
426
+
427
+ // Claude Code records the finished compaction as a system compact boundary with
428
+ // compactMetadata (duration/pre/post tokens). Raw text mentions are ignored.
429
+ if (runtime === 'claude') {
430
+ if (rec.type === 'system' && rec.subtype === 'compact_boundary') return true
431
+ if (rec.type === 'system' && rec.compactMetadata && typeof rec.compactMetadata === 'object') return true
432
+ }
433
+
434
+ return false
435
+ }
436
+
437
+ export function compactTranscriptHasDone(
438
+ baseline: CompactDoneBaseline,
439
+ opts: { env?: NodeJS.ProcessEnv } = {},
440
+ ): boolean {
441
+ const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
442
+ // Include a transcript/session file that appears after the baseline (rare, but a
443
+ // resume/rotate race should not make the done gate blind). Existing files are
444
+ // read only from their baseline byte offset, so old compact markers cannot pass.
445
+ for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
446
+ if (!offsets.has(path)) offsets.set(path, 0)
447
+ }
448
+
449
+ for (const [path, offset] of offsets) {
450
+ let buf: Buffer
451
+ try {
452
+ buf = readFileSync(path)
453
+ } catch {
454
+ continue
455
+ }
456
+ const start = Math.min(Math.max(0, offset), buf.length)
457
+ const tail = buf.subarray(start).toString('utf8')
458
+ for (const line of tail.split(/\r?\n/)) {
459
+ if (!line.trim()) continue
460
+ let obj: unknown
461
+ try {
462
+ obj = JSON.parse(line)
463
+ } catch {
464
+ continue
465
+ }
466
+ if (isStructuredCompactDone(baseline.runtime, obj)) return true
467
+ }
468
+ }
469
+ return false
470
+ }
471
+
472
+ function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
473
+ const raw = env?.IAPEER_COMPACT_DONE_TIMEOUT_MS
474
+ const n = raw === undefined ? NaN : Number(raw)
475
+ // Keep default below telegram-runtime's 300 s command timeout so iapeer returns
476
+ // the truthful failure itself instead of letting the channel kill the process.
477
+ return Number.isFinite(n) && n >= 0 ? n : 290_000
478
+ }
479
+
480
+ function compactDonePollMs(env: NodeJS.ProcessEnv | undefined): number {
481
+ const raw = env?.IAPEER_COMPACT_DONE_POLL_MS
482
+ const n = raw === undefined ? NaN : Number(raw)
483
+ return Number.isFinite(n) && n > 0 ? n : 1000
484
+ }
485
+
486
+ /**
487
+ * Wait until a slash-/compact command has actually FINISHED, not merely until its
488
+ * keystrokes were accepted. The primary signal is scroll-proof structured
489
+ * transcript state written by the runtimes: Codex `context_compacted`, Claude
490
+ * `compact_boundary`. The final input-ready check prevents a marker-only early
491
+ * return while the TUI is still transitioning back to the composer/prompt.
492
+ */
493
+ export function waitForCompactDone(
494
+ target: DeliveryTarget,
495
+ cwd: string,
496
+ baseline: CompactDoneBaseline,
497
+ opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number; pollMs?: number } = {},
498
+ ): Result<CompactDoneWaitResult> {
499
+ const env = opts.env ?? process.env
500
+ const timeoutMs = opts.timeoutMs ?? compactDoneTimeoutMs(env)
501
+ const pollMs = opts.pollMs ?? compactDonePollMs(env)
502
+ const adapter = getAdapter(target.runtime)
503
+ const started = monotonicMs()
504
+ const deadline = started + timeoutMs
505
+ let sawTranscriptDone = false
506
+ let lastPane = ''
507
+
508
+ do {
509
+ if (!sessionAlive(target.socketPath, target.address)) {
510
+ return err(`compact did not complete: tmux session vanished`)
511
+ }
512
+ sawTranscriptDone ||= compactTranscriptHasDone(baseline, { env })
513
+ const pane = tmux(target.socketPath, 'capture-pane', '-p', '-t', target.address)
514
+ if (pane.ok) lastPane = pane.out
515
+ if (sawTranscriptDone && pane.ok && adapter.isInputReady(pane.out)) {
516
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
517
+ }
518
+ sleepSync(pollMs)
519
+ } while (monotonicMs() < deadline)
520
+
521
+ const tail = lastPane
522
+ .split(/\r?\n/)
523
+ .map(line => line.trim())
524
+ .filter(Boolean)
525
+ .slice(-2)
526
+ .join(' ⏎ ')
527
+ .replace(/"/g, "'")
528
+ .slice(0, 240)
529
+ return err(
530
+ `compact did not complete within ${timeoutMs}ms (` +
531
+ `${sawTranscriptDone ? 'transcript marker seen, input not ready' : 'no transcript compact marker'}` +
532
+ `${tail ? `; pane tail: ${tail}` : ''})`,
533
+ )
534
+ }
535
+
312
536
  // ─── Control channel (Ф-E) — in-session control via the adapter, UNCONDITIONAL ──
313
537
 
314
538
  /**