@agfpd/iapeer 0.2.74 → 0.2.76

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.74",
3
+ "version": "0.2.76",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,12 +73,19 @@ describe('caller identity from header', () => {
73
73
  })
74
74
 
75
75
  describe('listTools', () => {
76
- test('serves ONLY send_to_peer (list_online_peers deprecated) with alwaysLoad meta and roster', () => {
77
- const tools = listTools(readPeersIndex()) as any[]
76
+ test('serves ONLY send_to_peer (list_online_peers deprecated) with alwaysLoad meta and NO embedded roster', () => {
77
+ const tools = listTools() as any[]
78
78
  expect(tools.map(t => t.name).sort()).toEqual(['send_to_peer'])
79
79
  expect(tools.every(t => t._meta['anthropic/alwaysLoad'] === true)).toBe(true)
80
80
  const sendTool = tools.find(t => t.name === 'send_to_peer')
81
- expect(sendTool.description).toContain('arthur')
81
+ // Regression guard: the peer roster lives ONLY in the system prompt
82
+ // (composeSystemPrompt → "## Known peers"), NEVER in the tool description —
83
+ // embedding it here duplicated the list into every agent's context per turn.
84
+ expect(sendTool.description).not.toMatch(/Known peers/)
85
+ expect(sendTool.description).not.toContain('arthur')
86
+ expect(sendTool.description).not.toMatch(/Runtimes:/)
87
+ // Purpose + parameter semantics stay.
88
+ expect(sendTool.description).toContain('Send a message to a known iapeer peer')
82
89
  expect(sendTool.description).toContain('wakes it and delivers there')
83
90
  expect(sendTool.description).toContain('parallel to the default runtime')
84
91
  expect(sendTool.inputSchema.properties.runtime.description).toContain('wakes it warm-on-demand')
@@ -41,7 +41,7 @@ import {
41
41
  import { parseSessionName } from '../core/socket.ts'
42
42
  import { IapError } from '../core/errors.ts'
43
43
  import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
44
- import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
44
+ import { readPeersIndex, type PeersIndex } from '../registry/index.ts'
45
45
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
46
46
  import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
47
47
  import { appendDeliveryEvent } from './deliverylog.ts'
@@ -76,31 +76,22 @@ export function resolveCallerFromHeader(value: string | undefined, index: PeersI
76
76
  // Tool definitions (ported verbatim from IAP server.ts)
77
77
  // ─────────────────────────────────────────────────────────────────────────────
78
78
 
79
- export function buildSendDescription(index: PeersIndex): string {
80
- const lines = [
79
+ // The roster of known peers is DELIBERATELY NOT in this description. It is rendered
80
+ // ONCE, in the peer's system prompt (composeSystemPrompt → Layer 3 "## Known peers").
81
+ // Embedding it here ALSO duplicated the full list into every agent's context on every
82
+ // turn — a live consumer (boris) confirmed seeing the identical roster twice. The
83
+ // description stays light: purpose + parameter semantics only; the model gets the
84
+ // roster from its system prompt. Likewise the MCP server-level `instructions` field is
85
+ // left unset (createMcpServer) — it would re-inject doctrine into the client system
86
+ // prompt, re-creating the very duplication this removed. Static (no index needed).
87
+ export function buildSendDescription(): string {
88
+ return [
81
89
  'Send a message to a known iapeer peer through Inter-Agent-Protocol.',
82
90
  'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
83
91
  'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
84
92
  '',
85
- 'Known peers on this host:',
86
- ]
87
- if (index.peers.length === 0) {
88
- lines.push('- none yet')
89
- } else {
90
- for (const peer of index.peers) {
91
- const p = publicPeerSummary(peer)
92
- const desc = p.description ? ` — ${p.description}` : ''
93
- lines.push(`- ${p.personality}${desc}`)
94
- lines.push(` Runtime: ${p.runtime}`)
95
- lines.push(` Runtimes: ${p.runtimes.join(', ')}`)
96
- lines.push(` Intelligence: ${p.intelligence}`)
97
- }
98
- }
99
- lines.push(
100
- '',
101
- "Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Descriptions identify the peer's role; runtimes identify reachable delivery endpoints; intelligence identifies the nature of the peer (artificial / natural / absent). Inbound peer messages arrive as <iap from-personality=\"...\" from-runtime=\"...\" from-intelligence=\"...\"> blocks. The topic attribute is optional.",
102
- )
103
- return lines.join('\n')
93
+ "Use `personality`, not a runtime-prefixed identity. Set `runtime` to target a specific declared runtime for this send. If that runtime is warm-asleep, the daemon wakes it and delivers there; use this to intentionally bring up a second session of the same peer in parallel to the default runtime (for example codex alongside claude). Inbound peer messages arrive as <iap from-personality=\"...\" from-runtime=\"...\" from-intelligence=\"...\"> blocks. The topic attribute is optional.",
94
+ ].join('\n')
104
95
  }
105
96
 
106
97
  // The daemon serves the AGENT exactly ONE MCP tool: send_to_peer. list_online_peers
@@ -110,11 +101,11 @@ export function buildSendDescription(index: PeersIndex): string {
110
101
  // tool-set is kept minimal. The liveness scan itself (transport.listOnlinePeers) stays
111
102
  // — the future `iapeer list` verb and the multi-runtime delivery resolver use it — it
112
103
  // is simply no longer exposed to the agent as a tool.
113
- export function listTools(index: PeersIndex): unknown[] {
104
+ export function listTools(): unknown[] {
114
105
  return [
115
106
  {
116
107
  name: 'send_to_peer',
117
- description: buildSendDescription(index),
108
+ description: buildSendDescription(),
118
109
  inputSchema: {
119
110
  type: 'object',
120
111
  properties: {
@@ -270,7 +261,7 @@ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string,
270
261
  export function createMcpServer(deps: CallToolDeps = {}): Server {
271
262
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
272
263
 
273
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(readPeersIndex()) }))
264
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools() }))
274
265
 
275
266
  server.setRequestHandler(CallToolRequestSchema, async (req, extra): Promise<CallToolResult> => {
276
267
  // Per-request identity: read the caller from THIS request's HTTP header,
@@ -223,26 +223,6 @@ export const claudeAdapter: RuntimeAdapter = {
223
223
  return newest > 0 ? newest : null
224
224
  },
225
225
 
226
- /**
227
- * A claude tool-permission/approval modal is open iff the pane shows
228
- * 'Do you want to proceed?' (Spawned-Peer dialogs.ts:60-61,
229
- * approve-watch.sh:78). Defensive only: a --dangerously-skip-permissions peer
230
- * does not normally reach it, but a stray approval (e.g. a connector) must not
231
- * stall a headless session.
232
- */
233
- permissionDialogActive(pane: string): boolean {
234
- return pane.includes('Do you want to proceed?')
235
- },
236
-
237
- /**
238
- * Affirm a claude approval with ['Enter']: option 1 ('Yes') is the
239
- * default-highlighted choice, so a bare Enter accepts (approve-watch.sh:22-25,
240
- * Spawned-Peer watcher.ts:298).
241
- */
242
- permissionDialogKeys(): string[] {
243
- return ['Enter']
244
- },
245
-
246
226
  /**
247
227
  * Resume preflight (fail-loud — never a silent fresh fallback): resolve the
248
228
  * newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
@@ -260,35 +260,6 @@ export const codexAdapter: RuntimeAdapter = {
260
260
  return newestCodexSessionMtime(cwd)
261
261
  },
262
262
 
263
- /**
264
- * A codex approval modal is open iff the pane carries the 'enter to submit'
265
- * footer AND an Allow-to-run title — the MCP tool-approval modal ("Allow the
266
- * <server> MCP server to run tool …?") or the generic command-exec approval
267
- * (dialogs.ts codexApprovalActive, 39-45). The footer is the discriminator
268
- * against boot-phase startup screens (those say 'Press enter to continue'), so
269
- * a dir-trust/update screen never matches here. Required even under YOLO:
270
- * connector / consequential MCP-tool approvals are NOT suppressed by
271
- * --dangerously-bypass-approvals-and-sandbox and would block a headless peer
272
- * forever (codex-start.sh:229-237).
273
- */
274
- permissionDialogActive(pane: string): boolean {
275
- if (!pane.includes('enter to submit')) return false
276
- if (/Allow the .+ MCP server to run tool/.test(pane)) return true
277
- return /\bAllow\b[\s\S]*\bto run\b/.test(pane)
278
- },
279
-
280
- /**
281
- * Affirm a codex approval with ['Down','Enter'] (watcher.answerPermissionDialog
282
- * codex branch, 302-306): the modal default-highlights option 1 ("Allow",
283
- * one-shot); one Down selects option 2 ("Allow for this session") so later tool
284
- * calls in this same headless session don't re-prompt, then Enter submits. Even
285
- * partial key delivery is safe — Down-then-nothing leaves option 1 (still an
286
- * affirmative), and the only destructive option (Cancel) is three Downs away.
287
- */
288
- permissionDialogKeys(): string[] {
289
- return ['Down', 'Enter']
290
- },
291
-
292
263
  /**
293
264
  * Resume preflight (fail-loud — never a silent fresh fallback). Codex resumes
294
265
  * via 'resume --last' with NO ref: it matches the newest session for cwd
@@ -68,16 +68,6 @@ export const notifierAdapter: RuntimeAdapter = {
68
68
  return null
69
69
  },
70
70
 
71
- /** No permission/approval modal: a router is not an LLM driving tools. */
72
- permissionDialogActive(_pane: string): boolean {
73
- return false
74
- },
75
-
76
- /** No permission modal to affirm — empty send-keys args. */
77
- permissionDialogKeys(): string[] {
78
- return []
79
- },
80
-
81
71
  /** Nothing to resume: a router does not replay a transcript. */
82
72
  resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
83
73
  return { ok: true }
@@ -98,20 +98,6 @@ export const telegramAdapter: RuntimeAdapter = {
98
98
  return null
99
99
  },
100
100
 
101
- /**
102
- * No permission/approval modal: a router is not an LLM driving tools behind an
103
- * autopilot, so there is never a dialog to detect (telegram-start.sh:14, "no
104
- * trust-dialog auto-accept, no permissions banner").
105
- */
106
- permissionDialogActive(_pane: string): boolean {
107
- return false
108
- },
109
-
110
- /** No permission modal to affirm — empty send-keys args (see above). */
111
- permissionDialogKeys(): string[] {
112
- return []
113
- },
114
-
115
101
  /**
116
102
  * Nothing to resume: a router does not replay a transcript, so resume cannot
117
103
  * fail-loud the way claude/codex do — there is no prior session ref to resolve
@@ -50,8 +50,6 @@ function catAdapter(recvPath: string): RuntimeAdapter {
50
50
  return null
51
51
  }
52
52
  },
53
- permissionDialogActive: () => false,
54
- permissionDialogKeys: () => [],
55
53
  resolveResume: () => ({ ok: true }),
56
54
  executeControl: () => null,
57
55
  }
@@ -136,7 +136,6 @@ describe('telegramAdapter (router — no TUI surface)', () => {
136
136
  expect(telegramAdapter.bootDialogKeys('anything')).toBeNull()
137
137
  expect(telegramAdapter.isInputReady('anything')).toBe(true)
138
138
  expect(telegramAdapter.newestActivityMtime('/w')).toBeNull()
139
- expect(telegramAdapter.permissionDialogActive('x')).toBe(false)
140
139
  })
141
140
  })
142
141
 
@@ -156,8 +155,6 @@ describe('notifierAdapter (router — infra/always-on)', () => {
156
155
  expect(notifierAdapter.bootDialogKeys('anything')).toBeNull()
157
156
  expect(notifierAdapter.isInputReady('anything')).toBe(true)
158
157
  expect(notifierAdapter.newestActivityMtime('/w')).toBeNull()
159
- expect(notifierAdapter.permissionDialogActive('x')).toBe(false)
160
- expect(notifierAdapter.permissionDialogKeys()).toEqual([])
161
158
  expect(notifierAdapter.resolveResume('/w')).toEqual({ ok: true })
162
159
  })
163
160
  })
@@ -249,12 +246,6 @@ describe('claudeAdapter', () => {
249
246
  "This session is 7h 3m old and 314.2k tokens.\n❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is\n 3. Don't ask me again\nEnter to confirm · Esc to cancel"
250
247
  expect(claudeAdapter.bootDialogKeys(live170)).toEqual(['Down'])
251
248
  })
252
-
253
- test('permissionDialog: proceed prompt → active, [Enter]', () => {
254
- expect(claudeAdapter.permissionDialogActive('Do you want to proceed?')).toBe(true)
255
- expect(claudeAdapter.permissionDialogActive('nothing')).toBe(false)
256
- expect(claudeAdapter.permissionDialogKeys()).toEqual(['Enter'])
257
- })
258
249
  })
259
250
 
260
251
  // ─── exit-cause observability: the pane-died hook builder (pure string) ──────
@@ -265,12 +265,6 @@ export interface RuntimeAdapter {
265
265
  * claude transcript / codex session jsonl mtime; null for a router (no proxy). */
266
266
  newestActivityMtime(cwd: string): number | null
267
267
 
268
- /** Is a permission/approval dialog open in the pane (headless autopilot)? */
269
- permissionDialogActive(pane: string): boolean
270
- /** tmux send-keys args to affirm a permission dialog (claude ['Enter'];
271
- * codex ['Down','Enter'] to pick "allow for session"). */
272
- permissionDialogKeys(): string[]
273
-
274
268
  /** Resume preflight — validate a resume request fail-loud (never silent fresh).
275
269
  * Returns the resolved ref (claude uuid) or ok:false with a reason. */
276
270
  resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
@@ -48,7 +48,7 @@ import {
48
48
  } from '../launch/index.ts'
49
49
  import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
50
50
  import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
51
- import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
51
+ import { hostSessionAlive, killPtyHost, ptyHostEnabled } from '../launch/ptyHost.ts'
52
52
  import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
53
53
 
54
54
  // ─────────────────────────────────────────────────────────────────────────────
@@ -668,10 +668,21 @@ function sessionAlive(sock: string, identity: string): boolean {
668
668
  * - `server-dead` — the server itself is gone: the socket file is missing, or it
669
669
  * exists but nothing serves it (stale socket — the SIGKILL/OOM class). pane-died
670
670
  * could never fire; the server-death canary (launch/canary.ts) is the recorder
671
- * for this class (`ev=server-exit` + forensics), this line is the backstop. */
672
- export function classifyGoneSession(sock: string): { death: 'server-dead' | 'session-gone'; reason: string } {
671
+ * for this class (`ev=server-exit` + forensics), this line is the backstop.
672
+ * `hosted=true` (pty-supervisor peer) reframes ONLY the absent-socket reason: a hosted peer
673
+ * has NO tmux socket, so that branch would otherwise misattribute its death to a tmux server
674
+ * that never existed. Its real cause lives in the supervisor exits.log. A socket that EXISTS
675
+ * is a genuine tmux server, so `hosted` never overrides the session-gone / stale-socket arm. */
676
+ export function classifyGoneSession(sock: string, hosted = false): { death: 'server-dead' | 'session-gone'; reason: string } {
673
677
  if (!existsSync(sock)) {
674
- return { death: 'server-dead', reason: 'tmux server gone (socket file missing)' }
678
+ // No socket file. For a pty-supervisor-hosted peer this is EXPECTED — it never had a tmux
679
+ // socket; its death is the supervisor session ending (cause in the supervisor exits.log
680
+ // death-EVENT), NOT a tmux server vanishing. For a tmux peer it IS a dead tmux server.
681
+ // The check is gated on the absent socket on purpose: a socket that EXISTS (below) means a
682
+ // real tmux server, so `hosted` (pty-enabled-by-default) must not override genuine tmux.
683
+ return hosted
684
+ ? { death: 'server-dead', reason: 'pty host session ended (supervisor) — cause in exits.log (death-EVENT)' }
685
+ : { death: 'server-dead', reason: 'tmux server gone (socket file missing)' }
675
686
  }
676
687
  // Ask the SERVER, not the session: any server-level command answering (exit 0)
677
688
  // proves the server is alive and merely lost this session.
@@ -1172,7 +1183,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1172
1183
  // distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
1173
1184
  // exits.log should have the cause) vs `server-dead` (whole tmux server died →
1174
1185
  // exits.log structurally empty; this line is the only durable trace).
1175
- const gone = classifyGoneSession(sock)
1186
+ const gone = classifyGoneSession(sock, ptyHostEnabled(cfg.logDir, s.identity))
1176
1187
  out.push({ identity: s.identity, action: 'reaped-gone', reason: gone.reason })
1177
1188
  trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
1178
1189
  continue
@@ -738,6 +738,15 @@ describe('classifyGoneSession (death-class tag)', () => {
738
738
  expect(r.reason).toContain('socket file missing')
739
739
  })
740
740
 
741
+ test('hosted peer → host-framed reason, not tmux', () => {
742
+ // A pty-supervisor-hosted peer never had a tmux socket; the tmux-framed "socket file
743
+ // missing" would misattribute its death. hosted=true short-circuits to the supervisor framing.
744
+ const r = classifyGoneSession(join(tmpdir(), 'iapeer-no-such-sock-ever.sock'), true)
745
+ expect(r.death).toBe('server-dead')
746
+ expect(r.reason).toContain('pty host')
747
+ expect(r.reason).not.toContain('tmux server')
748
+ })
749
+
741
750
  test('stale socket (file exists, nothing serves it) → server-dead', () => {
742
751
  // The SIGKILL/OOM class: the killed server never unlinks its socket file.
743
752
  const dir = mkdtempSync(join(tmpdir(), 'iapeer-stale-sock-'))
@@ -78,7 +78,7 @@ export function stripQueries(s: string): string {
78
78
  // "looks right" table).
79
79
  //
80
80
  // tmux semantics mirrored for the vocabulary our RuntimeAdapters actually emit (bootDialogKeys /
81
- // permissionDialogKeys / executeControl): a leading `-l` switches to LITERAL mode (every following
81
+ // executeControl): a leading `-l` switches to LITERAL mode (every following
82
82
  // token sent verbatim, no key-name lookup); otherwise each token is a KEY NAME when known, else
83
83
  // literal text. Cursor keys depend on DECCKM application-cursor-keys mode — `\x1b[B` (normal) vs
84
84
  // `\x1bOB` (application) — which tmux keys off the terminal mode and the caller reads from the
@@ -2,7 +2,14 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
- import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
5
+ import { compactDoneBaseline, compactTranscriptHasDone, waitForCompactDone, type DeliveryTarget } from './index.ts'
6
+
7
+ function claudeTarget(): DeliveryTarget {
8
+ return { runtime: 'claude', personality: 'tester', address: 'claude-tester', socketPath: '/tmp/iapeer-test-noop.sock' }
9
+ }
10
+
11
+ const CLAUDE_IDLE_PANE = '❯ \n bypass permissions on'
12
+ const CLAUDE_BUSY_PANE = '· Compacting conversation… (esc to interrupt)\n reloading context'
6
13
 
7
14
  function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
8
15
  const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
@@ -58,3 +65,80 @@ describe('compact done transcript gate', () => {
58
65
  }
59
66
  }))
60
67
  })
68
+
69
+ describe('waitForCompactDone — marker is authoritative, idle is a fast-path', () => {
70
+ function withClaudeMarker(
71
+ fn: (args: { cwd: string; env: NodeJS.ProcessEnv; writeMarker: () => void; baseline: ReturnType<typeof compactDoneBaseline> }) => void,
72
+ ): void {
73
+ withHome((home, env) => {
74
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-wait-cwd-'))
75
+ try {
76
+ const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
77
+ mkdirSync(dir, { recursive: true })
78
+ const file = join(dir, 'session.jsonl')
79
+ writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }) + '\n')
80
+ const baseline = compactDoneBaseline('claude', cwd, { env })
81
+ const writeMarker = () =>
82
+ appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { durationMs: 140708, preTokens: 535706, postTokens: 12524 } }) + '\n')
83
+ fn({ cwd, env, writeMarker, baseline })
84
+ } finally {
85
+ rmSync(cwd, { recursive: true, force: true })
86
+ }
87
+ })
88
+ }
89
+
90
+ // THE FIX: a large-context session that auto-continues into a working turn after compact
91
+ // keeps the composer busy (never idle). The marker proves completion → SUCCESS, not failure.
92
+ test('marker seen + composer never idle → success on the marker (signal: transcript)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
93
+ writeMarker()
94
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
95
+ env,
96
+ timeoutMs: 5000,
97
+ pollMs: 5,
98
+ graceMs: 20,
99
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
100
+ })
101
+ expect(r.ok).toBe(true)
102
+ if (r.ok) expect(r.value.signal).toBe('transcript')
103
+ }))
104
+
105
+ // Clean common case unchanged: marker + idle composer → the historical 'transcript+ready'.
106
+ test('marker seen + composer idle → fast-path success (signal: transcript+ready)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
107
+ writeMarker()
108
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
109
+ env,
110
+ timeoutMs: 5000,
111
+ pollMs: 5,
112
+ graceMs: 5000,
113
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_IDLE_PANE },
114
+ })
115
+ expect(r.ok).toBe(true)
116
+ if (r.ok) expect(r.value.signal).toBe('transcript+ready')
117
+ }))
118
+
119
+ // BOUNDARY preserved: no structured marker ever → honest failure (hung / never completed).
120
+ test('no marker → honest failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
121
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
122
+ env,
123
+ timeoutMs: 40,
124
+ pollMs: 5,
125
+ graceMs: 20,
126
+ seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
127
+ })
128
+ expect(r.ok).toBe(false)
129
+ if (!r.ok) expect(r.error.message).toContain('no transcript compact marker')
130
+ }))
131
+
132
+ // Session death during the wait still fails (liveness boundary).
133
+ test('session vanished → failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
134
+ const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
135
+ env,
136
+ timeoutMs: 5000,
137
+ pollMs: 5,
138
+ graceMs: 20,
139
+ seam: { sessionAlive: () => false, capturePane: () => CLAUDE_BUSY_PANE },
140
+ })
141
+ expect(r.ok).toBe(false)
142
+ if (!r.ok) expect(r.error.message).toContain('session vanished')
143
+ }))
144
+ })
@@ -262,13 +262,6 @@ export function hasAttachedTmuxClient(target: DeliveryTarget): boolean {
262
262
  return r.out.split(/\r?\n/).map(line => line.trim()).includes(target.address)
263
263
  }
264
264
 
265
- export function targetComposerHasHumanInput(target: DeliveryTarget): boolean {
266
- if (!isSupportedLocalRuntime(target.runtime)) return false
267
- const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
268
- if (!cap.ok) return false
269
- return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
270
- }
271
-
272
265
  function targetComposerBlocksDelivery(target: DeliveryTarget): boolean {
273
266
  if (!isSupportedLocalRuntime(target.runtime)) return false
274
267
  const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
@@ -603,7 +596,23 @@ export interface CompactDoneBaseline {
603
596
  export interface CompactDoneWaitResult {
604
597
  ok: true
605
598
  ms: number
606
- signal: 'transcript+ready'
599
+ /** How completion was confirmed:
600
+ * 'transcript+ready' — marker seen AND the composer returned to idle (the clean,
601
+ * common case: a small/medium compact that settles back to the prompt).
602
+ * 'transcript' — marker seen but the composer is still BUSY past the ready-grace
603
+ * (a large-context session that auto-continues into a working turn right after
604
+ * compact). The structured marker is the AUTHORITATIVE completion signal, so this
605
+ * is SUCCESS, not a timeout. */
606
+ signal: 'transcript+ready' | 'transcript'
607
+ }
608
+
609
+ /** Test seam for waitForCompactDone — inject the tmux liveness + pane capture so the
610
+ * marker/grace logic is covered hermetically (no live session). Omitting all = prod. */
611
+ export interface CompactDoneWaitSeam {
612
+ /** Is the target session still alive? Default: the real tmux/host sessionAlive. */
613
+ sessionAlive?: (target: DeliveryTarget) => boolean
614
+ /** Capture the target pane; null = capture failed. Default: real tmux capture-pane. */
615
+ capturePane?: (target: DeliveryTarget) => string | null
607
616
  }
608
617
 
609
618
  function envHome(env: NodeJS.ProcessEnv | undefined): string {
@@ -763,41 +772,97 @@ function compactDonePollMs(env: NodeJS.ProcessEnv | undefined): number {
763
772
  return Number.isFinite(n) && n > 0 ? n : 1000
764
773
  }
765
774
 
775
+ // Once the completion marker is seen, how long to keep PREFERRING the idle-composer
776
+ // confirmation (the clean 'transcript+ready' signal) before reporting success on the
777
+ // marker alone. A large-context session that auto-continues into a working turn never
778
+ // goes idle — this bounds how long we wait for an idle that may never come, so the
779
+ // busy case succeeds promptly instead of stalling to the overall deadline. Short by
780
+ // design; the marker is already authoritative.
781
+ function compactReadyGraceMs(env: NodeJS.ProcessEnv | undefined): number {
782
+ const raw = env?.IAPEER_COMPACT_READY_GRACE_MS
783
+ const n = raw === undefined ? NaN : Number(raw)
784
+ return Number.isFinite(n) && n >= 0 ? n : 5000
785
+ }
786
+
766
787
  /**
767
788
  * Wait until a slash-/compact command has actually FINISHED, not merely until its
768
- * keystrokes were accepted. The primary signal is scroll-proof structured
769
- * transcript state written by the runtimes: Codex `context_compacted`, Claude
770
- * `compact_boundary`. The final input-ready check prevents a marker-only early
771
- * return while the TUI is still transitioning back to the composer/prompt.
789
+ * keystrokes were accepted. The AUTHORITATIVE signal is scroll-proof structured
790
+ * transcript state written by the runtimes at the END of the operation (with post-
791
+ * compact token metadata): Codex `context_compacted`, Claude `compact_boundary`.
792
+ * Once that marker is present the compact objectively completed.
793
+ *
794
+ * Idle-composer (`isInputReady`) is treated as a FAST-PATH confirmation, NOT a
795
+ * requirement. A healthy large-context session auto-continues into a working turn
796
+ * right after compact — its composer is legitimately busy (not idle) for a while, so
797
+ * requiring idle here false-failed a finished compact (live incident 15.06, boris:
798
+ * marker at 12:14:58, first post-compact idle only ~232s later, past the ~290s gate).
799
+ * Therefore: marker seen + composer idle → 'transcript+ready' (clean); marker seen +
800
+ * composer still busy past a short grace → 'transcript' (busy-but-done, still SUCCESS).
801
+ *
802
+ * BOUNDARY (preserved): a compact whose structured marker NEVER appears still fails
803
+ * honestly — that is the genuine "hung / never completed" case. Cross-runtime: the
804
+ * marker set + isInputReady are both per-adapter, so claude and codex share this path.
772
805
  */
773
806
  export function waitForCompactDone(
774
807
  target: DeliveryTarget,
775
808
  cwd: string,
776
809
  baseline: CompactDoneBaseline,
777
- opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number; pollMs?: number } = {},
810
+ opts: {
811
+ env?: NodeJS.ProcessEnv
812
+ timeoutMs?: number
813
+ pollMs?: number
814
+ graceMs?: number
815
+ seam?: CompactDoneWaitSeam
816
+ } = {},
778
817
  ): Result<CompactDoneWaitResult> {
779
818
  const env = opts.env ?? process.env
780
819
  const timeoutMs = opts.timeoutMs ?? compactDoneTimeoutMs(env)
781
820
  const pollMs = opts.pollMs ?? compactDonePollMs(env)
821
+ const graceMs = opts.graceMs ?? compactReadyGraceMs(env)
822
+ const aliveFn = opts.seam?.sessionAlive ?? ((t: DeliveryTarget) => sessionAlive(t.socketPath, t.address))
823
+ const captureFn =
824
+ opts.seam?.capturePane ??
825
+ ((t: DeliveryTarget) => {
826
+ const cap = tmux(t.socketPath, 'capture-pane', '-p', '-t', t.address)
827
+ return cap.ok ? cap.out : null
828
+ })
782
829
  const adapter = getAdapter(target.runtime)
783
830
  const started = monotonicMs()
784
831
  const deadline = started + timeoutMs
785
832
  let sawTranscriptDone = false
833
+ let markerSeenAtMs = -1
786
834
  let lastPane = ''
787
835
 
788
836
  do {
789
- if (!sessionAlive(target.socketPath, target.address)) {
837
+ if (!aliveFn(target)) {
790
838
  return err(`compact did not complete: tmux session vanished`)
791
839
  }
792
- sawTranscriptDone ||= compactTranscriptHasDone(baseline, { env })
793
- const pane = tmux(target.socketPath, 'capture-pane', '-p', '-t', target.address)
794
- if (pane.ok) lastPane = pane.out
795
- if (sawTranscriptDone && pane.ok && adapter.isInputReady(pane.out)) {
796
- return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
840
+ if (!sawTranscriptDone && compactTranscriptHasDone(baseline, { env })) {
841
+ sawTranscriptDone = true
842
+ markerSeenAtMs = monotonicMs()
843
+ }
844
+ const pane = captureFn(target)
845
+ if (pane !== null) lastPane = pane
846
+ if (sawTranscriptDone) {
847
+ // Fast clean path: the composer returned to idle (the historical signal).
848
+ if (pane !== null && adapter.isInputReady(pane)) {
849
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
850
+ }
851
+ // Marker is authoritative: a busy composer after a held marker is a healthy
852
+ // auto-continue, not a failure. Succeed on the marker once the grace elapses.
853
+ if (monotonicMs() - markerSeenAtMs >= graceMs) {
854
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
855
+ }
797
856
  }
798
857
  sleepSync(pollMs)
799
858
  } while (monotonicMs() < deadline)
800
859
 
860
+ // Deadline hit. The marker is authoritative: if it was EVER seen, the compact did
861
+ // finish — a still-busy composer at the deadline is not a failure (succeed on the
862
+ // marker). Only a MISSING marker is an honest failure (hung / never-completed compact).
863
+ if (sawTranscriptDone) {
864
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
865
+ }
801
866
  const tail = lastPane
802
867
  .split(/\r?\n/)
803
868
  .map(line => line.trim())
@@ -807,8 +872,7 @@ export function waitForCompactDone(
807
872
  .replace(/"/g, "'")
808
873
  .slice(0, 240)
809
874
  return err(
810
- `compact did not complete within ${timeoutMs}ms (` +
811
- `${sawTranscriptDone ? 'transcript marker seen, input not ready' : 'no transcript compact marker'}` +
875
+ `compact did not complete within ${timeoutMs}ms (no transcript compact marker` +
812
876
  `${tail ? `; pane tail: ${tail}` : ''})`,
813
877
  )
814
878
  }
@@ -1302,7 +1366,7 @@ function telegramSenderGuard(
1302
1366
 
1303
1367
  /**
1304
1368
  * Route a send from a request-resolved caller to a target peer and deliver it.
1305
- * hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
1369
+ * hit (live) → build envelope from the CALLER → deliverWarm (host-aware) → {woke:false}
1306
1370
  * miss (dead) → if deps.wake: wake the peer (envelope = boot first-message) →
1307
1371
  * verify-before-act re-resolve → {woke:true}; else explicit
1308
1372
  * "offline" (Ф1 behaviour). H4 (don't wake launchd peers) and the