@agfpd/iapeer 0.4.115 → 0.4.116

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.
@@ -141,7 +141,7 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
141
141
  const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
142
142
  const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
143
143
  const { pluginLogsDir } = await import('../storage/index.ts')
144
- const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
144
+ const agentic = profile.runtimes.filter(r => r === 'claude' || r === 'codex' || r === 'opencode')
145
145
  for (const rt of agentic) {
146
146
  const o = runProvisionCommand({
147
147
  block: slot.provision,
@@ -182,6 +182,36 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
182
182
  opts.warn?.(`memory birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
183
183
  }
184
184
 
185
+ // Capability-plugin lifecycle (docs/21): every registered capability provisions its
186
+ // surfaces for every agent endpoint the peer declares (occasion=birth). This joint
187
+ // also runs for add-runtime / default-runtime convergence (they call initPeer →
188
+ // here), so a new endpoint is never left without the host's capabilities.
189
+ // Idempotency is the plugin's obligation; a failure is LOUD but never kills a birth.
190
+ try {
191
+ const { runCapabilityLifecycle, listCapabilities } = await import('../capability/index.ts')
192
+ const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
193
+ const { pluginLogsDir } = await import('../storage/index.ts')
194
+ for (const bad of listCapabilities({ env }).invalid) {
195
+ opts.warn?.(`capability declaration ${bad.file} is INVALID (${bad.error}) — not provisioned for "${profile.personality}"`)
196
+ }
197
+ for (const run of runCapabilityLifecycle({ kind: 'provision', personality: profile.personality, cwd, runtimes: profile.runtimes, occasion: 'birth', env })) {
198
+ const o = run.outcome
199
+ appendLifecycleEvent(
200
+ pluginLogsDir('iapeer', { env }),
201
+ { ev: 'capability-provision', capability: run.capability, identity: `${run.runtime}-${profile.personality}`, occasion: 'birth', state: o.state, exit: o.exitCode ?? undefined, ms: o.durationMs, detail: o.detail },
202
+ { env },
203
+ )
204
+ if (o.state !== 'ok') {
205
+ opts.warn?.(
206
+ `capability ${run.capability} provision (${run.runtime}) ${o.state.toUpperCase()} for "${profile.personality}"` +
207
+ `${o.detail ? `: ${o.detail}` : ''} — repair: iapeer capability sync ${run.capability}`,
208
+ )
209
+ }
210
+ }
211
+ } catch (e) {
212
+ opts.warn?.(`capability birth-time hook failed: ${e instanceof Error ? e.message : String(e)}`)
213
+ }
214
+
185
215
  // wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
186
216
  // lives in the hand-editable local profile, provision just surfaces the mismatch):
187
217
  // • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy
@@ -29,6 +29,7 @@ import {
29
29
  STATE_DIR,
30
30
  SUPPORTED_LOCAL_RUNTIMES,
31
31
  isRuntime,
32
+ isValidName,
32
33
  type Runtime,
33
34
  type SupportedLocalRuntime,
34
35
  } from '../core/constants.ts'
@@ -347,3 +348,52 @@ export function writeFileAtomic(path: string, data: string, mode: number = FILE_
347
348
  // The durability core (fsync-before-rename + looped writeSync) lives in ONE place — see atomicWrite.ts.
348
349
  writeFileAtomicRaw(path, data, mode)
349
350
  }
351
+
352
+ // ─────────────────────────────────────────────────────────────────────────────
353
+ // Daemon URL resolution (router.json published by the daemon, else the default)
354
+ // ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ /** Fallback HTTP-MCP daemon port when no router.json is published yet (the daemon
357
+ * binds this stable loopback port by default; init before daemon-start uses it). */
358
+ export const DEFAULT_DAEMON_MCP_PORT = 8765
359
+
360
+ /**
361
+ * Resolve the daemon HTTP-MCP URL to write into a peer's `.mcp.json`. Primary: the
362
+ * `tcp` field of the daemon's discovery file `~/.iapeer/state/iapeer/router.json`
363
+ * (published live by the daemon). Fallback: the well-known default loopback URL —
364
+ * so `iapeer init` works even before the daemon is started (the daemon binds the
365
+ * same stable port; IAPEER_PORT overrides it at the daemon, not here).
366
+ */
367
+ export function resolveDaemonMcpUrl(options: StorageOptions = {}): string {
368
+ const routerJson = join(pluginStateDir('iapeer', options), 'router.json')
369
+ try {
370
+ const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
371
+ if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp
372
+ } catch {
373
+ /* no router.json (daemon not started) → the well-known default below */
374
+ }
375
+ const env = options.env ?? process.env
376
+ const port = env.IAPEER_PORT?.trim() || String(DEFAULT_DAEMON_MCP_PORT)
377
+ return `http://127.0.0.1:${port}/mcp`
378
+ }
379
+
380
+ /**
381
+ * The core-owned private directory of an OpenCode endpoint: <root>/state/opencode/<personality>.
382
+ * The SINGLE layout authority — opencodePaths and the provider/capability command placeholder
383
+ * {privateDir} both resolve through it. Runtime state of the core lives under state/, never in the
384
+ * agent's working tree (the default peer cwd is <root>/peers/<p>).
385
+ */
386
+ export function opencodePrivateDir(personality: string, options: StorageOptions = {}): string {
387
+ return join(options.rootDir ?? resolveGlobalRoot(options.env), STATE_DIR, 'opencode', personality)
388
+ }
389
+
390
+ /**
391
+ * The core-owned private directory of an endpoint (personality × runtime), or undefined when that
392
+ * runtime has none (today only opencode has one). The ONE answer every consumer gets — the launch
393
+ * adapter, provider/capability commands ({privateDir} / IAPEER_PRIVATE_DIR), the fleet snapshot
394
+ * (`runtimes[].private_dir`) and `iapeer private-dir`. Consumers never derive it themselves.
395
+ */
396
+ export function endpointPrivateDir(runtime: string, personality: string, options: StorageOptions = {}): string | undefined {
397
+ if (runtime !== 'opencode' || !isValidName(personality)) return undefined
398
+ return opencodePrivateDir(personality, options)
399
+ }
@@ -13,7 +13,7 @@
13
13
  import { Terminal } from '@xterm/headless'
14
14
  import { SerializeAddon } from '@xterm/addon-serialize'
15
15
  import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
16
- import { dirname } from 'node:path'
16
+ import { dirname, join } from 'node:path'
17
17
  import {
18
18
  BackpressureWriter,
19
19
  FRAME_DATA,
@@ -34,6 +34,7 @@ import { claudeAdapter } from '../launch/adapters/claude.ts'
34
34
  import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
35
35
  import { routeCircuitBreaker } from './approvalRoute.ts'
36
36
  import { killPtyDescendants } from './processTree.ts'
37
+ import { recordOpencodeStopped } from '../opencode/journal.ts'
37
38
 
38
39
  /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
39
40
  * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
@@ -323,6 +324,25 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
323
324
  }
324
325
  }
325
326
 
327
+ // OpenCode (docs/20): the in-process plugin cannot record its own death on a signal (dispose does
328
+ // not run), so the host records `runtime.stopped` + state=stopped once the child is gone. Idempotent
329
+ // with a graceful dispose (state already stopped → no second record). Best-effort, never blocks.
330
+ let structuralStopRecorded = false
331
+ const recordStructuralStop = (reason: string, exitCode?: number, signal?: number | string): void => {
332
+ if (structuralStopRecorded || runtime !== 'opencode') return
333
+ structuralStopRecorded = true
334
+ const dir = (env as Record<string, string | undefined>).IAPEER_OPENCODE_DIR
335
+ if (!dir) return
336
+ try {
337
+ recordOpencodeStopped(
338
+ { state: join(dir, 'state.json'), events: join(dir, 'events.jsonl') },
339
+ { reason, ...(exitCode !== undefined ? { exitCode } : {}), ...(signal !== undefined ? { signal: String(signal) } : {}) },
340
+ )
341
+ } catch (e) {
342
+ appendExitEvent({ ev: 'opencode-stop-record-failed', identity: session, reason: (e as Error).message })
343
+ }
344
+ }
345
+
326
346
  const child = Bun.spawn(CMD, {
327
347
  cwd: opts.cwd ?? runDir,
328
348
  env,
@@ -375,6 +395,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
375
395
  const status = exitCode ?? c.exitCode
376
396
  const signal = signalCode ?? c.signalCode
377
397
  exitLogged = true // this IS the death record — shutdown below must not write a second one
398
+ recordStructuralStop('child-exit', status ?? undefined, signal ?? undefined)
378
399
  appendExitEvent({
379
400
  ev: 'session-exit',
380
401
  identity: session,
@@ -618,6 +639,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
618
639
  } catch {
619
640
  /* */
620
641
  }
642
+ recordStructuralStop(cause)
621
643
  try {
622
644
  server.stop(true)
623
645
  } catch {
@@ -331,7 +331,16 @@ export async function deliverWarm(
331
331
  // receives the WIRE form its parser expects. Rendered BEFORE deliverViaHost so
332
332
  // the transcript-confirm matches the text actually delivered. fail-open inside
333
333
  // the renderer: a render problem degrades to the wire form, never to a loss.
334
- const payload = isSupportedLocalRuntime(target.runtime) ? renderEnvelopeForAgent(envelope) : envelope
334
+ // Agent = a model reads the text: every TUI-kind adapter (claude/codex/opencode), not the local
335
+ // support list (an adapter can exist before its runtime is advertised for discovery).
336
+ const agentTarget = (() => {
337
+ try {
338
+ return getAdapter(target.runtime).kind === 'tui'
339
+ } catch {
340
+ return isSupportedLocalRuntime(target.runtime)
341
+ }
342
+ })()
343
+ const payload = agentTarget ? renderEnvelopeForAgent(envelope) : envelope
335
344
  const address = target.address
336
345
  const prev = deliverTails.get(address) ?? Promise.resolve()
337
346
  // run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
@@ -357,6 +366,16 @@ async function deliverViaHost(
357
366
  env: NodeJS.ProcessEnv = process.env,
358
367
  ): Promise<Result<void>> {
359
368
  const adapter = getAdapter(target.runtime)
369
+ // STRUCTURAL runtime (opencode): no pty paste at all — the adapter's deliver acknowledges only once
370
+ // THIS message is persisted natively (docs/20), so its ack IS the message-specific landed-confirm.
371
+ if (adapter.deliveryConfirm === 'structural') {
372
+ if (!adapter.deliver) return err(`runtime "${target.runtime}" declares structural delivery without a deliver hook`)
373
+ const r = await adapter.deliver({ personality: target.personality, identity: target.address }, envelope, env)
374
+ if (!r.ok) {
375
+ return err(`peer "${target.personality}" (${target.runtime}) deliver failed: ${r.error ?? 'unknown error'}; message NOT delivered`)
376
+ }
377
+ return ok(undefined)
378
+ }
360
379
  // Baseline the transcript/session-jsonl file SIZES BEFORE delivery (per-runtime candidate files).
361
380
  // The landed-confirm reads ONLY the bytes appended past this baseline, so a record carrying our
362
381
  // envelope can only be one written in RESPONSE to this delivery — never a pre-existing copy.
@@ -923,7 +942,13 @@ export async function executeControlOnTarget(
923
942
  command: ControlCommand,
924
943
  seam: ControlHostSeam = {},
925
944
  ): Promise<Result<void>> {
926
- const plan = getAdapter(target.runtime).executeControl(command)
945
+ const adapter = getAdapter(target.runtime)
946
+ // Structural runtime (opencode): control goes through the runtime's native channel, never pty keys.
947
+ if (adapter.control) {
948
+ const r = await adapter.control({ personality: target.personality, identity: target.address }, command, process.env)
949
+ return r.ok ? ok(undefined) : err(`control "${command.name}" failed for "${target.personality}" (${target.runtime}): ${r.error ?? 'unknown error'}`)
950
+ }
951
+ const plan = adapter.executeControl(command)
927
952
  if (!plan) {
928
953
  return err(`runtime "${target.runtime}" does not support control command "${command.name}"`)
929
954
  }
@@ -1203,6 +1228,8 @@ function delay(ms: number): Promise<void> {
1203
1228
  * held. No pane-log dir / unreadable model → don't hold (deliver, never stall). */
1204
1229
  async function shouldQueueForComposer(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
1205
1230
  if (!isSupportedLocalRuntime(target.runtime) || !hasAttachedSupervisorClient(target.address)) return false
1231
+ // A structural runtime never shares the human's composer (delivery bypasses the pty) → never hold.
1232
+ if (getAdapter(target.runtime).deliveryConfirm === 'structural') return false
1206
1233
  if (!logDir) return false // no pane-log dir → cannot read occupancy → don't hold (deliver, never stall)
1207
1234
  return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
1208
1235
  }