@agfpd/iapeer 0.4.120 → 0.4.122

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.
@@ -81,6 +81,7 @@ Field notes:
81
81
  - `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
82
82
  - `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
83
83
  - `runtimes[].model` — the `provider/model` the endpoint ACTUALLY used for its last assistant turn, as the runtime itself reports it (today `opencode`, from its state; docs/20 §Provider pinning). Absent until a first turn. Lets an operator see an implicit default provider. Additive.
84
+ - `runtimes[].warnings` — the endpoint's current-session launch self-check findings (today `opencode`: a missing or failed plugin, an MCP server not connected; docs/20 §Launch self-check). Absent when clean. Additive.
84
85
  - `host.agent_runtimes` — the agent runtimes this core build can host, in preference order. It grows when an agent adapter ships (e.g. `opencode`, docs/20).
85
86
  - `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
86
87
  - `runtimes[].activity` — **turn activity**: is this runtime *in a turn* right now. See «Activity» below. Additive; absent on a pre-activity daemon, on every non-`live` runtime, and whenever the daemon has no evidence — clients MUST treat absence as **no claim**, never as `idle`.
@@ -123,6 +123,23 @@ Open core contracts this ABI depends on:
123
123
  provision/unprovision per peer and runtime, like the memory slot. Until it
124
124
  exists, a manual install is not reported as final.
125
125
 
126
+ ## Launch self-check (OC04)
127
+
128
+ OpenCode tolerates some broken surfaces silently: a fragment plugin whose file
129
+ is missing or that throws on load, and an MCP server that does not connect
130
+ (e.g. an unreachable memory MCP), all still give a «working» session with the
131
+ surface absent. Before it reports readiness, the core plugin checks:
132
+ - every MCP server's native status (`connected`/`disabled` are fine);
133
+ - every `file://` plugin in the effective config exists;
134
+ - the native log (`<XDG_DATA_HOME>/opencode/log/opencode.log`, lines of THIS
135
+ process found through a `iapeer-selfcheck <launchID>` marker line) for
136
+ `failed to load plugin`.
137
+ Findings are never a refusal (the session is usable) and never silent:
138
+ `runtime.ready.properties.warnings` (additive), `state.json` `warnings`,
139
+ `ev=launch-warning` in lifecycle.log per warning, and the fleet field
140
+ `runtimes[].warnings` (docs/15). Native run of 2026-10-18: unreachable MCP,
141
+ missing plugin and throwing plugin each reported.
142
+
126
143
  ## Provider pinning
127
144
 
128
145
  Without an explicit `model`, native OpenCode uses its own hosted default
@@ -175,6 +192,23 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
175
192
  (lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
176
193
  (delivery.log).
177
194
  - A false «not processed» costs a visible duplicate, never a silent loss.
195
+ - Interrupted turns. «Processed» (a model response began) is not «finished»: a
196
+ turn killed mid-tool may already have had side effects, so it is never
197
+ redelivered. The ledger keeps an entry until its turn finishes (claude: an
198
+ assistant record with a terminal `stop_reason`; codex: `task_complete`;
199
+ opencode: a completed assistant message for it with a finish other than
200
+ `tool-calls`); a finished later message covers earlier ones. If the session
201
+ dies with the turn unfinished, the sender gets an explicit notice from iapeer
202
+ («the turn was INTERRUPTED and its result is not guaranteed»), logged as
203
+ `ev=inflight-interrupted`.
204
+ - Unknown is not failed. When a live receiver's acceptance record does not appear
205
+ within the confirm window (a busy claude records a queued message late), the
206
+ send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
207
+ sender must not resend. The ledger keeps the entry marked unconfirmed: once any
208
+ record carrying the envelope appears (including a claude `queue-operation`) it
209
+ is an ordinary pending entry; if none appears within 120 s the paste was
210
+ swallowed, and the ledger redelivers it. Each message is redelivered at most
211
+ once; a second miss becomes an explicit notice to the sender.
178
212
 
179
213
  The real death cause is recorded too: the supervisor waits for the reap, so a
180
214
  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.122",
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]
@@ -94,6 +94,9 @@ export interface FleetRuntimeStatus {
94
94
  /** The model the runtime ACTUALLY used last (`provider/model`, from the endpoint's own state; docs/20
95
95
  * §Provider pinning). Absent until a first assistant turn, or for runtimes that do not report it. */
96
96
  model?: string
97
+ /** Launch self-check warnings of the endpoint's current session (docs/20 OC04): a missing or failed
98
+ * plugin, an MCP server not connected. Absent when clean. */
99
+ warnings?: string[]
97
100
  /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
98
101
  attached?: boolean
99
102
  /** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
@@ -245,11 +248,17 @@ export function buildFleetSnapshot(
245
248
  * never emitted, which is exactly its "no claim" semantics. */
246
249
  activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
247
250
  ): FleetSnapshot {
248
- const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string } => {
251
+ const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string; warnings?: string[] } => {
249
252
  const d = endpointPrivateDir(runtime, personality, { env })
250
253
  if (!d) return {}
251
- const model = (readOpencodeState(join(d, 'state.json')) as { model?: unknown } | null)?.model
252
- return { private_dir: d, ...(typeof model === 'string' && model ? { model } : {}) }
254
+ const st = readOpencodeState(join(d, 'state.json'))
255
+ const model = st?.model
256
+ const warnings = Array.isArray(st?.warnings) ? st!.warnings!.filter(w => typeof w === 'string') : []
257
+ return {
258
+ private_dir: d,
259
+ ...(typeof model === 'string' && model ? { model } : {}),
260
+ ...(warnings.length ? { warnings } : {}),
261
+ }
253
262
  }
254
263
  const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
255
264
  const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
@@ -56,6 +56,8 @@ import type { ResolvedCaller } from '../identity/index.ts'
56
56
  import { findPeer, readPeersIndex } from '../registry/index.ts'
57
57
  import {
58
58
  createComposerDeliveryQueue,
59
+ deliveryCompleted,
60
+ deliveryLanded,
59
61
  deliveryProcessed,
60
62
  routeSend,
61
63
  type InflightNote,
@@ -77,7 +79,7 @@ import { startGoalWatch } from './goalwatch.ts'
77
79
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
78
80
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
79
81
  import { randomBytes } from 'crypto'
80
- import { listInflightIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
82
+ import { listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
81
83
  import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
82
84
 
83
85
  /** Default TCP loopback port for the always-on router. Real http MCP clients
@@ -265,8 +267,20 @@ export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) =>
265
267
  }
266
268
 
267
269
  /** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
270
+ const REDELIVERY_PREFIX = '[redelivered by iapeer:'
268
271
  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}`
272
+ const why = e.unconfirmed ? 'it never reached the receiving session' : 'the receiving session ended before answering this message'
273
+ return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
274
+ }
275
+
276
+ /** Body of the interrupted-turn notice (OC07 extension): processed, but the turn died mid-work. */
277
+ export function interruptedNotice(e: InflightEntry): string {
278
+ return (
279
+ `iapeer: your message to "${e.personality}" (${e.runtime}), sent ${e.sentAt}${e.topic ? ` topic=${e.topic}` : ''}, ` +
280
+ `was being worked on when the receiving session died — the turn was INTERRUPTED and its result is not guaranteed ` +
281
+ `(it is NOT redelivered: side effects may already have happened). Check its state and resend if needed. ` +
282
+ `First line: ${e.message.split('\n')[0]!.slice(0, 200)}`
283
+ )
270
284
  }
271
285
 
272
286
  /** Body of the explicit non-delivery notice to the original sender. */
@@ -289,22 +303,48 @@ export async function processInflight(
289
303
  cfg: LifecycleConfig,
290
304
  env: NodeJS.ProcessEnv,
291
305
  routeDeps: RouteDeps,
292
- seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
306
+ seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean; completed?: (e: InflightEntry) => boolean } = {},
293
307
  ): Promise<void> {
294
308
  const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
295
309
  const send = seam.send ?? routeSend
310
+ /** A notice FROM iapeer to the entry's original sender (skipped when the sender is iapeer itself). */
311
+ const notifySender = async (e: InflightEntry, message: string): Promise<string> => {
312
+ try {
313
+ const index = readPeersIndex({ env })
314
+ const self = findPeer(index, 'iapeer')
315
+ if (!self || `${e.caller.runtime}-${e.caller.personality}` === `${self.runtime}-iapeer`) return 'skipped'
316
+ const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
317
+ const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
318
+ return String(n.ok)
319
+ } catch {
320
+ return 'false'
321
+ }
322
+ }
296
323
  for (const identity of listInflightIdentities(cfg.stateDir)) {
297
324
  const n = sweepInflight(cfg.stateDir, identity, {
298
325
  sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
299
326
  processed,
327
+ completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
328
+ landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
300
329
  })
301
330
  if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
302
331
  }
332
+ // A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
333
+ // effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
334
+ for (const identity of listInterruptedIdentities(cfg.stateDir)) {
335
+ for (const e of takeInterrupted(cfg.stateDir, identity)) {
336
+ const notified = await notifySender(e, interruptedNotice(e))
337
+ appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-interrupted', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', notified })
338
+ }
339
+ }
303
340
  for (const identity of listOrphanIdentities(cfg.stateDir)) {
304
341
  for (const e of takeOrphans(cfg.stateDir, identity)) {
305
342
  const index = readPeersIndex({ env })
306
343
  let reason: string
307
344
  try {
345
+ // ONE redelivery per message: an already-redelivered message that orphans again goes to the
346
+ // sender as an explicit notice instead of looping.
347
+ if (e.message.startsWith(REDELIVERY_PREFIX)) throw new Error('already redelivered once and still not processed')
308
348
  const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
309
349
  const r = await send(
310
350
  caller,
@@ -320,18 +360,7 @@ export async function processInflight(
320
360
  reason = x instanceof Error ? x.message : String(x)
321
361
  }
322
362
  // Redelivery failed → the sender must learn it explicitly.
323
- let notified = 'false'
324
- try {
325
- const self = findPeer(index, 'iapeer')
326
- const callerAddr = `${e.caller.runtime}-${e.caller.personality}`
327
- if (self && callerAddr !== `${self.runtime}-iapeer`) {
328
- const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
329
- const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message: undeliveredNotice(e, reason), topic: e.topic ?? 'delivery-failed' }, routeDeps)
330
- notified = String(n.ok)
331
- }
332
- } catch {
333
- notified = 'false'
334
- }
363
+ const notified = await notifySender(e, undeliveredNotice(e, reason))
335
364
  appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-undelivered', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', err: reason.slice(0, 300), notified })
336
365
  }
337
366
  }
@@ -125,7 +125,9 @@ export const opencodeAdapter: RuntimeAdapter = {
125
125
  const ping = await opencodeControl(controlSocket(spec.personality, env), { op: 'ping' }, 5000)
126
126
  if (!ping.ok) return { ok: false, reason: `runtime.ready seen but control ping failed: ${ping.error}` }
127
127
  if (ping.launchID !== launchID) return { ok: false, reason: `control socket answers launch ${ping.launchID}, expected ${launchID}` }
128
- return { ok: true }
128
+ const w = (l.properties as { warnings?: unknown } | null)?.warnings
129
+ const warnings = Array.isArray(w) ? w.filter((x): x is string => typeof x === 'string') : []
130
+ return warnings.length ? { ok: true, warnings } : { ok: true }
129
131
  }
130
132
  if (l.type === 'runtime.error') {
131
133
  const e = (l.properties as { error?: unknown } | null)?.error
@@ -241,6 +241,7 @@ export const launch: LaunchFn = async (
241
241
  if (adapter.structuralReady && readyToken) {
242
242
  const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
243
243
  if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
244
+ for (const w of r.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
244
245
  if (firstMessage.trim().length === 0) return ready(identity)
245
246
  if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
246
247
  const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
@@ -428,7 +428,7 @@ export interface RuntimeAdapter {
428
428
 
429
429
  /** Structural readiness replacing the pane-glyph gate: resolves when the runtime itself reported
430
430
  * readiness for `readyToken` (fail on deadline / reported error / `alive()` false). */
431
- structuralReady?(spec: LaunchSpec, readyToken: string, deadlineMs: number, alive: () => boolean, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; reason?: string }>
431
+ structuralReady?(spec: LaunchSpec, readyToken: string, deadlineMs: number, alive: () => boolean, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; reason?: string; warnings?: string[] }>
432
432
 
433
433
  /** Structural delivery (deliveryConfirm 'structural'): resolves ok only when THIS message is accepted
434
434
  * and persisted by the runtime. */
@@ -648,12 +648,17 @@ export function resolveWakeMode(
648
648
  argsResume: boolean | undefined,
649
649
  resolveResume: (cwd: string) => { ok: boolean; ref?: string; reason?: string },
650
650
  incomingTopic?: string,
651
+ /** OC14-1 — marker CONSUMPTION is deferred to the caller: a failed bring-up must not change the
652
+ * next resume/fresh decision (a crash-looping binary used to eat .idle-reaped, and after the fix the
653
+ * peer came back FRESH, its context lost). Absent → consume immediately (legacy/test callers). */
654
+ defer?: (consume: () => void) => void,
651
655
  ): WakeMode {
656
+ const done = defer ?? ((f: () => void) => f())
652
657
  // 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
653
658
  // promise ("next bring-up is FRESH") is fulfilled by this launch; leaving it would
654
659
  // leak an eager relaunch into a LATER, unrelated death.
655
660
  if (argsResume === false) {
656
- clearNewEager(cfg, identity)
661
+ done(() => clearNewEager(cfg, identity))
657
662
  return { resume: false, cause: 'folder-launch' }
658
663
  }
659
664
  // 1.5 — a standing .new-eager OUTRANKS EVERYTHING below, including attach-resume
@@ -664,8 +669,8 @@ export function resolveWakeMode(
664
669
  // finds it must honor it. Consume-on-fire + clear .idle-reaped (mirror .fresh-next
665
670
  // hygiene: a stale clean-park marker must not make a later crash read as resume).
666
671
  if (hasNewEager(cfg, identity)) {
667
- clearNewEager(cfg, identity)
668
- clearIdleReaped(cfg, identity)
672
+ done(() => clearNewEager(cfg, identity))
673
+ done(() => clearIdleReaped(cfg, identity))
669
674
  return { resume: false, cause: 'self-fresh-eager' }
670
675
  }
671
676
  // 2. attach → resume, fail-loud if nothing to resume.
@@ -682,8 +687,8 @@ export function resolveWakeMode(
682
687
  // .fresh-next AND .idle-reaped (mirror the ephemeral hygiene below — else a stale .idle-reaped would make
683
688
  // a later CRASH read as a clean park in 3b → RESUME a killed context, exactly the 3a hazard).
684
689
  if (hasFreshNext(cfg, identity)) {
685
- clearFreshNext(cfg, identity)
686
- clearIdleReaped(cfg, identity)
690
+ done(() => clearFreshNext(cfg, identity))
691
+ done(() => clearIdleReaped(cfg, identity))
687
692
  return { resume: false, cause: 'soft-reload' }
688
693
  }
689
694
  // 3. default (a message woke a dead/asleep peer): decide by the death cause.
@@ -692,7 +697,7 @@ export function resolveWakeMode(
692
697
  // Consume a stray .idle-reaped marker so it does not accumulate (it has no effect for
693
698
  // an ephemeral peer, which never resumes, but keep state tidy).
694
699
  if (isEphemeralPeer(cwd)) {
695
- clearIdleReaped(cfg, identity)
700
+ done(() => clearIdleReaped(cfg, identity))
696
701
  return { resume: false, cause: 'ephemeral-policy' }
697
702
  }
698
703
  // 3a. NOT parked-clean → FRESH either way, but tell the two cases apart in the
@@ -705,7 +710,7 @@ export function resolveWakeMode(
705
710
  : { resume: false, cause: 'first-wake' }
706
711
  }
707
712
  // 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
708
- clearIdleReaped(cfg, identity)
713
+ done(() => clearIdleReaped(cfg, identity))
709
714
  // human-conversational dialogue never auto-freshes; only an explicit /new resets it.
710
715
  if (isHumanConversational(cwd)) {
711
716
  const r = resolveResume(cwd)
@@ -1107,7 +1112,27 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1107
1112
  // by the death cause (.idle-reaped marker) + peer-type/topic. An EXPLICIT resume
1108
1113
  // that finds nothing to resume fails loud. incomingTopic (args.topic) is the
1109
1114
  // executor discriminator.
1110
- const mode = resolveWakeMode(cfg, identity, cwd, args.resume, c => adapter.resolveResume(c, { personality: args.personality, env }), args.topic)
1115
+ // OC14-1 — marker consumption is applied only once the session is actually up (see below): a
1116
+ // failed bring-up leaves .idle-reaped / .fresh-next / .new-eager intact for the next attempt.
1117
+ const consumeOnUp: Array<() => void> = []
1118
+ const mode = resolveWakeMode(
1119
+ cfg,
1120
+ identity,
1121
+ cwd,
1122
+ args.resume,
1123
+ c => adapter.resolveResume(c, { personality: args.personality, env }),
1124
+ args.topic,
1125
+ f => consumeOnUp.push(f),
1126
+ )
1127
+ const applyConsumed = (): void => {
1128
+ for (const f of consumeOnUp.splice(0)) {
1129
+ try {
1130
+ f()
1131
+ } catch {
1132
+ /* best-effort marker hygiene — never fails a wake */
1133
+ }
1134
+ }
1135
+ }
1111
1136
  // The bring-up decision is the durable trace — log it BEFORE launch (the decision
1112
1137
  // stands regardless of whether the subsequent launch succeeds). resolveWakeMode has
1113
1138
  // already consumed any .idle-reaped marker, so `cause` is now its only record.
@@ -1193,6 +1218,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1193
1218
  // must still hear FAILED; we only make the live remnant supervised. Best-effort: a write
1194
1219
  // hiccup must not mask the original FAILED reason.
1195
1220
  if (sessionAlive(sock, identity, env)) {
1221
+ applyConsumed() // a live remnant IS this bring-up's session — its markers are spent
1196
1222
  try {
1197
1223
  writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
1198
1224
  } catch { /* keep the original FAILED reason — enroll is best-effort */ }
@@ -1207,6 +1233,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1207
1233
  }
1208
1234
  return { status: 'FAILED', woke: false, runtime, reason: result.reason }
1209
1235
  }
1236
+ applyConsumed()
1210
1237
  // The session is up and the message delivered. Recording supervise-state must not
1211
1238
  // turn a successful wake into a failure (audit #18): log loudly rather than throw
1212
1239
  // past a live spawn. NB since 0.2.55 (self-TTL removed) the session-state file IS
@@ -32,10 +32,17 @@ 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`)
45
+ const interruptedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.interrupted.jsonl`)
39
46
 
40
47
  function readJsonl(path: string): InflightEntry[] {
41
48
  if (!existsSync(path)) return []
@@ -84,6 +91,11 @@ export interface InflightSweepDeps {
84
91
  sessionToken: (identity: string) => string | null
85
92
  /** Did the receiving session answer this entry? */
86
93
  processed: (entry: InflightEntry) => boolean
94
+ /** Did the turn this entry started FINISH? Absent → processed counts as finished (legacy). */
95
+ completed?: (entry: InflightEntry) => boolean
96
+ /** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
97
+ landed?: (entry: InflightEntry) => boolean
98
+ now?: number
87
99
  }
88
100
 
89
101
  /**
@@ -97,20 +109,57 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
97
109
  const current = deps.sessionToken(identity)
98
110
  const keep: InflightEntry[] = []
99
111
  const orphaned: InflightEntry[] = []
100
- for (const e of entries) {
101
- let done = false
112
+ const interrupted: InflightEntry[] = []
113
+ const safe = (f: ((e: InflightEntry) => boolean) | undefined, e: InflightEntry, dflt: boolean): boolean => {
114
+ if (!f) return dflt
102
115
  try {
103
- done = deps.processed(e)
116
+ return f(e)
104
117
  } catch {
105
- done = false
118
+ return false
119
+ }
120
+ }
121
+ // A session works its input in order: a FINISHED turn for a later message means every earlier one
122
+ // was handled too (bounds the ledger when a turn-end record is missed).
123
+ let finishedUpTo = -Infinity
124
+ const finished = new Set<string>()
125
+ for (const e of entries) {
126
+ if (safe(deps.completed, e, false) || (!deps.completed && safe(deps.processed, e, false))) {
127
+ finished.add(e.id)
128
+ finishedUpTo = Math.max(finishedUpTo, e.ts)
106
129
  }
107
- if (done) continue
108
- if (current !== null && current === e.sessionToken) keep.push(e)
109
- else orphaned.push(e)
130
+ }
131
+ for (const e of entries) {
132
+ if (finished.has(e.id) || e.ts < finishedUpTo) continue
133
+ const done = safe(deps.processed, e, false)
134
+ if (done) {
135
+ // Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
136
+ // may have happened — never redelivered, the sender is told).
137
+ if (current !== null && current === e.sessionToken) keep.push(e)
138
+ else interrupted.push(e)
139
+ continue
140
+ }
141
+ if (current !== null && current === e.sessionToken) {
142
+ // Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
143
+ // still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
144
+ if (e.unconfirmed && deps.landed) {
145
+ let landed = false
146
+ try {
147
+ landed = deps.landed(e)
148
+ } catch {
149
+ landed = false
150
+ }
151
+ if (landed) keep.push({ ...e, unconfirmed: undefined })
152
+ else if ((deps.now ?? Date.now()) - e.ts > UNLANDED_AFTER_MS) orphaned.push(e)
153
+ else keep.push(e)
154
+ continue
155
+ }
156
+ keep.push(e)
157
+ } else orphaned.push(e)
110
158
  }
111
159
  writeJsonl(inflightPath(stateDir, identity), keep)
112
160
  if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
113
- return orphaned.length
161
+ if (interrupted.length) for (const o of interrupted) appendFileSync(interruptedPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
162
+ return orphaned.length + interrupted.length
114
163
  }
115
164
 
116
165
  export function listOrphanIdentities(stateDir: string): string[] {
@@ -131,8 +180,27 @@ export function takeOrphans(stateDir: string, identity: string): InflightEntry[]
131
180
  return entries
132
181
  }
133
182
 
183
+ export function listInterruptedIdentities(stateDir: string): string[] {
184
+ let names: string[]
185
+ try {
186
+ names = readdirSync(stateDir)
187
+ } catch {
188
+ return []
189
+ }
190
+ return names.filter(n => n.endsWith('.interrupted.jsonl')).map(n => n.slice(0, -'.interrupted.jsonl'.length))
191
+ }
192
+
193
+ /** Take (and remove) an identity's interrupted entries — the caller notifies each sender. */
194
+ export function takeInterrupted(stateDir: string, identity: string): InflightEntry[] {
195
+ const path = interruptedPath(stateDir, identity)
196
+ const entries = readJsonl(path)
197
+ rmSync(path, { force: true })
198
+ return entries
199
+ }
200
+
134
201
  /** Remove an identity's ledger + orphans (peer removal). */
135
202
  export function purgeInflight(stateDir: string, identity: string): void {
136
203
  rmSync(inflightPath(stateDir, identity), { force: true })
137
204
  rmSync(orphansPath(stateDir, identity), { force: true })
205
+ rmSync(interruptedPath(stateDir, identity), { force: true })
138
206
  }
@@ -8,9 +8,11 @@
8
8
  // message delivery (acknowledged only once THIS message is persisted natively),
9
9
  // interrupt and TUI session selection.
10
10
  // Plain ESM, no dependencies: it runs inside OpenCode's own server worker.
11
- import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
12
  import { createServer } from 'node:net'
13
+ import { homedir } from 'node:os'
13
14
  import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
14
16
 
15
17
  const SCHEMA = 1
16
18
  const DELIVER_ACK_MS = 15000
@@ -296,6 +298,66 @@ async function server(input) {
296
298
  control.listen(paths.socket, () => resolve())
297
299
  })
298
300
 
301
+ // ── launch self-check (OC04): problems OpenCode itself tolerates silently ─────
302
+ // A missing / throwing plugin from a fragment and an unreachable MCP server still give a «working»
303
+ // session with surfaces silently absent. They are reported in runtime.ready.properties.warnings and
304
+ // state.warnings — never a refusal (the session is usable), never silent.
305
+ function tailText(path, bytes) {
306
+ try {
307
+ const size = statSync(path).size
308
+ const fd = openSync(path, 'r')
309
+ try {
310
+ const start = Math.max(0, size - bytes)
311
+ const buf = Buffer.alloc(size - start)
312
+ readSync(fd, buf, 0, buf.length, start)
313
+ return buf.toString('utf8')
314
+ } finally {
315
+ closeSync(fd)
316
+ }
317
+ } catch {
318
+ return ''
319
+ }
320
+ }
321
+ async function selfCheck() {
322
+ const warnings = []
323
+ try {
324
+ const st = await client.mcp.status()
325
+ for (const [name, s] of Object.entries((st && st.data) || {})) {
326
+ if (s && s.status !== 'connected' && s.status !== 'disabled') warnings.push('mcp ' + name + ': ' + s.status + (s.error ? ' — ' + String(s.error).slice(0, 200) : ''))
327
+ }
328
+ } catch (e) {
329
+ warnings.push('mcp status unavailable: ' + (e instanceof Error ? e.message : String(e)))
330
+ }
331
+ try {
332
+ const cfg = await client.config.get()
333
+ for (const spec of ((cfg && cfg.data && cfg.data.plugin) || [])) {
334
+ if (typeof spec === 'string' && spec.startsWith('file://') && !existsSync(fileURLToPath(spec))) warnings.push('plugin missing: ' + spec)
335
+ }
336
+ } catch {}
337
+ // Plugin load failures are only in the native log (one shared file, lines tagged run=<id>): find
338
+ // THIS process's run id through a marker line, then its «failed to load plugin» lines.
339
+ try {
340
+ const marker = 'iapeer-selfcheck ' + launchID
341
+ await client.app.log({ body: { service: 'iapeer', level: 'warn', message: marker } })
342
+ const logFile = join(env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share'), 'opencode', 'log', 'opencode.log')
343
+ let text = ''
344
+ for (let i = 0; i < 10 && !text.includes(marker); i++) {
345
+ await new Promise(r => setTimeout(r, 100))
346
+ text = tailText(logFile, 4 * 1024 * 1024)
347
+ }
348
+ const mine = text.split('\n').find(l => l.includes(marker))
349
+ const run = mine && (mine.match(/ run=([^ ]+)/) || [])[1]
350
+ if (run) {
351
+ for (const l of text.split('\n')) {
352
+ if (l.includes(' run=' + run + ' ') && /failed to load plugin|Failed to load plugin|plugin config hook failed/.test(l)) {
353
+ warnings.push('plugin failed: ' + l.replace(/^.*?message=/, '').slice(0, 300))
354
+ }
355
+ }
356
+ }
357
+ } catch {}
358
+ return warnings
359
+ }
360
+
299
361
  // Readiness runs AFTER init returns: calling the in-process client while OpenCode is
300
362
  // still loading this plugin would wait on the very instance state being built.
301
363
  // The socket is up and the session is known (fresh: created now) → runtime.ready —
@@ -310,8 +372,11 @@ async function server(input) {
310
372
  const hist = await client.session.messages({ path: { id: sessionID } }).catch(() => null)
311
373
  for (const m of (hist && hist.data) || []) noteModel(m && m.info)
312
374
  }
375
+ const warnings = await selfCheck()
376
+ if (warnings.length) state.warnings = warnings
377
+ else delete state.warnings
313
378
  if (state.status === 'starting') state.status = 'idle'
314
- journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
379
+ journal('runtime.ready', { session: got.data, resumed, ...(warnings.length ? { warnings } : {}) }, null, sessionID)
315
380
  } catch (e) {
316
381
  state.status = 'error'
317
382
  state.error = e instanceof Error ? e.message : String(e)
@@ -19,6 +19,8 @@ export interface OpencodeState {
19
19
  error?: string
20
20
  /** Actual `provider/model` of the last assistant turn (plugin-reported). */
21
21
  model?: string
22
+ /** Launch self-check findings (missing/failed plugin, MCP not connected) — docs/20 OC04. */
23
+ warnings?: string[]
22
24
  }
23
25
 
24
26
  export interface OpencodeJournalLine {
@@ -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,106 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
892
903
  return false
893
904
  }
894
905
 
906
+ /**
907
+ * Did the receiving session FINISH the turn this delivery started (OC07 extension)? Processed (a model
908
+ * response began) is not finished: a SIGKILL mid-tool leaves a processed-but-unfinished turn whose side
909
+ * effects may have happened — it must not be redelivered, and its sender must be told. Proof of a
910
+ * finished turn after the envelope's record:
911
+ * claude — an assistant record with a terminal stop_reason (end_turn / stop_sequence / max_tokens);
912
+ * codex — an `event_msg` `task_complete`;
913
+ * opencode — an assistant message for that user message with `time.completed` and a finish other
914
+ * than `tool-calls`.
915
+ */
916
+ export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
917
+ const needles = confirmNeedles(probe.rendered)
918
+ if (!needles.length || needles.every(n => !n)) return false
919
+ if (getAdapter(probe.runtime).deliveryConfirm === 'structural') {
920
+ const dir = endpointPrivateDir(probe.runtime, probe.personality, { env })
921
+ if (!dir) return false
922
+ const path = join(dir, 'events.jsonl')
923
+ let size = 0
924
+ try {
925
+ size = statSync(path).size
926
+ } catch {
927
+ return false
928
+ }
929
+ const userIDs = new Set<string>()
930
+ for (const line of readFileTailFrom(path, Math.max(0, size - PROCESSED_TAIL_BYTES)).split('\n')) {
931
+ if (!line.trim()) continue
932
+ let e: { type?: string; properties?: { part?: { type?: string; text?: string; messageID?: string }; info?: { role?: string; parentID?: string; finish?: string; time?: { completed?: number } } } }
933
+ try {
934
+ e = JSON.parse(line)
935
+ } catch {
936
+ continue
937
+ }
938
+ const part = e.properties?.part
939
+ if (e.type === 'message.part.updated' && part?.type === 'text' && typeof part.text === 'string' && part.messageID) {
940
+ const text = part.text.replace(/\r/g, '')
941
+ if (needles.every(n => text.includes(n))) userIDs.add(part.messageID)
942
+ }
943
+ const info = e.properties?.info
944
+ if (e.type === 'message.updated' && info?.role === 'assistant' && info.parentID && userIDs.has(info.parentID) && info.time?.completed && info.finish !== 'tool-calls') return true
945
+ }
946
+ return false
947
+ }
948
+ for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
949
+ let st
950
+ try {
951
+ st = statSync(path)
952
+ } catch {
953
+ continue
954
+ }
955
+ if (st.mtimeMs < probe.ts - 5000) continue
956
+ let carried = false
957
+ for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
958
+ if (!line.trim()) continue
959
+ let o: { type?: string; message?: { stop_reason?: string }; payload?: { type?: string } }
960
+ try {
961
+ o = JSON.parse(line)
962
+ } catch {
963
+ continue
964
+ }
965
+ if (!carried) {
966
+ if (o.type !== 'queue-operation' && jsonStringValuesContainAll(o, needles)) carried = true
967
+ continue
968
+ }
969
+ if (o.type === 'assistant' && ['end_turn', 'stop_sequence', 'max_tokens'].includes(o.message?.stop_reason ?? '')) return true
970
+ if (o.type === 'event_msg' && o.payload?.type === 'task_complete') return true
971
+ }
972
+ }
973
+ return false
974
+ }
975
+
976
+ /**
977
+ * Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
978
+ * queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being
979
+ * worked on» from «the paste was swallowed and nothing will ever see it». Structural runtimes (opencode)
980
+ * always landed: their delivery ack is a native persistence ack.
981
+ */
982
+ export function deliveryLanded(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
983
+ if (getAdapter(probe.runtime).deliveryConfirm === 'structural') return true
984
+ const needles = confirmNeedles(probe.rendered)
985
+ if (!needles.length || needles.every(n => !n)) return false
986
+ for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
987
+ let st
988
+ try {
989
+ st = statSync(path)
990
+ } catch {
991
+ continue
992
+ }
993
+ if (st.mtimeMs < probe.ts - 5000) continue
994
+ for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
995
+ if (!line.trim()) continue
996
+ try {
997
+ if (jsonStringValuesContainAll(JSON.parse(line), needles)) return true
998
+ } catch {
999
+ /* skip */
1000
+ }
1001
+ }
1002
+ }
1003
+ return false
1004
+ }
1005
+
895
1006
  /** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
896
1007
  export interface InflightNote {
897
1008
  sentAt: string
@@ -905,6 +1016,8 @@ export interface InflightNote {
905
1016
  topic?: string
906
1017
  attachments?: string[]
907
1018
  rendered: string
1019
+ /** The live confirm window ran out (see WarmDeliverSeam.allowUnconfirmed). */
1020
+ unconfirmed?: true
908
1021
  }
909
1022
 
910
1023
  function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
@@ -1144,6 +1257,10 @@ export interface RouteResult {
1144
1257
  queuedBy?: 'ephemeral' | 'composer'
1145
1258
  /** Queue depth right after the enqueue (observability; only with queued). */
1146
1259
  queueDepth?: number
1260
+ /** The bytes reached the live session but its acceptance record did not appear within the confirm
1261
+ * window (e.g. a busy receiver). NOT a failure: do not resend — the core's inflight ledger checks
1262
+ * it landed and redelivers once (or tells you) if it did not. */
1263
+ unconfirmed?: true
1147
1264
  }
1148
1265
 
1149
1266
  // WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
@@ -1293,7 +1410,7 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
1293
1410
  }
1294
1411
  }
1295
1412
 
1296
- type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
1413
+ type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string; unconfirmed?: boolean }
1297
1414
 
1298
1415
  /** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
1299
1416
  function noteInflight(
@@ -1318,6 +1435,7 @@ function noteInflight(
1318
1435
  ...(info.topic ? { topic: info.topic } : {}),
1319
1436
  ...(info.attachments?.length ? { attachments: info.attachments } : {}),
1320
1437
  rendered: renderEnvelopeForAgent(info.envelope),
1438
+ ...(info.unconfirmed ? { unconfirmed: true as const } : {}),
1321
1439
  })
1322
1440
  } catch {
1323
1441
  /* a post-delivery hook must never fail the delivery */
@@ -1790,16 +1908,26 @@ async function routeSendInner(
1790
1908
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
1791
1909
  // deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
1792
1910
  // 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)
1911
+ // Unknown ≠ failed: a confirm window that runs out on a LIVE session is reported unconfirmed and the
1912
+ // inflight ledger owns the retry — only when that ledger is wired (deps.noteInflight), else strict.
1913
+ let unconfirmed = false
1914
+ const delivered = await deliverWarm(
1915
+ target.value,
1916
+ envelope,
1917
+ peer.cwd,
1918
+ deps.noteInflight ? { allowUnconfirmed: true, onUnconfirmed: () => (unconfirmed = true) } : {},
1919
+ env,
1920
+ )
1794
1921
  if (!delivered.ok) return delivered
1795
1922
  noteTopic(deps, target.value.address, topic)
1796
1923
  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)
1924
+ noteInflight(deps, target.value, peer.cwd, { ...inflightInfo, unconfirmed }, env)
1798
1925
  return ok({
1799
1926
  ok: true,
1800
1927
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
1801
1928
  woke: false,
1802
1929
  ts: sentAt,
1930
+ ...(unconfirmed ? { unconfirmed: true as const } : {}),
1803
1931
  })
1804
1932
  }
1805
1933