@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 +1 -1
- package/src/daemon/daemon.test.ts +10 -3
- package/src/daemon/index.ts +16 -25
- package/src/launch/adapters/claude.ts +0 -20
- package/src/launch/adapters/codex.ts +0 -29
- package/src/launch/adapters/notifier.ts +0 -10
- package/src/launch/adapters/telegram.ts +0 -14
- package/src/launch/bootdeliver.test.ts +0 -2
- package/src/launch/launch.test.ts +0 -9
- package/src/launch/types.ts +0 -6
- package/src/lifecycle/index.ts +16 -5
- package/src/lifecycle/lifecycle.test.ts +9 -0
- package/src/supervisor/protocol.ts +1 -1
- package/src/transport/compactDone.test.ts +85 -1
- package/src/transport/index.ts +86 -22
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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')
|
package/src/daemon/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
104
|
+
export function listTools(): unknown[] {
|
|
114
105
|
return [
|
|
115
106
|
{
|
|
116
107
|
name: 'send_to_peer',
|
|
117
|
-
description: buildSendDescription(
|
|
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(
|
|
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
|
|
@@ -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) ──────
|
package/src/launch/types.ts
CHANGED
|
@@ -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 }
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
769
|
-
* transcript state written by the runtimes
|
|
770
|
-
*
|
|
771
|
-
*
|
|
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: {
|
|
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 (!
|
|
837
|
+
if (!aliveFn(target)) {
|
|
790
838
|
return err(`compact did not complete: tmux session vanished`)
|
|
791
839
|
}
|
|
792
|
-
sawTranscriptDone
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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 →
|
|
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
|