@agfpd/iapeer 0.2.75 → 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.75",
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
@@ -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)
@@ -1373,7 +1366,7 @@ function telegramSenderGuard(
1373
1366
 
1374
1367
  /**
1375
1368
  * Route a send from a request-resolved caller to a target peer and deliver it.
1376
- * hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
1369
+ * hit (live) → build envelope from the CALLER → deliverWarm (host-aware) → {woke:false}
1377
1370
  * miss (dead) → if deps.wake: wake the peer (envelope = boot first-message) →
1378
1371
  * verify-before-act re-resolve → {woke:true}; else explicit
1379
1372
  * "offline" (Ф1 behaviour). H4 (don't wake launchd peers) and the