@agfpd/iapeer 0.4.121 → 0.4.123

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,15 @@ 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`.
178
204
  - Unknown is not failed. When a live receiver's acceptance record does not appear
179
205
  within the confirm window (a busy claude records a queued message late), the
180
206
  send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.121",
3
+ "version": "0.4.123",
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",
@@ -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 => {
@@ -608,7 +617,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
608
617
  // observations in; the snapshot below reads it. Reading is O(1): no file touched per request.
609
618
  const activity = opts.activity ?? new ActivityBoard()
610
619
  const activityOf = (personality: string, runtime: string): TurnActivity | undefined =>
611
- activity.get(personality, runtime, Date.now())
620
+ structuralActivity(personality, runtime, env) ?? activity.get(personality, runtime, Date.now())
612
621
  let opsPromise: Promise<FleetOps> | undefined
613
622
  const getOps = (): Promise<FleetOps> => {
614
623
  if (opts.ops) return Promise.resolve(opts.ops)
@@ -891,6 +900,20 @@ async function runCommand(
891
900
  }
892
901
  }
893
902
 
903
+ /**
904
+ * Activity of a STRUCTURAL runtime straight from its own state (docs/20: state.json `status` is the
905
+ * busy/idle source, not a transcript watch). busy/approval → working, idle → idle; anything else (or
906
+ * no private dir) → undefined, so the transcript turn-watch decides for claude/codex as before.
907
+ */
908
+ export function structuralActivity(personality: string, runtime: string, env: NodeJS.ProcessEnv): TurnActivity | undefined {
909
+ const dir = endpointPrivateDir(runtime, personality, { env })
910
+ if (!dir) return undefined
911
+ const status = readOpencodeState(join(dir, 'state.json'))?.status
912
+ if (status === 'busy' || status === 'approval') return 'working'
913
+ if (status === 'idle') return 'idle'
914
+ return undefined
915
+ }
916
+
894
917
  /** Serve the SSE event stream: optional replay of the newest N historical events,
895
918
  * then live tail. At-least-once semantics (a reconnect may re-see events — `id`
896
919
  * is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
@@ -56,6 +56,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
56
56
  import { findPeer, readPeersIndex } from '../registry/index.ts'
57
57
  import {
58
58
  createComposerDeliveryQueue,
59
+ deliveryCompleted,
59
60
  deliveryLanded,
60
61
  deliveryProcessed,
61
62
  routeSend,
@@ -78,7 +79,7 @@ import { startGoalWatch } from './goalwatch.ts'
78
79
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
79
80
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
80
81
  import { randomBytes } from 'crypto'
81
- 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'
82
83
  import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
83
84
 
84
85
  /** Default TCP loopback port for the always-on router. Real http MCP clients
@@ -272,6 +273,16 @@ export function redeliveryMessage(e: InflightEntry): string {
272
273
  return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
273
274
  }
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
+ )
284
+ }
285
+
275
286
  /** Body of the explicit non-delivery notice to the original sender. */
276
287
  export function undeliveredNotice(e: InflightEntry, reason: string): string {
277
288
  return (
@@ -292,18 +303,40 @@ export async function processInflight(
292
303
  cfg: LifecycleConfig,
293
304
  env: NodeJS.ProcessEnv,
294
305
  routeDeps: RouteDeps,
295
- seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean } = {},
306
+ seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean; completed?: (e: InflightEntry) => boolean } = {},
296
307
  ): Promise<void> {
297
308
  const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
298
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
+ }
299
323
  for (const identity of listInflightIdentities(cfg.stateDir)) {
300
324
  const n = sweepInflight(cfg.stateDir, identity, {
301
325
  sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
302
326
  processed,
327
+ completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
303
328
  landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
304
329
  })
305
330
  if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
306
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
+ }
307
340
  for (const identity of listOrphanIdentities(cfg.stateDir)) {
308
341
  for (const e of takeOrphans(cfg.stateDir, identity)) {
309
342
  const index = readPeersIndex({ env })
@@ -327,18 +360,7 @@ export async function processInflight(
327
360
  reason = x instanceof Error ? x.message : String(x)
328
361
  }
329
362
  // Redelivery failed → the sender must learn it explicitly.
330
- let notified = 'false'
331
- try {
332
- const self = findPeer(index, 'iapeer')
333
- const callerAddr = `${e.caller.runtime}-${e.caller.personality}`
334
- if (self && callerAddr !== `${self.runtime}-iapeer`) {
335
- const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
336
- const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message: undeliveredNotice(e, reason), topic: e.topic ?? 'delivery-failed' }, routeDeps)
337
- notified = String(n.ok)
338
- }
339
- } catch {
340
- notified = 'false'
341
- }
363
+ const notified = await notifySender(e, undeliveredNotice(e, reason))
342
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 })
343
365
  }
344
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
@@ -42,6 +42,7 @@ export const UNLANDED_AFTER_MS = 120_000
42
42
 
43
43
  const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
44
44
  const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
45
+ const interruptedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.interrupted.jsonl`)
45
46
 
46
47
  function readJsonl(path: string): InflightEntry[] {
47
48
  if (!existsSync(path)) return []
@@ -90,6 +91,8 @@ export interface InflightSweepDeps {
90
91
  sessionToken: (identity: string) => string | null
91
92
  /** Did the receiving session answer this entry? */
92
93
  processed: (entry: InflightEntry) => boolean
94
+ /** Did the turn this entry started FINISH? Absent → processed counts as finished (legacy). */
95
+ completed?: (entry: InflightEntry) => boolean
93
96
  /** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
94
97
  landed?: (entry: InflightEntry) => boolean
95
98
  now?: number
@@ -106,14 +109,35 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
106
109
  const current = deps.sessionToken(identity)
107
110
  const keep: InflightEntry[] = []
108
111
  const orphaned: InflightEntry[] = []
109
- for (const e of entries) {
110
- 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
111
115
  try {
112
- done = deps.processed(e)
116
+ return f(e)
113
117
  } catch {
114
- 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)
129
+ }
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
115
140
  }
116
- if (done) continue
117
141
  if (current !== null && current === e.sessionToken) {
118
142
  // Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
119
143
  // still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
@@ -134,7 +158,8 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
134
158
  }
135
159
  writeJsonl(inflightPath(stateDir, identity), keep)
136
160
  if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
137
- 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
138
163
  }
139
164
 
140
165
  export function listOrphanIdentities(stateDir: string): string[] {
@@ -155,8 +180,27 @@ export function takeOrphans(stateDir: string, identity: string): InflightEntry[]
155
180
  return entries
156
181
  }
157
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
+
158
201
  /** Remove an identity's ledger + orphans (peer removal). */
159
202
  export function purgeInflight(stateDir: string, identity: string): void {
160
203
  rmSync(inflightPath(stateDir, identity), { force: true })
161
204
  rmSync(orphansPath(stateDir, identity), { force: true })
205
+ rmSync(interruptedPath(stateDir, identity), { force: true })
162
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 {
@@ -903,6 +903,76 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
903
903
  return false
904
904
  }
905
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
+
906
976
  /**
907
977
  * Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
908
978
  * queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being