@agfpd/iapeer 0.4.114 → 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.
- package/docs/15-fleet-api.md +7 -3
- package/docs/20-opencode.md +323 -0
- package/docs/21-capabilities.md +83 -0
- package/docs/README.md +2 -0
- package/package.json +1 -1
- package/src/approval/install.ts +8 -0
- package/src/capability/cli.ts +106 -0
- package/src/capability/index.ts +160 -0
- package/src/cli/index.ts +75 -6
- package/src/core/constants.ts +1 -1
- package/src/daemon/fleet.ts +19 -0
- package/src/enable/provisionCommand.ts +12 -3
- package/src/identity/index.ts +1 -1
- package/src/init/index.ts +40 -28
- package/src/launch/adapters/opencode.ts +192 -0
- package/src/launch/index.ts +29 -2
- package/src/launch/types.ts +42 -4
- package/src/lifecycle/index.ts +13 -6
- package/src/opencode/config.ts +121 -0
- package/src/opencode/control.ts +56 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.d.mts +4 -0
- package/src/opencode/iapeer-plugin.mjs +362 -0
- package/src/opencode/journal.ts +143 -0
- package/src/provision/index.ts +31 -1
- package/src/storage/index.ts +50 -0
- package/src/supervisor/daemon.ts +23 -1
- package/src/transport/index.ts +29 -2
package/src/storage/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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 {
|
package/src/transport/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|