@agfpd/iapeer 0.4.116 → 0.4.118

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.
@@ -80,6 +80,7 @@ Field notes:
80
80
  - `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
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
+ - `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.
83
84
  - `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).
84
85
  - `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
85
86
  - `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,49 @@ 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
+ ## Provider pinning
127
+
128
+ Without an explicit `model`, native OpenCode uses its own hosted default
129
+ (`opencode/big-pickle`), and the composed doctrine, registry and messages go
130
+ there. Owner policy (2026-10-18): this is allowed, but never silent.
131
+ - No explicit `model` (`<provider>/<model>` in the peer's `opencode.json` or the
132
+ inline `OPENCODE_CONFIG_CONTENT`): the launch proceeds, `lifecycle.log` records
133
+ `ev=launch-warning warning="opencode-implicit-provider: …"`, and
134
+ `add-runtime`/`create` warn with the file to fill.
135
+ - An explicit `model` is pinned: `small_model` defaults to `model` (titles and
136
+ summaries never drift to another provider), `enabled_providers` defaults to
137
+ exactly the providers of those two; an explicit list that excludes them refuses
138
+ the launch.
139
+ - The model actually used is reported by the plugin (`state.json` `model`) and
140
+ appears as the fleet snapshot field `runtimes[].model` (docs/15).
141
+
142
+ ## Durable inbound across a session death (OC07, all agent runtimes)
143
+
144
+ Invariant: an inbound the core acknowledged is either processed by a later
145
+ session, or its sender is told explicitly that it was not. A runtime's input
146
+ queue lives in its process. Measured 2026-10-18 on claude: a message acked during
147
+ a busy turn had its `user` record (and the API request) in the transcript, the
148
+ process was SIGKILLed before any answer, and the fresh next session never saw it.
149
+
150
+ - Every confirmed delivery to an agent endpoint (live, wake, launchd-revive,
151
+ composer queue) is recorded in `<stateDir>/<identity>.inflight.jsonl` with the
152
+ hosting session token.
153
+ - «Processed» means a model response after the record carrying the envelope:
154
+ claude — an `assistant` record after a non-`queue-operation` record; codex — a
155
+ non-user `response_item` or `agent_message`; opencode — an assistant message
156
+ whose `parentID` is that user message. «Recorded» alone is not processed.
157
+ - After each supervise tick, processed entries leave. Unprocessed entries whose
158
+ session is gone or was replaced become orphans and are redelivered through the
159
+ normal route as their original sender, prefixed `[redelivered by iapeer: …]`
160
+ (wake-on-miss and the crash-loop guard apply). If redelivery fails, the sender
161
+ receives an explicit notice from `iapeer`. Logged as `ev=inflight-orphaned`
162
+ (lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
163
+ (delivery.log).
164
+ - A false «not processed» costs a visible duplicate, never a silent loss.
165
+
166
+ The real death cause is recorded too: the supervisor waits for the reap, so a
167
+ SIGKILL is logged as `dead_signal=SIGKILL`, never as a clean exit 0.
168
+
126
169
  ## Acceptance matrix
127
170
 
128
171
  `OPEN` includes implementation and/or missing live proof. Unit tests, config-file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.116",
3
+ "version": "0.4.118",
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
@@ -2735,7 +2735,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2735
2735
  ` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
2736
2736
  trayLine +
2737
2737
  ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
2738
- ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
2738
+ (plistChanged
2739
+ ? ` (install does not load it: \`iapeer update\` re-registers the daemon itself (bootout → bootstrap) right after this step; ` +
2740
+ `a standalone install loads it with \`iapeer onboard\` or launchctl bootstrap gui/$(id -u) ${plist})\n`
2741
+ : ''),
2739
2742
  )
2740
2743
  return 0
2741
2744
  }
@@ -35,9 +35,11 @@
35
35
 
36
36
  import type { IncomingMessage, ServerResponse } from 'http'
37
37
  import { closeSync, openSync, readSync, statSync } from 'fs'
38
+ import { join } from 'path'
38
39
  import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
39
40
  import { buildProcessAddress } from '../core/socket.ts'
40
41
  import { endpointPrivateDir } from '../storage/index.ts'
42
+ import { readOpencodeState } from '../opencode/journal.ts'
41
43
  import { IAPEER_VERSION } from '../core/version.ts'
42
44
  import {
43
45
  ephemeralQueueDepth,
@@ -89,6 +91,9 @@ export interface FleetRuntimeStatus {
89
91
  /** Core-owned private directory of this endpoint (docs/20: opencode journal/state). Absent when the
90
92
  * runtime has none. Observers read it from here; they never derive the layout. */
91
93
  private_dir?: string
94
+ /** The model the runtime ACTUALLY used last (`provider/model`, from the endpoint's own state; docs/20
95
+ * §Provider pinning). Absent until a first assistant turn, or for runtimes that do not report it. */
96
+ model?: string
92
97
  /** Present (true) iff a HUMAN operator is attached to this live hosted session. */
93
98
  attached?: boolean
94
99
  /** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
@@ -240,9 +245,11 @@ export function buildFleetSnapshot(
240
245
  * never emitted, which is exactly its "no claim" semantics. */
241
246
  activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
242
247
  ): FleetSnapshot {
243
- const privateDirOf = (runtime: string, personality: string): { private_dir?: string } => {
248
+ const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string } => {
244
249
  const d = endpointPrivateDir(runtime, personality, { env })
245
- return d ? { private_dir: d } : {}
250
+ 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 } : {}) }
246
253
  }
247
254
  const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
248
255
  const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
@@ -45,7 +45,7 @@ import { IAPEER_VERSION } from '../core/version.ts'
45
45
  import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
46
46
  import { readPeersIndex, type PeersIndex } from '../registry/index.ts'
47
47
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
48
- import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
48
+ import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type RouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
49
49
  import { appendDeliveryEvent } from './deliverylog.ts'
50
50
 
51
51
  export const CALLER_HEADER = 'x-iapeer-identity'
@@ -226,6 +226,8 @@ export interface CallToolDeps {
226
226
  noteLiveTopic?: (identity: string, topic: string) => void
227
227
  /** В7 — confirmed-delivery stamp → the target's idle-proxy floor (see transport.RouteDeps.noteDelivered). */
228
228
  noteDelivered?: (identity: string) => void
229
+ /** OC07 — confirmed delivery to an agent endpoint → the inflight ledger (see transport.RouteDeps.noteInflight). */
230
+ noteInflight?: RouteDeps['noteInflight']
229
231
  /** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
230
232
  composerQueue?: ComposerQueueRouteDeps
231
233
  /** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
@@ -255,6 +257,7 @@ export async function callTool(
255
257
  ephemeral: deps.ephemeral,
256
258
  noteLiveTopic: deps.noteLiveTopic,
257
259
  noteDelivered: deps.noteDelivered,
260
+ noteInflight: deps.noteInflight,
258
261
  composerQueue: deps.composerQueue,
259
262
  isLaunchdManaged: deps.isLaunchdManaged,
260
263
  })
@@ -466,6 +469,8 @@ export interface StartDaemonOptions extends StorageOptions {
466
469
  /** В7 — confirmed-delivery stamp → the target's idle-proxy floor (setLastDelivered). OFF by default;
467
470
  * the production main wires it. */
468
471
  noteDelivered?: (identity: string) => void
472
+ /** OC07 — confirmed delivery to an agent endpoint → the inflight ledger (see transport.RouteDeps.noteInflight). */
473
+ noteInflight?: RouteDeps['noteInflight']
469
474
  /**
470
475
  * Busy-human-composer queue — async acceptance for live local TUI delivery when
471
476
  * an attached operator has non-dim text in the target composer. OFF by default
@@ -510,6 +515,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
510
515
  ephemeral: opts.ephemeral,
511
516
  noteLiveTopic: opts.noteLiveTopic,
512
517
  noteDelivered: opts.noteDelivered,
518
+ noteInflight: opts.noteInflight,
513
519
  composerQueue: opts.composerQueue,
514
520
  isLaunchdManaged: opts.isLaunchdManaged,
515
521
  }
@@ -56,7 +56,10 @@ import type { ResolvedCaller } from '../identity/index.ts'
56
56
  import { findPeer, readPeersIndex } from '../registry/index.ts'
57
57
  import {
58
58
  createComposerDeliveryQueue,
59
+ deliveryProcessed,
59
60
  routeSend,
61
+ type InflightNote,
62
+ type RouteDeps,
60
63
  type ComposerQueueRouteDeps,
61
64
  type ComposerQueuedDelivery,
62
65
  type EphemeralRouteDeps,
@@ -73,6 +76,9 @@ import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesyn
73
76
  import { startGoalWatch } from './goalwatch.ts'
74
77
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
75
78
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
79
+ import { randomBytes } from 'crypto'
80
+ import { listInflightIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
81
+ import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
76
82
 
77
83
  /** Default TCP loopback port for the always-on router. Real http MCP clients
78
84
  * (claude/codex `--transport http <url>`) bind to a fixed URL, so production needs
@@ -249,6 +255,88 @@ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): stri
249
255
  * 2) before daemon shutdown/restart, fail every pending queued envelope back
250
256
  * to its sender, so `queued` never becomes silent loss.
251
257
  */
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+ // OC07 — durable inbound across a session death (lifecycle/inflight.ts)
260
+ // ─────────────────────────────────────────────────────────────────────────────
261
+
262
+ /** The transport hook → the inflight ledger. Best-effort by contract (transport swallows throws). */
263
+ export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) => void {
264
+ return note => recordInflight(cfg.stateDir, { ...note, id: randomBytes(6).toString('hex'), ts: Date.now() })
265
+ }
266
+
267
+ /** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
268
+ 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}`
270
+ }
271
+
272
+ /** Body of the explicit non-delivery notice to the original sender. */
273
+ export function undeliveredNotice(e: InflightEntry, reason: string): string {
274
+ return (
275
+ `iapeer: your message to "${e.personality}" (${e.runtime}), sent ${e.sentAt}${e.topic ? ` topic=${e.topic}` : ''}, ` +
276
+ `was acknowledged but NOT processed: the receiving session ended before answering it, and redelivery failed (${reason}). ` +
277
+ `Resend it if it still matters. First line: ${e.message.split('\n')[0]!.slice(0, 200)}`
278
+ )
279
+ }
280
+
281
+ /**
282
+ * One OC07 pass, run after every supervise tick. (1) Sweep every ledger: processed entries leave;
283
+ * unprocessed entries whose receiving session is gone or replaced become orphans. (2) Every orphan is
284
+ * redelivered through the normal route AS ITS ORIGINAL SENDER (wake-on-miss, guards, crash-loop guard
285
+ * all apply); if that fails, the sender gets an explicit notice from iapeer. Either way the orphan
286
+ * leaves the store — the invariant «processed by a later session, or the sender knows» holds.
287
+ */
288
+ export async function processInflight(
289
+ cfg: LifecycleConfig,
290
+ env: NodeJS.ProcessEnv,
291
+ routeDeps: RouteDeps,
292
+ seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
293
+ ): Promise<void> {
294
+ const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
295
+ const send = seam.send ?? routeSend
296
+ for (const identity of listInflightIdentities(cfg.stateDir)) {
297
+ const n = sweepInflight(cfg.stateDir, identity, {
298
+ sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
299
+ processed,
300
+ })
301
+ if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
302
+ }
303
+ for (const identity of listOrphanIdentities(cfg.stateDir)) {
304
+ for (const e of takeOrphans(cfg.stateDir, identity)) {
305
+ const index = readPeersIndex({ env })
306
+ let reason: string
307
+ try {
308
+ const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
309
+ const r = await send(
310
+ caller,
311
+ { personality: e.personality, runtime: e.runtime, message: redeliveryMessage(e), ...(e.topic ? { topic: e.topic } : {}), ...(e.attachments?.length ? { attachments: e.attachments } : {}) },
312
+ routeDeps,
313
+ )
314
+ if (r.ok) {
315
+ appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-redelivered', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'true', woke: String(r.value.woke) })
316
+ continue
317
+ }
318
+ reason = r.error.message
319
+ } catch (x) {
320
+ reason = x instanceof Error ? x.message : String(x)
321
+ }
322
+ // 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
+ }
335
+ 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
+ }
337
+ }
338
+ }
339
+
252
340
  export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
253
341
  const noteLiveTopic = makeNoteLiveTopic(cfg, env)
254
342
  const onDelivered = makeArmEphemeralOnDelivered(cfg)
@@ -264,6 +352,7 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
264
352
  logDir: cfg.logDir,
265
353
  noteLiveTopic,
266
354
  noteDelivered: identity => setLastDelivered(cfg, identity),
355
+ noteInflight: makeNoteInflight(cfg),
267
356
  onDelivered,
268
357
  notifyFailed: async (job, reason) => {
269
358
  const index = readPeersIndex({ env })
@@ -373,6 +462,7 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
373
462
  const armEphemeralOnDelivered = makeArmEphemeralOnDelivered(cfg)
374
463
  const ephemeral = makeEphemeralRouteDeps(cfg, env)
375
464
  const composerQueue = makeComposerQueueRouteDeps(cfg, env)
465
+ const noteInflight = makeNoteInflight(cfg)
376
466
  // Notice board (docs/19) — ONE instance shared by the mute-watch (raises) and the
377
467
  // fleet handler (serves /notices + the snapshot field). Constructed HERE, at the
378
468
  // composition point, because that sharing is the whole wiring.
@@ -463,6 +553,8 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
463
553
  // В7 — a confirmed live delivery floors the target's idle proxy (setLastDelivered), so a
464
554
  // just-delivered message is never reaped away before its turn record lands.
465
555
  noteDelivered: identity => setLastDelivered(cfg, identity),
556
+ // OC07 — confirmed delivery to an agent endpoint → inflight ledger (redelivered if its session dies first).
557
+ noteInflight,
466
558
  // Busy-human-composer queue: fast queued ack, async drain, fail all pending
467
559
  // on daemon close/update so a queued envelope is never silently lost.
468
560
  composerQueue,
@@ -496,6 +588,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
496
588
  // start (durable queue), and RETRIES a failed wake (the item stayed at the
497
589
  // head). Identities without a queue or with a live session are no-ops.
498
590
  await drainAllEphemeralQueues(cfg, { env })
591
+ // OC07 — acknowledged-but-unanswered inbound of a dead session: redeliver or tell the sender.
592
+ await processInflight(cfg, env, {
593
+ env,
594
+ wake: makeWakeFn(cfg, env),
595
+ ephemeral,
596
+ noteLiveTopic,
597
+ noteDelivered: identity => setLastDelivered(cfg, identity),
598
+ noteInflight,
599
+ isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
600
+ })
499
601
  },
500
602
  // Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
501
603
  // hid a stuck reaper for hours): record ev=supervise-error in lifecycle.log, flattened to one
@@ -605,6 +707,14 @@ export interface InstallDaemonPlistResult {
605
707
  * com.agfpd.* is the foundation-exclusive daemon namespace, so this never
606
708
  * touches the com.iapeer.* persistent-peer fleet.
607
709
  */
710
+ /** The EnvironmentVariables.PATH value of a rendered plist (XML-unescaped), or undefined. */
711
+ export function plistEnvPath(xml: string): string | undefined {
712
+ const m = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(xml)
713
+ const v = m ? /<key>PATH<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(m[1]!) : null
714
+ if (!v) return undefined
715
+ return v[1]!.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&')
716
+ }
717
+
608
718
  export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): InstallDaemonPlistResult {
609
719
  const env = opts.env ?? process.env
610
720
  const path = daemonPlistPath(env)
@@ -614,7 +724,19 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): Instal
614
724
  `(no ownership sentinel) — another manager owns it`,
615
725
  )
616
726
  }
617
- const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
727
+ // PATH STABILITY: buildDaemonPlistSpec defaults PATH to the INVOKING process's env.PATH, so every
728
+ // `iapeer update` run from a different shell rewrote the plist (a BTM notification each time, and a
729
+ // daemon PATH that silently followed whoever deployed last). The first install fixes the PATH; later
730
+ // installs keep the one on disk unless a caller passes `path` explicitly.
731
+ let keepPath: string | undefined
732
+ if (opts.path === undefined) {
733
+ try {
734
+ keepPath = plistEnvPath(readFileSync(path, 'utf8'))
735
+ } catch {
736
+ keepPath = undefined
737
+ }
738
+ }
739
+ const rendered = renderLaunchdPlist(buildDaemonPlistSpec(keepPath !== undefined ? { ...opts, path: keepPath } : opts))
618
740
  mkdirSync(launchAgentsDir(env), { recursive: true })
619
741
  mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
620
742
  // Write-if-changed: a byte-identical rewrite is a pure no-op for the daemon but
package/src/init/index.ts CHANGED
@@ -554,6 +554,26 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
554
554
 
555
555
  const doctrine = ensureDoctrineTemplate(provisioned.cwd)
556
556
 
557
+ // OpenCode never launches on an implicit provider (pinProvider). Say so at attach time, with the
558
+ // exact file to fill, instead of letting the first wake be the first signal.
559
+ if (provisioned.runtime === 'opencode') {
560
+ const { opencodePaths } = await import('../opencode/config.ts')
561
+ const cfgPath = opencodePaths(provisioned.personality, { env }).config
562
+ let model: unknown
563
+ try {
564
+ model = (JSON.parse(readFileSync(cfgPath, 'utf8')) as { model?: unknown }).model
565
+ } catch {
566
+ model = undefined
567
+ }
568
+ if (typeof model !== 'string' || !model.includes('/')) {
569
+ opts.warn?.(
570
+ `opencode provider not configured for "${provisioned.personality}": it will run on OpenCode's own default ` +
571
+ `provider (doctrine and messages go there) until ${cfgPath} names "model": "<provider>/<model>" ` +
572
+ `and that provider (docs/20 §Provider pinning)`,
573
+ )
574
+ }
575
+ }
576
+
557
577
  // INFRA peer: per-peer runtime self-config, THEN auto-bootstrap. For an agentic
558
578
  // (warm-on-demand) runtime neither applies — it is daemon-woken, never launchd-held,
559
579
  // and gets its MCP wiring above instead.
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { randomBytes } from 'node:crypto'
21
21
  import { existsSync, readFileSync, statSync } from 'node:fs'
22
- import { composeOpencodeConfig, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
22
+ import { composeOpencodeConfig, hasExplicitModel, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
23
23
  import { opencodeControl } from '../../opencode/control.ts'
24
24
  import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
25
25
  import { resolveDaemonMcpUrl } from '../../storage/index.ts'
@@ -105,7 +105,10 @@ export const opencodeAdapter: RuntimeAdapter = {
105
105
  IAPEER_FLEET_URL: resolveFleetBase(env),
106
106
  }
107
107
  if (spec.resume && spec.resumeRef) out.IAPEER_OPENCODE_SESSION = spec.resumeRef
108
- return { env: out, readyToken: launchID }
108
+ const warnings = hasExplicitModel(config)
109
+ ? []
110
+ : [`opencode-implicit-provider: no explicit "model" in ${p.config} — OpenCode's own default provider receives the doctrine and messages`]
111
+ return { env: out, readyToken: launchID, warnings }
109
112
  },
110
113
 
111
114
  async structuralReady(spec, launchID, deadlineMs, alive, env) {
@@ -200,6 +200,7 @@ export const launch: LaunchFn = async (
200
200
  const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
201
201
  invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
202
202
  readyToken = prepared.readyToken
203
+ for (const w of prepared.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
203
204
  } catch (e) {
204
205
  return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
205
206
  }
@@ -449,6 +449,8 @@ export interface ObserveContext {
449
449
  export interface PreparedLaunch {
450
450
  env: Record<string, string>
451
451
  readyToken?: string
452
+ /** Non-fatal launch facts the primitive records in lifecycle.log (ev=launch-warning). */
453
+ warnings?: string[]
452
454
  }
453
455
 
454
456
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,138 @@
1
+ // Inflight ledger — the core's record of CONFIRMED deliveries a receiving session has not yet
2
+ // PROCESSED (docs/20 OC07; invariant: an inbound the core acknowledged is either processed by a later
3
+ // session or its sender is told explicitly it was not). A runtime's own input queue lives in its
4
+ // process: a session that dies (crash, SIGKILL, operator kill) before answering a queued message
5
+ // takes it along, and a fresh next session never sees it. The ledger survives that death.
6
+ //
7
+ // Lifecycle of an entry: written at delivery (transport → noteInflight) → pruned once the session
8
+ // answered it (transport.deliveryProcessed) → when the receiving session is gone (or replaced) while
9
+ // the entry is unprocessed, moved to the identity's ORPHANS → the daemon redelivers orphans through
10
+ // the normal route, or notifies the sender when that fails.
11
+ //
12
+ // Files under cfg.stateDir: `<identity>.inflight.jsonl` (append) and `<identity>.orphans.jsonl`.
13
+ import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ export interface InflightEntry {
17
+ id: string
18
+ /** Epoch ms of the confirmed delivery. */
19
+ ts: number
20
+ /** Wire sentAt of the original envelope (shown on redelivery). */
21
+ sentAt: string
22
+ /** Receiving endpoint. */
23
+ identity: string
24
+ personality: string
25
+ runtime: string
26
+ cwd: string
27
+ /** Hosting session token at delivery (supervisor pid) — a different/absent token = that session is gone. */
28
+ sessionToken: string | null
29
+ caller: { personality: string; runtime: string }
30
+ message: string
31
+ topic?: string
32
+ attachments?: string[]
33
+ /** The presented envelope (what the model sees) — the processed-check needle source. */
34
+ rendered: string
35
+ }
36
+
37
+ const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
38
+ const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
39
+
40
+ function readJsonl(path: string): InflightEntry[] {
41
+ if (!existsSync(path)) return []
42
+ const out: InflightEntry[] = []
43
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
44
+ if (!line.trim()) continue
45
+ try {
46
+ out.push(JSON.parse(line) as InflightEntry)
47
+ } catch {
48
+ /* a torn line is skipped, never fatal */
49
+ }
50
+ }
51
+ return out
52
+ }
53
+
54
+ function writeJsonl(path: string, entries: InflightEntry[]): void {
55
+ if (entries.length === 0) {
56
+ rmSync(path, { force: true })
57
+ return
58
+ }
59
+ const tmp = `${path}.${process.pid}.tmp`
60
+ writeFileSync(tmp, entries.map(e => JSON.stringify(e)).join('\n') + '\n', { mode: 0o600 })
61
+ renameSync(tmp, path)
62
+ }
63
+
64
+ export function recordInflight(stateDir: string, entry: InflightEntry): void {
65
+ appendFileSync(inflightPath(stateDir, entry.identity), JSON.stringify(entry) + '\n', { mode: 0o600 })
66
+ }
67
+
68
+ export function listInflightIdentities(stateDir: string): string[] {
69
+ let names: string[]
70
+ try {
71
+ names = readdirSync(stateDir)
72
+ } catch {
73
+ return []
74
+ }
75
+ return names.filter(n => n.endsWith('.inflight.jsonl')).map(n => n.slice(0, -'.inflight.jsonl'.length))
76
+ }
77
+
78
+ export function readInflight(stateDir: string, identity: string): InflightEntry[] {
79
+ return readJsonl(inflightPath(stateDir, identity))
80
+ }
81
+
82
+ export interface InflightSweepDeps {
83
+ /** Current hosting token of the identity's session (null = no live session). */
84
+ sessionToken: (identity: string) => string | null
85
+ /** Did the receiving session answer this entry? */
86
+ processed: (entry: InflightEntry) => boolean
87
+ }
88
+
89
+ /**
90
+ * One pass over an identity's ledger: drop processed entries; entries whose receiving session is
91
+ * gone or was replaced (token differs) and that are still unprocessed move to ORPHANS. Entries of the
92
+ * still-live session that are unprocessed stay. Returns the number orphaned.
93
+ */
94
+ export function sweepInflight(stateDir: string, identity: string, deps: InflightSweepDeps): number {
95
+ const entries = readInflight(stateDir, identity)
96
+ if (entries.length === 0) return 0
97
+ const current = deps.sessionToken(identity)
98
+ const keep: InflightEntry[] = []
99
+ const orphaned: InflightEntry[] = []
100
+ for (const e of entries) {
101
+ let done = false
102
+ try {
103
+ done = deps.processed(e)
104
+ } catch {
105
+ done = false
106
+ }
107
+ if (done) continue
108
+ if (current !== null && current === e.sessionToken) keep.push(e)
109
+ else orphaned.push(e)
110
+ }
111
+ writeJsonl(inflightPath(stateDir, identity), keep)
112
+ if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
113
+ return orphaned.length
114
+ }
115
+
116
+ export function listOrphanIdentities(stateDir: string): string[] {
117
+ let names: string[]
118
+ try {
119
+ names = readdirSync(stateDir)
120
+ } catch {
121
+ return []
122
+ }
123
+ return names.filter(n => n.endsWith('.orphans.jsonl')).map(n => n.slice(0, -'.orphans.jsonl'.length))
124
+ }
125
+
126
+ /** Take (and remove) an identity's orphans — the caller owns redelivery-or-notify for each. */
127
+ export function takeOrphans(stateDir: string, identity: string): InflightEntry[] {
128
+ const path = orphansPath(stateDir, identity)
129
+ const entries = readJsonl(path)
130
+ rmSync(path, { force: true })
131
+ return entries
132
+ }
133
+
134
+ /** Remove an identity's ledger + orphans (peer removal). */
135
+ export function purgeInflight(stateDir: string, identity: string): void {
136
+ rmSync(inflightPath(stateDir, identity), { force: true })
137
+ rmSync(orphansPath(stateDir, identity), { force: true })
138
+ }
@@ -61,6 +61,43 @@ export function readOpencodeExtension(path: string): JsonObject {
61
61
  return fragment
62
62
  }
63
63
 
64
+ /** Is the peer's provider explicit (`model` = `<provider>/<model>`)? */
65
+ export function hasExplicitModel(config: JsonObject): boolean {
66
+ return typeof config.model === 'string' && /^[^/\s]+\/\S+$/.test(config.model)
67
+ }
68
+
69
+ /**
70
+ * Provider pinning (docs/20 §Provider pinning, OC04). Without an explicit model OpenCode falls back to
71
+ * its own hosted default (`opencode/big-pickle`) and the whole composed doctrine goes there. Owner
72
+ * policy (18.10): allowed, but NEVER silently — an implicit provider passes through unchanged and the
73
+ * launch records a warning (see the adapter) while the actual model is reported in the fleet snapshot.
74
+ * An EXPLICIT model is pinned: `small_model` defaults to `model` (titles never drift to another
75
+ * provider) and `enabled_providers` is exactly those providers; an explicit list that excludes them
76
+ * is a misconfiguration and refuses loudly.
77
+ */
78
+ export function pinProvider(config: JsonObject, configPath: string): JsonObject {
79
+ const hint =
80
+ `configure the peer's provider in ${configPath} (or OPENCODE_CONFIG_CONTENT in the peer's launch.env): ` +
81
+ `"model": "<provider>/<model>" plus that provider's "provider" block; see docs/20 §Provider pinning`
82
+ const providerOf = (v: unknown, field: string): string => {
83
+ if (typeof v !== 'string' || !/^[^/\s]+\/\S+$/.test(v)) {
84
+ throw new Error(`opencode: no explicit ${field} — refusing to launch on OpenCode's implicit default provider. ${hint}`)
85
+ }
86
+ return v.slice(0, v.indexOf('/'))
87
+ }
88
+ if (!hasExplicitModel(config)) return config
89
+ const model = providerOf(config.model, 'model')
90
+ const smallModel = config.small_model === undefined ? config.model : config.small_model
91
+ const small = providerOf(smallModel, 'small_model')
92
+ const needed = [...new Set([model, small])]
93
+ let enabled = config.enabled_providers
94
+ if (enabled === undefined) enabled = needed
95
+ else if (!Array.isArray(enabled) || !needed.every(p => (enabled as unknown[]).includes(p))) {
96
+ throw new Error(`opencode: enabled_providers ${JSON.stringify(enabled)} must include ${JSON.stringify(needed)} (the model providers). ${hint}`)
97
+ }
98
+ return { ...config, small_model: smallModel, enabled_providers: enabled }
99
+ }
100
+
64
101
  export interface ComposeOpencodeConfigInput {
65
102
  personality: string
66
103
  doctrine: string
@@ -90,6 +127,7 @@ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options
90
127
  const path = join(paths.dir, `${stem}.json`)
91
128
  if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
92
129
  }
130
+ result = pinProvider(result, paths.config)
93
131
  // Last writer for required fields; foreign MCP servers, agents, plugins and
94
132
  // provider settings survive. Effective managed overrides still need a live gate.
95
133
  return mergeOpencodeConfig(result, {
@@ -151,6 +151,7 @@ async function server(input) {
151
151
  function noteModel(info) {
152
152
  if (info && info.role === 'assistant' && typeof info.providerID === 'string' && typeof info.modelID === 'string') {
153
153
  lastModel = { providerID: info.providerID, modelID: info.modelID }
154
+ state.model = info.providerID + '/' + info.modelID // the ACTUAL model, for operators (fleet snapshot)
154
155
  }
155
156
  }
156
157
 
@@ -17,6 +17,8 @@ export interface OpencodeState {
17
17
  lastActivityMs: number
18
18
  status: OpencodeStatus
19
19
  error?: string
20
+ /** Actual `provider/model` of the last assistant turn (plugin-reported). */
21
+ model?: string
20
22
  }
21
23
 
22
24
  export interface OpencodeJournalLine {
@@ -113,6 +113,10 @@ const SIGWINCH_REDRAW_WAIT_MS = 300
113
113
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
114
114
  interface PtyChild {
115
115
  pid: number
116
+ /** Resolves with the exit status once the process is REAPED (the pty exit callback fires earlier). */
117
+ exited?: Promise<number>
118
+ exitCode?: number | null
119
+ signalCode?: string | number | null
116
120
  terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
117
121
  kill(sig?: number | string): void
118
122
  }
@@ -385,26 +389,33 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
385
389
  }
386
390
  }
387
391
  },
388
- exit(_t?: unknown, exitCode?: number | null, signalCode?: number | null) {
392
+ exit() {
389
393
  // resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log,
390
394
  // mirroring the tmux pane-died hook (ev=session-exit dead_status/dead_signal), so the daemon's
391
395
  // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
392
- // distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
393
- // onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
394
- const c = child as { exitCode?: number | null; signalCode?: number | null }
395
- const status = exitCode ?? c.exitCode
396
- const signal = signalCode ?? c.signalCode
397
- exitLogged = true // this IS the death record — shutdown below must not write a second one
398
- recordStructuralStop('child-exit', status ?? undefined, signal ?? undefined)
399
- appendExitEvent({
400
- ev: 'session-exit',
401
- identity: session,
402
- dead_status: status ?? '',
403
- dead_signal: signal ?? '',
404
- cause: 'child-exit',
405
- host: 'supervisor',
406
- })
407
- shutdown()
396
+ // distinguishes the source.
397
+ //
398
+ // The Bun-native pty `exit` callback fires on pty EOF, BEFORE the process is reaped, and passes
399
+ // (0, null) even for a SIGKILL (measured, Bun 1.3.13: callback (0, null); `exited` → 137,
400
+ // signalCode 'SIGKILL'). Reporting those args recorded a SIGKILL as a clean exit 0 (linus OC07).
401
+ // So wait for the reap (bounded) and read the subprocess's own exitCode/signalCode.
402
+ exitLogged = true // this IS the death record — a racing shutdown must not write a second one
403
+ const finish = (): void => {
404
+ const status = child.signalCode != null ? undefined : (child.exitCode ?? undefined)
405
+ const signal = child.signalCode ?? undefined
406
+ recordStructuralStop('child-exit', status, signal)
407
+ appendExitEvent({
408
+ ev: 'session-exit',
409
+ identity: session,
410
+ dead_status: status ?? '',
411
+ dead_signal: signal ?? '',
412
+ cause: 'child-exit',
413
+ host: 'supervisor',
414
+ })
415
+ shutdown()
416
+ }
417
+ const reaped = child.exited ?? Promise.resolve(0)
418
+ void Promise.race([reaped.catch(() => 0), new Promise(r => setTimeout(r, 2000))]).then(finish)
408
419
  },
409
420
  },
410
421
  } as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
@@ -33,7 +33,7 @@ import {
33
33
  type ProcessAddress,
34
34
  } from '../core/socket.ts'
35
35
  import { buildEnvelope, formatSentAt, renderEnvelopeForAgent } from '../codec/index.ts'
36
- import { resolveGlobalRoot } from '../storage/index.ts'
36
+ import { endpointPrivateDir, resolveGlobalRoot } from '../storage/index.ts'
37
37
  import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
38
38
  import type { ResolvedCaller } from '../identity/index.ts'
39
39
  // Delivery markers are OWNED by the runtime adapter. transport
@@ -805,6 +805,108 @@ export function transcriptCarriesEnvelope(
805
805
  return false
806
806
  }
807
807
 
808
+ /** The shape transport needs to judge one inflight delivery (a subset of lifecycle's InflightEntry —
809
+ * transport never imports lifecycle). */
810
+ export interface ProcessedProbe {
811
+ runtime: string
812
+ personality: string
813
+ cwd: string
814
+ ts: number
815
+ rendered: string
816
+ }
817
+
818
+ const PROCESSED_TAIL_BYTES = 16 * 1024 * 1024
819
+
820
+ /**
821
+ * Did the receiving session ANSWER this delivery (OC07)? «Recorded» is not «processed»: measured
822
+ * 18.10 on claude, a message SIGKILLed mid-request had its `user` record (and the API request blob)
823
+ * in the transcript but no assistant record after it — the model never answered, and a fresh next
824
+ * session would not see it. So the proof is a model response AFTER the record that carries the
825
+ * envelope:
826
+ * claude — a `type:"assistant"` record after a non-queue-operation record carrying the needles;
827
+ * codex — a non-user `response_item` (assistant message / function_call / reasoning) or an
828
+ * `agent_message` event after the user input carrying the needles;
829
+ * opencode — an assistant `message.updated` whose parentID is the user message whose text part
830
+ * carries the needles (journal; docs/20).
831
+ * Reads bounded tails only. False → the entry stays inflight (or orphans if its session is gone):
832
+ * a false negative costs a duplicate redelivery, never a silent loss.
833
+ */
834
+ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
835
+ const needles = confirmNeedles(probe.rendered)
836
+ if (!needles.length || needles.every(n => !n)) return false
837
+ if (getAdapter(probe.runtime).deliveryConfirm === 'structural') {
838
+ const dir = endpointPrivateDir(probe.runtime, probe.personality, { env })
839
+ if (!dir) return false
840
+ const path = join(dir, 'events.jsonl')
841
+ let size = 0
842
+ try {
843
+ size = statSync(path).size
844
+ } catch {
845
+ return false
846
+ }
847
+ const userIDs = new Set<string>()
848
+ for (const line of readFileTailFrom(path, Math.max(0, size - PROCESSED_TAIL_BYTES)).split('\n')) {
849
+ if (!line.trim()) continue
850
+ let e: { type?: string; properties?: { part?: { type?: string; text?: string; messageID?: string }; info?: { role?: string; parentID?: string } } }
851
+ try {
852
+ e = JSON.parse(line)
853
+ } catch {
854
+ continue
855
+ }
856
+ const part = e.properties?.part
857
+ if (e.type === 'message.part.updated' && part?.type === 'text' && typeof part.text === 'string' && part.messageID) {
858
+ const text = part.text.replace(/\r/g, '')
859
+ if (needles.every(n => text.includes(n))) userIDs.add(part.messageID)
860
+ }
861
+ const info = e.properties?.info
862
+ if (e.type === 'message.updated' && info?.role === 'assistant' && info.parentID && userIDs.has(info.parentID)) return true
863
+ }
864
+ return false
865
+ }
866
+ for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
867
+ let st
868
+ try {
869
+ st = statSync(path)
870
+ } catch {
871
+ continue
872
+ }
873
+ if (st.mtimeMs < probe.ts - 5000) continue
874
+ let carried = false
875
+ for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
876
+ if (!line.trim()) continue
877
+ let o: { type?: string; payload?: { type?: string; role?: string } }
878
+ try {
879
+ o = JSON.parse(line)
880
+ } catch {
881
+ continue
882
+ }
883
+ if (!carried) {
884
+ if (o.type !== 'queue-operation' && jsonStringValuesContainAll(o, needles)) carried = true
885
+ continue
886
+ }
887
+ if (o.type === 'assistant') return true // claude
888
+ if (o.type === 'response_item' && o.payload && o.payload.role !== 'user') return true // codex
889
+ if (o.type === 'event_msg' && o.payload?.type === 'agent_message') return true // codex
890
+ }
891
+ }
892
+ return false
893
+ }
894
+
895
+ /** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
896
+ export interface InflightNote {
897
+ sentAt: string
898
+ identity: string
899
+ personality: string
900
+ runtime: string
901
+ cwd: string
902
+ sessionToken: string | null
903
+ caller: { personality: string; runtime: string }
904
+ message: string
905
+ topic?: string
906
+ attachments?: string[]
907
+ rendered: string
908
+ }
909
+
808
910
  function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
809
911
  const raw = env?.IAPEER_COMPACT_DONE_TIMEOUT_MS
810
912
  const n = raw === undefined ? NaN : Number(raw)
@@ -1084,6 +1186,8 @@ export interface ComposerQueuedDelivery {
1084
1186
  topic?: string
1085
1187
  enqueuedAtMs: number
1086
1188
  sessionToken: string
1189
+ /** OC07 — what the inflight ledger needs once the queued delivery lands. */
1190
+ inflight?: InflightInfo
1087
1191
  }
1088
1192
 
1089
1193
  export interface ComposerQueueTryEnqueueArgs {
@@ -1094,6 +1198,7 @@ export interface ComposerQueueTryEnqueueArgs {
1094
1198
  topic?: string
1095
1199
  /** Router-acceptance instant (formatSentAt) — the queued ACK's `ts` mirrors it. */
1096
1200
  sentAt?: string
1201
+ inflight?: InflightInfo
1097
1202
  }
1098
1203
 
1099
1204
  export interface ComposerQueueRouteDeps {
@@ -1137,6 +1242,10 @@ export interface RouteDeps {
1137
1242
  * and cannot reap the session before the just-delivered message's turn record lands. Injected (same
1138
1243
  * layering as noteLiveTopic — lifecycle.setLastDelivered wired in main.ts). */
1139
1244
  noteDelivered?: (identity: string) => void
1245
+ /** OC07 — record a CONFIRMED delivery to an AGENT endpoint in the inflight ledger, so a session that
1246
+ * dies before answering it does not take the message along silently (lifecycle.recordInflight wired
1247
+ * in main.ts; same layering as noteDelivered). Best-effort: a hook error never fails the delivery. */
1248
+ noteInflight?: (note: InflightNote) => void
1140
1249
  /** H4 launchd-managed detector (injected so transport never imports lifecycle —
1141
1250
  * main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
1142
1251
  * almost always a TRANSIENT restart window (router restart on connect / `iapeer
@@ -1165,6 +1274,37 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
1165
1274
  }
1166
1275
  }
1167
1276
 
1277
+ type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
1278
+
1279
+ /** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
1280
+ function noteInflight(
1281
+ deps: Pick<RouteDeps, 'noteInflight'>,
1282
+ target: DeliveryTarget,
1283
+ cwd: string,
1284
+ info: InflightInfo,
1285
+ env: NodeJS.ProcessEnv,
1286
+ ): void {
1287
+ if (!deps.noteInflight) return
1288
+ try {
1289
+ if (getAdapter(target.runtime).kind !== 'tui') return
1290
+ deps.noteInflight({
1291
+ sentAt: info.sentAt,
1292
+ identity: target.address,
1293
+ personality: target.personality,
1294
+ runtime: target.runtime,
1295
+ cwd,
1296
+ sessionToken: hostSessionToken(target.address, env),
1297
+ caller: { personality: info.caller.personality, runtime: info.caller.runtime },
1298
+ message: info.message,
1299
+ ...(info.topic ? { topic: info.topic } : {}),
1300
+ ...(info.attachments?.length ? { attachments: info.attachments } : {}),
1301
+ rendered: renderEnvelopeForAgent(info.envelope),
1302
+ })
1303
+ } catch {
1304
+ /* a post-delivery hook must never fail the delivery */
1305
+ }
1306
+ }
1307
+
1168
1308
  /** Best-effort lastDelivered stamp (В7) — records a CONFIRMED live delivery so the idle-reap proxy
1169
1309
  * cannot reap the session out from under a just-delivered message before its turn record lands. */
1170
1310
  function noteDelivered(deps: RouteDeps, identity: string): void {
@@ -1202,6 +1342,8 @@ export interface ComposerDeliveryQueueOptions {
1202
1342
  noteLiveTopic?: (identity: string, topic: string) => void
1203
1343
  /** В7 — stamp a confirmed queued delivery (same seam as RouteDeps.noteDelivered). */
1204
1344
  noteDelivered?: (identity: string) => void
1345
+ /** OC07 — same seam as RouteDeps.noteInflight, for a queued delivery once it lands. */
1346
+ noteInflight?: (note: InflightNote) => void
1205
1347
  onDelivered?: (caller: ResolvedCaller) => void
1206
1348
  }
1207
1349
 
@@ -1349,6 +1491,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1349
1491
  } catch {
1350
1492
  /* best-effort */
1351
1493
  }
1494
+ if (job.inflight) noteInflight({ noteInflight: opts.noteInflight }, job.target, job.peer.cwd, job.inflight, opts.env ?? process.env)
1352
1495
  try {
1353
1496
  opts.onDelivered?.(job.caller)
1354
1497
  } catch {
@@ -1377,6 +1520,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
1377
1520
  topic: args.topic,
1378
1521
  enqueuedAtMs: monotonicMs(),
1379
1522
  sessionToken: token,
1523
+ ...(args.inflight ? { inflight: args.inflight } : {}),
1380
1524
  }
1381
1525
  const q = queues.get(args.target.address) ?? []
1382
1526
  q.push(job)
@@ -1601,6 +1745,8 @@ async function routeSendInner(
1601
1745
  attachments: spooled.value.paths,
1602
1746
  message,
1603
1747
  })
1748
+ // OC07 — what the inflight ledger needs to redeliver this message if the receiving session dies first.
1749
+ const inflightInfo = { sentAt, caller, message, topic, attachments: spooled.value.paths, envelope }
1604
1750
 
1605
1751
  // M3 wake_policy:"ephemeral" — an ephemeral target is NEVER injected into a live
1606
1752
  // session and never woken synchronously here: the delivery is ALWAYS enqueued
@@ -1620,7 +1766,7 @@ async function routeSendInner(
1620
1766
  // target must still obey the domain guard even though selection is exact).
1621
1767
  const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
1622
1768
  if (!liveGuard.ok) return liveGuard
1623
- const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
1769
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt, inflight: inflightInfo })
1624
1770
  if (queued) return queued
1625
1771
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
1626
1772
  // deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
@@ -1629,6 +1775,7 @@ async function routeSendInner(
1629
1775
  if (!delivered.ok) return delivered
1630
1776
  noteTopic(deps, target.value.address, topic)
1631
1777
  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)
1632
1779
  return ok({
1633
1780
  ok: true,
1634
1781
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
@@ -1661,6 +1808,7 @@ async function routeSendInner(
1661
1808
  const delivered = await deliverWarm(revived.value, envelope, peer.cwd, {}, env)
1662
1809
  if (delivered.ok) {
1663
1810
  noteTopic(deps, revived.value.address, topic)
1811
+ noteInflight(deps, revived.value, peer.cwd, inflightInfo, env)
1664
1812
  return ok({
1665
1813
  ok: true,
1666
1814
  delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
@@ -1701,11 +1849,12 @@ async function routeSendInner(
1701
1849
  // a false ok:true — the class the contract forbids. A delivery failure here is
1702
1850
  // surfaced loudly (false-FAIL over false-OK).
1703
1851
  if (woke.taskDelivered === false) {
1704
- const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt })
1852
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt, inflight: inflightInfo })
1705
1853
  if (queued) return queued
1706
1854
  const delivered = await deliverWarm(live.value, envelope, peer.cwd, {}, env)
1707
1855
  if (!delivered.ok) return delivered
1708
1856
  noteTopic(deps, live.value.address, topic)
1857
+ noteInflight(deps, live.value, peer.cwd, inflightInfo, env)
1709
1858
  return ok({
1710
1859
  ok: true,
1711
1860
  delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
@@ -1714,6 +1863,7 @@ async function routeSendInner(
1714
1863
  })
1715
1864
  }
1716
1865
  // The envelope was delivered as the boot first-message during wake.
1866
+ noteInflight(deps, live.value, peer.cwd, inflightInfo, env)
1717
1867
  return ok({
1718
1868
  ok: true,
1719
1869
  delivered_to: { personality: live.value.personality, runtime: live.value.runtime },