@agfpd/iapeer 0.4.120 → 0.4.121

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.
@@ -175,6 +175,14 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
175
175
  (lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
176
176
  (delivery.log).
177
177
  - A false «not processed» costs a visible duplicate, never a silent loss.
178
+ - Unknown is not failed. When a live receiver's acceptance record does not appear
179
+ within the confirm window (a busy claude records a queued message late), the
180
+ send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
181
+ sender must not resend. The ledger keeps the entry marked unconfirmed: once any
182
+ record carrying the envelope appears (including a claude `queue-operation`) it
183
+ is an ordinary pending entry; if none appears within 120 s the paste was
184
+ swallowed, and the ledger redelivers it. Each message is redelivered at most
185
+ once; a second miss becomes an explicit notice to the sender.
178
186
 
179
187
  The real death cause is recorded too: the supervisor waits for the reap, so a
180
188
  SIGKILL is logged as `dead_signal=SIGKILL`, never as a clean exit 0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.120",
3
+ "version": "0.4.121",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -900,6 +900,117 @@ export async function compactPeer(
900
900
  // ephemeral zombie records a retired spawn leaves behind.
901
901
  // ─────────────────────────────────────────────────────────────────────────────
902
902
 
903
+ // ─────────────────────────────────────────────────────────────────────────────
904
+ // remove-runtime — the symmetric undo of add-runtime (one endpoint, not the peer)
905
+ // ─────────────────────────────────────────────────────────────────────────────
906
+
907
+ export interface RemoveRuntimeOutcome {
908
+ personality: string
909
+ runtime: string
910
+ action: 'removed' | 'absent' | 'refused-default' | 'refused-last' | 'refused-live' | 'refused-infra'
911
+ reason?: string
912
+ /** What was undone, one line per surface. */
913
+ steps?: string[]
914
+ }
915
+
916
+ /**
917
+ * Detach ONE runtime from a peer (OC14-2 — the pilot rollback of `add-runtime --all`). Undoes what
918
+ * add-runtime did for that endpoint, in this order: refuse unsafe cases (the default runtime, the last
919
+ * runtime, a live session unless --force, an infra channel — its plist teardown is `remove`'s job);
920
+ * unprovision the memory slot and every capability for THIS runtime (occasion=remove); runtime cleanup
921
+ * (opencode: its core private dir; codex: cwd trust); park the cwd discovery markers (`.claude`/`.codex`
922
+ * and `.iapeer/runtimes/<rt>`) as `<name>.removed-<ts>` so re-provision does not rediscover the
923
+ * runtime — data is kept, nothing is deleted from the peer folder; drop it from the local profile
924
+ * (source of truth) and re-project the registry; purge the endpoint's lifecycle state.
925
+ */
926
+ export async function removeRuntimeCli(
927
+ runtime: string,
928
+ opts: CliEnvOptions & { peer: string; force?: boolean },
929
+ ): Promise<RemoveRuntimeOutcome> {
930
+ const env = opts.env ?? process.env
931
+ const personality = opts.peer
932
+ const peer = findPeer(readPeersIndex({ env }), personality)
933
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
934
+ if (!peer.runtimes.includes(runtime)) return { personality, runtime, action: 'absent', reason: `"${personality}" does not declare ${runtime}` }
935
+ if (isInfraRuntime(runtime)) {
936
+ return { personality, runtime, action: 'refused-infra', reason: `"${runtime}" is an always-on channel (launchd plist) — not supported by remove-runtime yet` }
937
+ }
938
+ if (peer.runtimes.length === 1) {
939
+ return { personality, runtime, action: 'refused-last', reason: `${runtime} is the only runtime of "${personality}" — use \`iapeer remove ${personality}\`` }
940
+ }
941
+ if (peer.runtime === runtime) {
942
+ return {
943
+ personality,
944
+ runtime,
945
+ action: 'refused-default',
946
+ reason: `${runtime} is the default runtime of "${personality}" — switch it first: iapeer default-runtime <other> --peer ${personality}`,
947
+ }
948
+ }
949
+ const cfg = loadLifecycleConfig(env)
950
+ const identity = buildProcessAddress(runtime as Runtime, personality)
951
+ const steps: string[] = []
952
+ if (isPeerLive(runtime, personality, cfg.sockDir, env)) {
953
+ if (!opts.force) {
954
+ return { personality, runtime, action: 'refused-live', reason: `${identity} is LIVE — stop it first (iapeer stop ${personality} ${runtime}) or pass --force` }
955
+ }
956
+ const { killPtyHost } = await import('../launch/ptyHost.ts')
957
+ killPtyHost(identity, env)
958
+ steps.push('live session stopped (--force)')
959
+ }
960
+ // Slots for THIS endpoint (provider + capabilities), while the peer's state is still consistent.
961
+ try {
962
+ const slot = readMemoryProvider(env)
963
+ if (slot?.unprovision) {
964
+ const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
965
+ const o = runProvisionCommand({ block: slot.unprovision, cwd: peer.cwd, runtime, personality, occasion: 'remove', env })
966
+ steps.push(`memory unprovision: ${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
967
+ }
968
+ } catch (e) {
969
+ steps.push(`memory unprovision: failed (${e instanceof Error ? e.message : String(e)})`)
970
+ }
971
+ try {
972
+ const { runCapabilityLifecycle } = await import('../capability/index.ts')
973
+ for (const run of runCapabilityLifecycle({ kind: 'unprovision', personality, cwd: peer.cwd, runtimes: [runtime], occasion: 'remove', env })) {
974
+ steps.push(`capability ${run.capability} unprovision: ${run.outcome.state}`)
975
+ }
976
+ } catch (e) {
977
+ steps.push(`capabilities: failed (${e instanceof Error ? e.message : String(e)})`)
978
+ }
979
+ if (runtime === 'opencode') {
980
+ const { opencodePaths } = await import('../opencode/config.ts')
981
+ const dir = opencodePaths(personality, { env }).dir
982
+ if (existsSync(dir)) {
983
+ rmSync(dir, { recursive: true, force: true })
984
+ steps.push(`opencode private dir removed: ${dir}`)
985
+ }
986
+ }
987
+ if (runtime === 'codex') {
988
+ try {
989
+ const { removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
990
+ steps.push(`codex cwd trust: ${removeCodexCwdTrust(peer.cwd, env).state}`)
991
+ } catch (e) {
992
+ steps.push(`codex cwd trust: failed (${e instanceof Error ? e.message : String(e)})`)
993
+ }
994
+ }
995
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '')
996
+ for (const marker of [join(peer.cwd, `.${runtime}`), join(peer.cwd, '.iapeer', 'runtimes', runtime)]) {
997
+ if (!existsSync(marker)) continue
998
+ const parked = `${marker}.removed-${stamp}`
999
+ renameSync(marker, parked)
1000
+ steps.push(`marker parked (data kept): ${parked}`)
1001
+ }
1002
+ const profile = readPeerProfile(peer.cwd)
1003
+ if (profile) {
1004
+ writePeerProfileAtomic(peer.cwd, { ...profile, runtimes: profile.runtimes.filter(r => r !== runtime) })
1005
+ steps.push('local profile: runtime removed')
1006
+ }
1007
+ const { reindexFromLocals } = await import('../identity/profileStandard.ts')
1008
+ await reindexFromLocals({ env })
1009
+ const purged = purgeIdentityState(cfg, identity)
1010
+ if (purged.length) steps.push(`lifecycle state purged: ${purged.join(', ')}`)
1011
+ return { personality, runtime, action: 'removed', steps }
1012
+ }
1013
+
903
1014
  export interface RemoveOutcome {
904
1015
  personality: string
905
1016
  action: 'removed' | 'absent' | 'refused-live' | 'refused-foreign-launchd'
@@ -1605,6 +1716,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1605
1716
  sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
1606
1717
  desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
1607
1718
  },
1719
+ { sig: 'remove-runtime <runtime> --peer <p> [--force]', desc: 'detach ONE agentic runtime from a peer (the undo of add-runtime): unprovision memory + capabilities for it, runtime cleanup (opencode private dir, codex trust), park its cwd markers as *.removed-<ts> (data kept), drop it from the profile + registry, purge its lifecycle state. Refuses the default runtime, the last runtime, a live session (unless --force) and infra channels' },
1608
1720
  { sig: 'private-dir <peer> [--runtime r]', desc: 'print the core-owned private directory of a peer endpoint (docs/20: opencode journal/state/fragments). The ONLY way a process not launched by the core gets the path — never derive the layout. Without --runtime: the single declared runtime that has one. Exit 1 when none' },
1609
1721
  { sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
1610
1722
  { sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
@@ -2310,6 +2422,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2310
2422
  out(`${endpointPrivateDir(candidates[0]!, name, { env })}\n`)
2311
2423
  return 0
2312
2424
  }
2425
+ case 'remove-runtime': {
2426
+ const rt = positionals[0]
2427
+ const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
2428
+ if (!rt || !peerName) return argErr(errOut, 'remove-runtime needs a runtime and a peer — usage: iapeer remove-runtime <runtime> --peer <p> [--force]')
2429
+ const o = await removeRuntimeCli(rt, { peer: peerName, force: flags.force === true, env })
2430
+ out(`${o.personality}: ${o.runtime} ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
2431
+ for (const step of o.steps ?? []) out(` ${step}\n`)
2432
+ return o.action === 'removed' || o.action === 'absent' ? 0 : 1
2433
+ }
2313
2434
  case 'default-runtime': {
2314
2435
  // The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
2315
2436
  const rt = positionals[0]
@@ -56,6 +56,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
56
56
  import { findPeer, readPeersIndex } from '../registry/index.ts'
57
57
  import {
58
58
  createComposerDeliveryQueue,
59
+ deliveryLanded,
59
60
  deliveryProcessed,
60
61
  routeSend,
61
62
  type InflightNote,
@@ -265,8 +266,10 @@ export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) =>
265
266
  }
266
267
 
267
268
  /** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
269
+ const REDELIVERY_PREFIX = '[redelivered by iapeer:'
268
270
  export function redeliveryMessage(e: InflightEntry): string {
269
- return `[redelivered by iapeer: the receiving session ended before answering this message; originally sent ${e.sentAt}]\n${e.message}`
271
+ const why = e.unconfirmed ? 'it never reached the receiving session' : 'the receiving session ended before answering this message'
272
+ return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
270
273
  }
271
274
 
272
275
  /** Body of the explicit non-delivery notice to the original sender. */
@@ -289,7 +292,7 @@ export async function processInflight(
289
292
  cfg: LifecycleConfig,
290
293
  env: NodeJS.ProcessEnv,
291
294
  routeDeps: RouteDeps,
292
- seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
295
+ seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean } = {},
293
296
  ): Promise<void> {
294
297
  const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
295
298
  const send = seam.send ?? routeSend
@@ -297,6 +300,7 @@ export async function processInflight(
297
300
  const n = sweepInflight(cfg.stateDir, identity, {
298
301
  sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
299
302
  processed,
303
+ landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
300
304
  })
301
305
  if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
302
306
  }
@@ -305,6 +309,9 @@ export async function processInflight(
305
309
  const index = readPeersIndex({ env })
306
310
  let reason: string
307
311
  try {
312
+ // ONE redelivery per message: an already-redelivered message that orphans again goes to the
313
+ // sender as an explicit notice instead of looping.
314
+ if (e.message.startsWith(REDELIVERY_PREFIX)) throw new Error('already redelivered once and still not processed')
308
315
  const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
309
316
  const r = await send(
310
317
  caller,
@@ -32,8 +32,14 @@ export interface InflightEntry {
32
32
  attachments?: string[]
33
33
  /** The presented envelope (what the model sees) — the processed-check needle source. */
34
34
  rendered: string
35
+ /** The live confirm window ran out at delivery: whether it LANDED is still unknown. */
36
+ unconfirmed?: true
35
37
  }
36
38
 
39
+ /** How long an unconfirmed delivery may stay unlanded in a live session before the ledger treats the
40
+ * paste as swallowed and redelivers it (once). Generous: a busy receiver records a queued message late. */
41
+ export const UNLANDED_AFTER_MS = 120_000
42
+
37
43
  const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
38
44
  const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
39
45
 
@@ -84,6 +90,9 @@ export interface InflightSweepDeps {
84
90
  sessionToken: (identity: string) => string | null
85
91
  /** Did the receiving session answer this entry? */
86
92
  processed: (entry: InflightEntry) => boolean
93
+ /** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
94
+ landed?: (entry: InflightEntry) => boolean
95
+ now?: number
87
96
  }
88
97
 
89
98
  /**
@@ -105,8 +114,23 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
105
114
  done = false
106
115
  }
107
116
  if (done) continue
108
- if (current !== null && current === e.sessionToken) keep.push(e)
109
- else orphaned.push(e)
117
+ if (current !== null && current === e.sessionToken) {
118
+ // Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
119
+ // still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
120
+ if (e.unconfirmed && deps.landed) {
121
+ let landed = false
122
+ try {
123
+ landed = deps.landed(e)
124
+ } catch {
125
+ landed = false
126
+ }
127
+ if (landed) keep.push({ ...e, unconfirmed: undefined })
128
+ else if ((deps.now ?? Date.now()) - e.ts > UNLANDED_AFTER_MS) orphaned.push(e)
129
+ else keep.push(e)
130
+ continue
131
+ }
132
+ keep.push(e)
133
+ } else orphaned.push(e)
110
134
  }
111
135
  writeJsonl(inflightPath(stateDir, identity), keep)
112
136
  if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
@@ -294,6 +294,13 @@ export interface WarmDeliverSeam {
294
294
  * the real `resubmitHosted` (a bare CR over the control channel). Tests inject a spy to assert the
295
295
  * retry fires only while unconfirmed — and never once the record is in. */
296
296
  resubmit?: (identity: string) => Promise<void>
297
+ /** UNKNOWN ≠ FAILED (incident 18.10: a busy receiver's transcript record landed after the grace,
298
+ * the sender got «NOT delivered», believed it and sent a duplicate). When set, a confirm grace that
299
+ * runs out on a LIVE session is reported as UNCONFIRMED — `onUnconfirmed()` fires and the delivery
300
+ * returns ok — and the inflight ledger, not the sender, owns any retry (docs/20 §Durable inbound).
301
+ * Unset (default): the strict legacy false-FAIL. */
302
+ allowUnconfirmed?: boolean
303
+ onUnconfirmed?: () => void
297
304
  }
298
305
 
299
306
  /**
@@ -445,6 +452,10 @@ async function deliverViaHost(
445
452
  }
446
453
  await sleep(LIVENESS_POLL_MS)
447
454
  }
455
+ if (seam.allowUnconfirmed) {
456
+ seam.onUnconfirmed?.()
457
+ return ok(undefined)
458
+ }
448
459
  return err(
449
460
  `hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
450
461
  `(no transcript record carrying the message within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
@@ -892,6 +903,36 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
892
903
  return false
893
904
  }
894
905
 
906
+ /**
907
+ * Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
908
+ * queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being
909
+ * worked on» from «the paste was swallowed and nothing will ever see it». Structural runtimes (opencode)
910
+ * always landed: their delivery ack is a native persistence ack.
911
+ */
912
+ export function deliveryLanded(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
913
+ if (getAdapter(probe.runtime).deliveryConfirm === 'structural') return true
914
+ const needles = confirmNeedles(probe.rendered)
915
+ if (!needles.length || needles.every(n => !n)) return false
916
+ for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
917
+ let st
918
+ try {
919
+ st = statSync(path)
920
+ } catch {
921
+ continue
922
+ }
923
+ if (st.mtimeMs < probe.ts - 5000) continue
924
+ for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
925
+ if (!line.trim()) continue
926
+ try {
927
+ if (jsonStringValuesContainAll(JSON.parse(line), needles)) return true
928
+ } catch {
929
+ /* skip */
930
+ }
931
+ }
932
+ }
933
+ return false
934
+ }
935
+
895
936
  /** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
896
937
  export interface InflightNote {
897
938
  sentAt: string
@@ -905,6 +946,8 @@ export interface InflightNote {
905
946
  topic?: string
906
947
  attachments?: string[]
907
948
  rendered: string
949
+ /** The live confirm window ran out (see WarmDeliverSeam.allowUnconfirmed). */
950
+ unconfirmed?: true
908
951
  }
909
952
 
910
953
  function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
@@ -1144,6 +1187,10 @@ export interface RouteResult {
1144
1187
  queuedBy?: 'ephemeral' | 'composer'
1145
1188
  /** Queue depth right after the enqueue (observability; only with queued). */
1146
1189
  queueDepth?: number
1190
+ /** The bytes reached the live session but its acceptance record did not appear within the confirm
1191
+ * window (e.g. a busy receiver). NOT a failure: do not resend — the core's inflight ledger checks
1192
+ * it landed and redelivers once (or tells you) if it did not. */
1193
+ unconfirmed?: true
1147
1194
  }
1148
1195
 
1149
1196
  // WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
@@ -1293,7 +1340,7 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
1293
1340
  }
1294
1341
  }
1295
1342
 
1296
- type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
1343
+ type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string; unconfirmed?: boolean }
1297
1344
 
1298
1345
  /** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
1299
1346
  function noteInflight(
@@ -1318,6 +1365,7 @@ function noteInflight(
1318
1365
  ...(info.topic ? { topic: info.topic } : {}),
1319
1366
  ...(info.attachments?.length ? { attachments: info.attachments } : {}),
1320
1367
  rendered: renderEnvelopeForAgent(info.envelope),
1368
+ ...(info.unconfirmed ? { unconfirmed: true as const } : {}),
1321
1369
  })
1322
1370
  } catch {
1323
1371
  /* a post-delivery hook must never fail the delivery */
@@ -1790,16 +1838,26 @@ async function routeSendInner(
1790
1838
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
1791
1839
  // deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
1792
1840
  // socket, any other target keeps the tmux path. Flag-off → byte-identical to deliverViaTmux.
1793
- const delivered = await deliverWarm(target.value, envelope, peer.cwd, {}, env)
1841
+ // Unknown ≠ failed: a confirm window that runs out on a LIVE session is reported unconfirmed and the
1842
+ // inflight ledger owns the retry — only when that ledger is wired (deps.noteInflight), else strict.
1843
+ let unconfirmed = false
1844
+ const delivered = await deliverWarm(
1845
+ target.value,
1846
+ envelope,
1847
+ peer.cwd,
1848
+ deps.noteInflight ? { allowUnconfirmed: true, onUnconfirmed: () => (unconfirmed = true) } : {},
1849
+ env,
1850
+ )
1794
1851
  if (!delivered.ok) return delivered
1795
1852
  noteTopic(deps, target.value.address, topic)
1796
1853
  noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
1797
- noteInflight(deps, target.value, peer.cwd, inflightInfo, env)
1854
+ noteInflight(deps, target.value, peer.cwd, { ...inflightInfo, unconfirmed }, env)
1798
1855
  return ok({
1799
1856
  ok: true,
1800
1857
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
1801
1858
  woke: false,
1802
1859
  ts: sentAt,
1860
+ ...(unconfirmed ? { unconfirmed: true as const } : {}),
1803
1861
  })
1804
1862
  }
1805
1863