@agfpd/iapeer 0.4.119 → 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.119",
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 {
@@ -1080,12 +1123,31 @@ export interface ControlResult {
1080
1123
  * "specify runtime"). The clean-slash control namespace (interrupt/compact/…) is the
1081
1124
  * caller's; /alias_* expansions are message, not control (docs/Control §namespace).
1082
1125
  */
1126
+ /**
1127
+ * Which session an operator control (interrupt / compact / …) WITHOUT a runtime acts on. An operator
1128
+ * commands the DIALOGUE they see, not a session they must name: the dialogue a runtime-less send
1129
+ * reaches is the peer's default_runtime (config-anchored, resolvePeerDeliveryTarget), so a LIVE
1130
+ * default wins even when other runtimes are live too (incident 18.10: /stop on a peer with claude AND
1131
+ * opencode live answered «specify runtime» while the opencode turn ran on). A dead default with
1132
+ * exactly one live endpoint → that one (the only running session). Only a dead default with SEVERAL
1133
+ * live endpoints stays ambiguous → explicit refusal. An explicit runtime is exact.
1134
+ */
1135
+ export function resolveControlTarget(personality: string, runtime: string | undefined, env: NodeJS.ProcessEnv = process.env): Result<DeliveryTarget> {
1136
+ if (runtime) return resolveDeliveryTarget({ personality, runtime, env })
1137
+ const peer = findPeer(readPeersIndex({ env }), personality)
1138
+ if (peer) {
1139
+ const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env })
1140
+ if (anchored.ok) return anchored
1141
+ }
1142
+ return resolveDeliveryTarget({ personality, env })
1143
+ }
1144
+
1083
1145
  export async function routeControl(personality: string, runtime: string | undefined, command: ControlCommand): Promise<Result<ControlResult>> {
1084
1146
  if (!isValidName(personality)) {
1085
1147
  return err(`invalid personality "${personality}" — must match /^[a-z][a-z0-9-]{0,31}$/`)
1086
1148
  }
1087
1149
  if (runtime && !isRuntime(runtime)) return err(`invalid runtime "${runtime}"`)
1088
- const target = resolveDeliveryTarget({ personality, runtime })
1150
+ const target = resolveControlTarget(personality, runtime)
1089
1151
  if (!target.ok) return err(`cannot control "${personality}": ${target.error.message}`)
1090
1152
  const done = await executeControlOnTarget(target.value, command)
1091
1153
  if (!done.ok) return done
@@ -1125,6 +1187,10 @@ export interface RouteResult {
1125
1187
  queuedBy?: 'ephemeral' | 'composer'
1126
1188
  /** Queue depth right after the enqueue (observability; only with queued). */
1127
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
1128
1194
  }
1129
1195
 
1130
1196
  // WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
@@ -1274,7 +1340,7 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
1274
1340
  }
1275
1341
  }
1276
1342
 
1277
- 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 }
1278
1344
 
1279
1345
  /** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
1280
1346
  function noteInflight(
@@ -1299,6 +1365,7 @@ function noteInflight(
1299
1365
  ...(info.topic ? { topic: info.topic } : {}),
1300
1366
  ...(info.attachments?.length ? { attachments: info.attachments } : {}),
1301
1367
  rendered: renderEnvelopeForAgent(info.envelope),
1368
+ ...(info.unconfirmed ? { unconfirmed: true as const } : {}),
1302
1369
  })
1303
1370
  } catch {
1304
1371
  /* a post-delivery hook must never fail the delivery */
@@ -1771,16 +1838,26 @@ async function routeSendInner(
1771
1838
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
1772
1839
  // deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
1773
1840
  // socket, any other target keeps the tmux path. Flag-off → byte-identical to deliverViaTmux.
1774
- 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
+ )
1775
1851
  if (!delivered.ok) return delivered
1776
1852
  noteTopic(deps, target.value.address, topic)
1777
1853
  noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
1778
- noteInflight(deps, target.value, peer.cwd, inflightInfo, env)
1854
+ noteInflight(deps, target.value, peer.cwd, { ...inflightInfo, unconfirmed }, env)
1779
1855
  return ok({
1780
1856
  ok: true,
1781
1857
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
1782
1858
  woke: false,
1783
1859
  ts: sentAt,
1860
+ ...(unconfirmed ? { unconfirmed: true as const } : {}),
1784
1861
  })
1785
1862
  }
1786
1863