@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 +1 -1
- package/src/cli/cli.test.ts +23 -1
- package/src/cli/index.ts +41 -6
- package/src/launch/canary.test.ts +43 -1
- package/src/launch/canary.ts +20 -12
- package/src/transport/compactDone.test.ts +60 -0
- package/src/transport/index.ts +225 -1
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -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
|
|
519
|
-
*
|
|
520
|
-
*
|
|
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
|
|
557
|
-
|
|
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(`
|
|
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
|
})
|
package/src/launch/canary.ts
CHANGED
|
@@ -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
|
|
23
|
-
// silently (a lost canary is re-armed and logged
|
|
24
|
-
// within a tick);
|
|
25
|
-
// and unclaimed → ONE logfmt line `ev=server-exit`
|
|
26
|
-
// per-peer death-cause home, next to pane-died's
|
|
27
|
-
// forensics snapshot (vm_stat / swap / top-RSS ps /
|
|
28
|
-
// captured within seconds — the evidence the 60 s
|
|
29
|
-
// recover ("системных следов ноль" was the recurring
|
|
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
|
|
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
|
|
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
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
/**
|