@agfpd/iapeer 0.4.115 → 0.4.117

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.
@@ -0,0 +1,143 @@
1
+ // Core-side reader/writer of the OpenCode observability ABI (docs/20 §Observability ABI).
2
+ // The in-process plugin (iapeer-plugin.mjs) is the producer while the process lives; the core
3
+ // writes ONLY when the process is gone (supervisor death record) — so the journal stays
4
+ // single-writer at every instant. seq continues from the journal tail.
5
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs'
6
+
7
+ export type OpencodeStatus = 'starting' | 'idle' | 'busy' | 'approval' | 'error' | 'stopped'
8
+
9
+ export interface OpencodeState {
10
+ schema: 1
11
+ identity: string
12
+ cwd: string
13
+ sessionID: string | null
14
+ launchID: string
15
+ startedAt: number
16
+ lastEventSeq: number
17
+ lastActivityMs: number
18
+ status: OpencodeStatus
19
+ error?: string
20
+ }
21
+
22
+ export interface OpencodeJournalLine {
23
+ schema: 1
24
+ seq: number
25
+ time: number
26
+ identity: string
27
+ launchID: string
28
+ sessionID: string | null
29
+ type: string
30
+ properties: unknown
31
+ nativeID: string | null
32
+ }
33
+
34
+ export function readOpencodeState(path: string): OpencodeState | null {
35
+ try {
36
+ const s = JSON.parse(readFileSync(path, 'utf8')) as OpencodeState
37
+ return s && typeof s === 'object' && typeof s.launchID === 'string' ? s : null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ function writeStateAtomic(path: string, state: OpencodeState): void {
44
+ const tmp = `${path}.${process.pid}.tmp`
45
+ writeFileSync(tmp, JSON.stringify(state) + '\n', { mode: 0o600 })
46
+ renameSync(tmp, path)
47
+ }
48
+
49
+ /** Parsed journal lines appended at or after byte `offset` (a partial trailing line is skipped). */
50
+ export function readJournalFrom(path: string, offset: number): { lines: OpencodeJournalLine[]; end: number } {
51
+ if (!existsSync(path)) return { lines: [], end: 0 }
52
+ const fd = openSync(path, 'r')
53
+ try {
54
+ const size = fstatSync(fd).size
55
+ if (size <= offset) return { lines: [], end: size }
56
+ const buf = Buffer.alloc(size - offset)
57
+ readSync(fd, buf, 0, buf.length, offset)
58
+ const text = buf.toString('utf8')
59
+ const lastNl = text.lastIndexOf('\n')
60
+ if (lastNl < 0) return { lines: [], end: offset }
61
+ const lines: OpencodeJournalLine[] = []
62
+ for (const raw of text.slice(0, lastNl).split('\n')) {
63
+ if (!raw) continue
64
+ try {
65
+ lines.push(JSON.parse(raw) as OpencodeJournalLine)
66
+ } catch {
67
+ /* a malformed line is not ours to repair; consumers dedup by seq */
68
+ }
69
+ }
70
+ return { lines, end: offset + Buffer.byteLength(text.slice(0, lastNl + 1)) }
71
+ } finally {
72
+ closeSync(fd)
73
+ }
74
+ }
75
+
76
+ /** Last `seq` in the journal (tail read; the journal never shrinks). 0 when absent/empty. */
77
+ export function lastJournalSeq(path: string): number {
78
+ if (!existsSync(path)) return 0
79
+ const fd = openSync(path, 'r')
80
+ try {
81
+ const size = fstatSync(fd).size
82
+ let span = 4096
83
+ while (true) {
84
+ const start = Math.max(0, size - span)
85
+ const buf = Buffer.alloc(size - start)
86
+ readSync(fd, buf, 0, buf.length, start)
87
+ const lines = buf.toString('utf8').split('\n').filter(Boolean)
88
+ for (let i = lines.length - 1; i >= (start === 0 ? 0 : 1); i--) {
89
+ try {
90
+ const seq = (JSON.parse(lines[i]!) as { seq?: unknown }).seq
91
+ if (Number.isInteger(seq)) return seq as number
92
+ } catch {
93
+ /* skip */
94
+ }
95
+ }
96
+ if (start === 0) return 0
97
+ span *= 4
98
+ }
99
+ } finally {
100
+ closeSync(fd)
101
+ }
102
+ }
103
+
104
+ export function fileMtimeMs(path: string): number | null {
105
+ try {
106
+ return statSync(path).mtimeMs
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Supervisor-side death record: the process is gone, so the plugin cannot write `stopped`
114
+ * (dispose does not run on a signal). Appends exactly one `runtime.stopped` for the launch in
115
+ * state.json and flips status → stopped. Idempotent: a launch whose state already says stopped
116
+ * (graceful dispose wrote it) is left untouched. No state file → nothing launched → no record.
117
+ */
118
+ export function recordOpencodeStopped(
119
+ paths: { state: string; events: string },
120
+ info: { reason: string; exitCode?: number; signal?: string },
121
+ now: number = Date.now(),
122
+ ): boolean {
123
+ const state = readOpencodeState(paths.state)
124
+ if (!state || state.status === 'stopped') return false
125
+ const seq = lastJournalSeq(paths.events) + 1
126
+ const properties: Record<string, unknown> = { reason: info.reason }
127
+ if (info.exitCode !== undefined) properties.exitCode = info.exitCode
128
+ if (info.signal !== undefined) properties.signal = info.signal
129
+ const line: OpencodeJournalLine = {
130
+ schema: 1,
131
+ seq,
132
+ time: now,
133
+ identity: state.identity,
134
+ launchID: state.launchID,
135
+ sessionID: state.sessionID,
136
+ type: 'runtime.stopped',
137
+ properties,
138
+ nativeID: null,
139
+ }
140
+ appendFileSync(paths.events, JSON.stringify(line) + '\n', { mode: 0o600 })
141
+ writeStateAtomic(paths.state, { ...state, status: 'stopped', lastEventSeq: seq, lastActivityMs: now })
142
+ return true
143
+ }
@@ -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
  }