@agfpd/iapeer 0.4.132 → 0.4.134
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/docs/12-cli-reference.md +1 -1
- package/docs/20-opencode.md +11 -4
- package/package.json +1 -1
- package/src/approval/install.ts +4 -0
- package/src/cli/index.ts +16 -2
- package/src/daemon/main.ts +6 -2
- package/src/launch/adapters/codex.ts +24 -2
- package/src/lifecycle/index.ts +25 -4
- package/src/lifecycle/inflight.ts +30 -0
- package/src/transport/index.ts +13 -0
package/docs/12-cli-reference.md
CHANGED
|
@@ -77,7 +77,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
77
77
|
|---|---|
|
|
78
78
|
| `enable <plugin> [peer] [--no-setup]` | Install and enable a capability plugin for a peer (across runtimes), run its setup step. |
|
|
79
79
|
| `native-memory <off\|on> (--peer <p> \| --all)` | Turn off/restore a peer's runtimes' native memory. |
|
|
80
|
-
| `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. |
|
|
80
|
+
| `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. The core also re-asserts the trust of `<cwd>/.codex/hooks.json` on EVERY codex launch (idempotent), and `rename` / `approval-mode yolo` restore what must stay trusted — a codex peer boots to its input with no human even after its trust was wiped (18.10). |
|
|
81
81
|
|
|
82
82
|
## Agent self-calls
|
|
83
83
|
|
package/docs/20-opencode.md
CHANGED
|
@@ -212,16 +212,23 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
|
|
|
212
212
|
session is gone or was replaced become orphans and are redelivered through the
|
|
213
213
|
normal route as their original sender, prefixed `[redelivered by iapeer: …]`
|
|
214
214
|
(wake-on-miss and the crash-loop guard apply). If redelivery fails, the sender
|
|
215
|
-
receives an explicit notice from `iapeer`. Logged as `ev=inflight-orphaned`
|
|
215
|
+
receives an explicit notice from the core identity `iapeer-system`. Logged as `ev=inflight-orphaned`
|
|
216
216
|
(lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
|
|
217
217
|
(delivery.log).
|
|
218
218
|
- A false «not processed» costs a visible duplicate, never a silent loss.
|
|
219
219
|
- Interrupted turns. «Processed» (a model response began) is not «finished»: a
|
|
220
220
|
turn killed mid-tool may already have had side effects, so it is never
|
|
221
221
|
redelivered. The ledger keeps an entry until its turn finishes (claude: an
|
|
222
|
-
assistant record with a terminal `stop_reason
|
|
223
|
-
|
|
224
|
-
`
|
|
222
|
+
assistant record with a terminal `stop_reason`, or claude's own interrupt
|
|
223
|
+
marker `[Request interrupted by user…]`; codex: `task_complete` or
|
|
224
|
+
`turn_aborted`; opencode: a completed assistant message for it with a finish
|
|
225
|
+
other than `tool-calls`); a finished later message covers earlier ones. An
|
|
226
|
+
operator interrupt is a finished turn, not a death (18.10: a turn the owner had
|
|
227
|
+
stopped was reported «INTERRUPTED» when the session later died for another
|
|
228
|
+
reason). A session that ends ITSELF (`self-fresh`) leaves a `<identity>.self-ended`
|
|
229
|
+
marker with its hosting token; the daemon's sweep settles that session's started
|
|
230
|
+
entries silently instead of notifying their senders (the peer declared the work
|
|
231
|
+
done); not-yet-started entries are redelivered to the fresh session as usual. If the session
|
|
225
232
|
dies with the turn unfinished, the sender gets an explicit notice from iapeer
|
|
226
233
|
(«the turn was INTERRUPTED and its result is not guaranteed»), logged as
|
|
227
234
|
`ev=inflight-interrupted`.
|
package/package.json
CHANGED
package/src/approval/install.ts
CHANGED
|
@@ -161,6 +161,10 @@ export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = proces
|
|
|
161
161
|
return path
|
|
162
162
|
}
|
|
163
163
|
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
164
|
+
// The trust wipe above is cwd-wide (codex keys state POSITIONALLY, so removing our group shifts the
|
|
165
|
+
// others' keys). Re-seed whatever hooks REMAIN — the memory provider's among them — or the next codex
|
|
166
|
+
// boot stops at «hooks need review» (incident 18.10).
|
|
167
|
+
preSeedCodexHooksTrust(path, env)
|
|
164
168
|
return path
|
|
165
169
|
}
|
|
166
170
|
|
package/src/cli/index.ts
CHANGED
|
@@ -1439,9 +1439,13 @@ export async function renamePeerCli(
|
|
|
1439
1439
|
const { preTrustCodexCwd, removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
|
|
1440
1440
|
preTrustCodexCwd(newCwd, env)
|
|
1441
1441
|
removeCodexCwdTrust(oldCwd, env)
|
|
1442
|
-
const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
|
|
1442
|
+
const { removeCodexHooksTrustUnder, preSeedCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
|
|
1443
1443
|
removeCodexHooksTrustUnder(oldCwd, env)
|
|
1444
|
-
|
|
1444
|
+
// Hooks trust is keyed by the hooks.json PATH, so it must move with the cwd — without this a
|
|
1445
|
+
// rename round-trip (a→b→a) wiped it entirely and codex stopped at «hooks need review» (18.10).
|
|
1446
|
+
const newHooks = join(newCwd, '.codex', 'hooks.json')
|
|
1447
|
+
if (existsSync(newHooks)) preSeedCodexHooksTrust(newHooks, env)
|
|
1448
|
+
sideEffects.push('codex-trust: re-trusted new cwd (+ hooks) + cleaned old')
|
|
1445
1449
|
} catch (e) {
|
|
1446
1450
|
sideEffects.push(`codex-trust: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
1447
1451
|
}
|
|
@@ -3006,6 +3010,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
3006
3010
|
// see a dead session with no .new-eager → a plain reaped-gone (lazy fresh on the
|
|
3007
3011
|
// next message), not the eager relaunch — degrade gracefully, never lose the mark.
|
|
3008
3012
|
setNewEager(cfg, identity)
|
|
3013
|
+
// The peer ends ITS OWN turn: tell the daemon's OC07 sweep this session ended itself, so the
|
|
3014
|
+
// entries it had started are settled — not reported to their senders as INTERRUPTED.
|
|
3015
|
+
try {
|
|
3016
|
+
const { hostSessionToken } = await import('../launch/ptyHost.ts')
|
|
3017
|
+
const { markSelfEnded } = await import('../lifecycle/inflight.ts')
|
|
3018
|
+
const token = hostSessionToken(identity, env)
|
|
3019
|
+
if (token) markSelfEnded(cfg.stateDir, identity, token)
|
|
3020
|
+
} catch {
|
|
3021
|
+
/* best-effort: without it the sender merely gets a false INTERRUPTED notice */
|
|
3022
|
+
}
|
|
3009
3023
|
out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
|
|
3010
3024
|
const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
|
|
3011
3025
|
killSession(sock, identity, env)
|
package/src/daemon/main.ts
CHANGED
|
@@ -81,7 +81,7 @@ import { startGoalWatch } from './goalwatch.ts'
|
|
|
81
81
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
82
82
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
83
83
|
import { randomBytes } from 'crypto'
|
|
84
|
-
import { listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
84
|
+
import { clearSelfEnded, listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, readSelfEnded, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
85
85
|
import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
|
|
86
86
|
|
|
87
87
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -321,13 +321,17 @@ export async function processInflight(
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
for (const identity of listInflightIdentities(cfg.stateDir)) {
|
|
324
|
+
const selfEnded = readSelfEnded(cfg.stateDir, identity)
|
|
325
|
+
const current = hostSessionAlive(identity, env) ? hostSessionToken(identity, env) : null
|
|
324
326
|
const n = sweepInflight(cfg.stateDir, identity, {
|
|
325
|
-
sessionToken:
|
|
327
|
+
sessionToken: () => current,
|
|
328
|
+
selfEndedToken: selfEnded,
|
|
326
329
|
processed,
|
|
327
330
|
completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
|
|
328
331
|
landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
|
|
329
332
|
})
|
|
330
333
|
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
334
|
+
if (selfEnded && current !== selfEnded) clearSelfEnded(cfg.stateDir, identity) // that session is gone — settled
|
|
331
335
|
}
|
|
332
336
|
// A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
|
|
333
337
|
// effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
|
|
42
42
|
import { homedir } from 'os'
|
|
43
43
|
import { join } from 'path'
|
|
44
|
-
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
44
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
45
|
+
import { preSeedCodexHooksTrust } from '../codexHooksTrust.ts'
|
|
45
46
|
import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
46
47
|
import { detectNumberedModal } from './modalDetect.ts'
|
|
47
|
-
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
48
|
+
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, PreparedLaunch, RuntimeAdapter } from '../types.ts'
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* The `/goal` subcommands we will type into a live codex session (see executeControl → 'goal').
|
|
@@ -170,6 +171,27 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
170
171
|
kind: 'tui',
|
|
171
172
|
usesDoctrine: true,
|
|
172
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Hooks trust, re-asserted on EVERY launch (incident 18.10: oc-test-nemo/codex stopped at «PostToolUse
|
|
176
|
+
* hooks — 1 hook needs review» after a rename round-trip had wiped its `[hooks.state]` — a fresh codex
|
|
177
|
+
* that meets an untrusted hook waits for a human forever). The invariant «a codex peer boots to its
|
|
178
|
+
* input with no human» therefore lives HERE, not only at provision time: whatever erased the trust
|
|
179
|
+
* (rename, an approval-mode flip, a hand edit of config.toml), the next launch restores it. The peer's
|
|
180
|
+
* `.codex/hooks.json` is operator/provider-owned configuration, trusted like its `.mcp.json`.
|
|
181
|
+
* Idempotent (no write when the hashes already match); a failure is a launch WARNING, never a block —
|
|
182
|
+
* the modal is still better than no session, and the warning names it.
|
|
183
|
+
*/
|
|
184
|
+
prepareLaunch(spec, _baseEnv, cfg): PreparedLaunch {
|
|
185
|
+
const env = cfg.env ?? process.env
|
|
186
|
+
const hooks = join(spec.cwd, '.codex', 'hooks.json')
|
|
187
|
+
const warnings: string[] = []
|
|
188
|
+
if (existsSync(hooks)) {
|
|
189
|
+
const t = preSeedCodexHooksTrust(hooks, env)
|
|
190
|
+
if (t.state === 'failed') warnings.push(`codex hooks trust not seeded (${t.detail ?? 'unknown'}) — a review modal may block the boot`)
|
|
191
|
+
}
|
|
192
|
+
return { env: {}, ...(warnings.length ? { warnings } : {}) }
|
|
193
|
+
},
|
|
194
|
+
|
|
173
195
|
/**
|
|
174
196
|
* Delivery markers for submitIntoTui (docs/Рантайм codex "Доставка"):
|
|
175
197
|
* - promptGlyphs ['›'] — codex's input-row arrow (the same stable invariant as
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -1652,17 +1652,38 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1652
1652
|
export async function processEagerRelaunches(
|
|
1653
1653
|
cfg: LifecycleConfig,
|
|
1654
1654
|
outcomes: SuperviseOutcome[],
|
|
1655
|
-
deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult
|
|
1655
|
+
deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult>; sessionAliveFn?: (identity: string) => boolean } = {},
|
|
1656
1656
|
): Promise<WakeResult[]> {
|
|
1657
1657
|
const wake = deps.wakeFn ?? wakeOrSpawn
|
|
1658
|
+
const env = deps.env ?? process.env
|
|
1659
|
+
const alive = deps.sessionAliveFn ?? ((identity: string) => sessionAlive('', identity, env))
|
|
1658
1660
|
const results: WakeResult[] = []
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
+
const eager = outcomes.filter(o => o.action === 'needs-eager-fresh' && !!o.personality)
|
|
1662
|
+
for (const o of eager) {
|
|
1661
1663
|
clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
|
|
1664
|
+
// WHICH endpoint comes back. Two invariants meet here:
|
|
1665
|
+
// • H5 fleet-switch (12.06): a peer whose ONLY session self-freshes after `default-runtime` was
|
|
1666
|
+
// flipped must come back on the NEW default → re-resolve (no runtime pinned).
|
|
1667
|
+
// • Parallel endpoints (18.10): a peer live on several runtimes at once whose NON-default
|
|
1668
|
+
// endpoint self-freshes must get THAT endpoint back — re-resolving woke the default (already
|
|
1669
|
+
// live → no-op) and the claude endpoint of oc-test-nemo lay down.
|
|
1670
|
+
// So: pin the dead endpoint's runtime when it is the default, or when the peer keeps another
|
|
1671
|
+
// session (live now, or relaunched in this same batch); otherwise it was the peer's only
|
|
1672
|
+
// session → re-resolve to the registry default.
|
|
1673
|
+
let runtime: string | undefined
|
|
1674
|
+
try {
|
|
1675
|
+
const rec = findPeer(readPeersIndex({ env }), o.personality!)
|
|
1676
|
+
const others = (rec?.runtimes ?? []).filter(rt => rt !== o.runtime)
|
|
1677
|
+
const otherAlive = others.some(rt => alive(`${rt}-${o.personality}`))
|
|
1678
|
+
const otherInBatch = eager.some(x => x !== o && x.personality === o.personality)
|
|
1679
|
+
if (o.runtime && (o.runtime === rec?.runtime || otherAlive || otherInBatch)) runtime = o.runtime
|
|
1680
|
+
} catch {
|
|
1681
|
+
runtime = undefined // registry unreadable → the H5 default path
|
|
1682
|
+
}
|
|
1662
1683
|
try {
|
|
1663
1684
|
results.push(
|
|
1664
1685
|
await wake(
|
|
1665
|
-
{ personality: o.personality, task: '', resume: false },
|
|
1686
|
+
{ personality: o.personality!, ...(runtime ? { runtime } : {}), task: '', resume: false },
|
|
1666
1687
|
{ cfg, env: deps.env },
|
|
1667
1688
|
),
|
|
1668
1689
|
)
|
|
@@ -87,6 +87,9 @@ export function readInflight(stateDir: string, identity: string): InflightEntry[
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export interface InflightSweepDeps {
|
|
90
|
+
/** Hosting token of a session that ENDED ITSELF (self-fresh) — its started entries are settled, not
|
|
91
|
+
* reported interrupted. Absent → none. */
|
|
92
|
+
selfEndedToken?: string | null
|
|
90
93
|
/** Current hosting token of the identity's session (null = no live session). */
|
|
91
94
|
sessionToken: (identity: string) => string | null
|
|
92
95
|
/** Did the receiving session answer this entry? */
|
|
@@ -138,6 +141,7 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
138
141
|
// Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
|
|
139
142
|
// may have happened — never redelivered, the sender is told).
|
|
140
143
|
if (current !== null && current === e.sessionToken) keep.push(e)
|
|
144
|
+
else if (deps.selfEndedToken && e.sessionToken === deps.selfEndedToken) continue // the peer ended it itself
|
|
141
145
|
else interrupted.push(e)
|
|
142
146
|
continue
|
|
143
147
|
}
|
|
@@ -207,3 +211,29 @@ export function purgeInflight(stateDir: string, identity: string): void {
|
|
|
207
211
|
rmSync(orphansPath(stateDir, identity), { force: true })
|
|
208
212
|
rmSync(interruptedPath(stateDir, identity), { force: true })
|
|
209
213
|
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
const selfEndedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.self-ended`)
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* self-fresh: the peer's OWN turn declares its work done and ends its session. The CLI only drops this
|
|
220
|
+
* marker (the hosting token of the session that chose to end); the DAEMON's sweep — the single writer
|
|
221
|
+
* of the ledger — then closes that session's started entries instead of reporting them INTERRUPTED
|
|
222
|
+
* (false: the peer itself ended; live parity run 18.10). Not-yet-started entries are untouched and are
|
|
223
|
+
* redelivered to the fresh session as usual. No read-modify-write of the ledger outside the daemon.
|
|
224
|
+
*/
|
|
225
|
+
export function markSelfEnded(stateDir: string, identity: string, sessionToken: string): void {
|
|
226
|
+
writeFileSync(selfEndedPath(stateDir, identity), `${sessionToken}\n`, { mode: 0o600 })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function readSelfEnded(stateDir: string, identity: string): string | null {
|
|
230
|
+
try {
|
|
231
|
+
return readFileSync(selfEndedPath(stateDir, identity), 'utf8').trim() || null
|
|
232
|
+
} catch {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function clearSelfEnded(stateDir: string, identity: string): void {
|
|
238
|
+
rmSync(selfEndedPath(stateDir, identity), { force: true })
|
|
239
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -913,6 +913,13 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
913
913
|
* opencode — an assistant message for that user message with `time.completed` and a finish other
|
|
914
914
|
* than `tool-calls`.
|
|
915
915
|
*/
|
|
916
|
+
/** claude's own record of an interrupted turn (user line whose text is the interrupt marker). */
|
|
917
|
+
function isInterruptMarkerLine(o: { message?: unknown }): boolean {
|
|
918
|
+
const c = (o.message as { content?: unknown } | undefined)?.content
|
|
919
|
+
const texts = typeof c === 'string' ? [c] : Array.isArray(c) ? c.filter(b => b?.type === 'text').map(b => String(b.text ?? '')) : []
|
|
920
|
+
return texts.some(t => /^\[Request interrupted by user/.test(t.trim()))
|
|
921
|
+
}
|
|
922
|
+
|
|
916
923
|
export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
917
924
|
const needles = confirmNeedles(probe.rendered)
|
|
918
925
|
if (!needles.length || needles.every(n => !n)) return false
|
|
@@ -968,6 +975,12 @@ export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
968
975
|
}
|
|
969
976
|
if (o.type === 'assistant' && ['end_turn', 'stop_sequence', 'max_tokens'].includes(o.message?.stop_reason ?? '')) return true
|
|
970
977
|
if (o.type === 'event_msg' && o.payload?.type === 'task_complete') return true
|
|
978
|
+
// An operator INTERRUPT ends the turn too (claude: a `[Request interrupted by user…]` user line,
|
|
979
|
+
// codex: `turn_aborted`). Not counting it, a turn the owner had stopped was later reported to its
|
|
980
|
+
// sender as «INTERRUPTED by the session's death» once the session died for an unrelated reason
|
|
981
|
+
// (live parity run 18.10). OpenCode's abort already closes the assistant message, so it was right.
|
|
982
|
+
if (o.type === 'event_msg' && o.payload?.type === 'turn_aborted') return true
|
|
983
|
+
if (o.type === 'user' && isInterruptMarkerLine(o)) return true
|
|
971
984
|
}
|
|
972
985
|
}
|
|
973
986
|
return false
|