@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
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Capability-plugin lifecycle (docs/21-capabilities.md).
|
|
2
|
+
//
|
|
3
|
+
// A capability plugin (statusline-injector, totp-presence, …) puts per-peer
|
|
4
|
+
// surfaces into a peer's runtime: hooks, a private config fragment, an MCP entry.
|
|
5
|
+
// Like the memory slot (ADR-009 inversion), the CORE owns WHEN and FOR WHOM —
|
|
6
|
+
// every agent endpoint of every peer, at birth, at a new runtime, at removal, and
|
|
7
|
+
// on registration — and the PLUGIN owns WHAT: it declares two commands and the
|
|
8
|
+
// core shells into them. The core knows nothing about surface forms.
|
|
9
|
+
//
|
|
10
|
+
// Declaration: <IAPEER_ROOT>/capabilities/<name>.json, written ONLY through
|
|
11
|
+
// `iapeer capability register` (validated), removed through `unregister`.
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
13
|
+
import { isAbsolute, join } from 'node:path'
|
|
14
|
+
import { isSupportedLocalRuntime, isValidName } from '../core/constants.ts'
|
|
15
|
+
import { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
16
|
+
import { runProvisionCommand, type ProvisionCommandOutcome, type ProvisionOccasion } from '../enable/provisionCommand.ts'
|
|
17
|
+
|
|
18
|
+
export const CAPABILITY_SCHEMA = 1
|
|
19
|
+
|
|
20
|
+
export interface CapabilityCommand {
|
|
21
|
+
/** Absolute path to the plugin's executable (launchd's minimal PATH resolves nothing). */
|
|
22
|
+
command: string
|
|
23
|
+
/** Argv tail; {cwd} {runtime} {personality} {occasion} are substituted per argument, no shell. */
|
|
24
|
+
args: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CapabilityDeclaration {
|
|
28
|
+
schema: 1
|
|
29
|
+
/** The capability name = the declaration file stem. */
|
|
30
|
+
capability: string
|
|
31
|
+
package?: string
|
|
32
|
+
version?: string
|
|
33
|
+
/** Agent runtimes the plugin provisions for. Endpoints of other runtimes are skipped. */
|
|
34
|
+
runtimes: string[]
|
|
35
|
+
provision: CapabilityCommand
|
|
36
|
+
unprovision: CapabilityCommand
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function capabilitiesDir(options: StorageOptions = {}): string {
|
|
40
|
+
return join(options.rootDir ?? resolveGlobalRoot(options.env), 'capabilities')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function command(value: unknown, where: string): CapabilityCommand {
|
|
44
|
+
const v = value as Partial<CapabilityCommand> | null
|
|
45
|
+
if (!v || typeof v !== 'object') throw new Error(`${where}: expected {command, args}`)
|
|
46
|
+
if (typeof v.command !== 'string' || !isAbsolute(v.command)) throw new Error(`${where}.command must be an absolute path`)
|
|
47
|
+
if (!Array.isArray(v.args) || v.args.some(a => typeof a !== 'string')) throw new Error(`${where}.args must be an array of strings`)
|
|
48
|
+
if (!v.args.some(a => a.includes('{personality}'))) {
|
|
49
|
+
// Several personalities can share one cwd: a command that cannot tell them apart
|
|
50
|
+
// would provision or tear down the wrong peer's surfaces.
|
|
51
|
+
throw new Error(`${where}.args must carry {personality}`)
|
|
52
|
+
}
|
|
53
|
+
return { command: v.command, args: [...v.args] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validate an untrusted declaration. Throws with the first violation. */
|
|
57
|
+
export function parseCapabilityDeclaration(raw: unknown): CapabilityDeclaration {
|
|
58
|
+
const d = raw as Record<string, unknown> | null
|
|
59
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('declaration must be a JSON object')
|
|
60
|
+
if (d.schema !== CAPABILITY_SCHEMA) throw new Error(`unsupported schema ${JSON.stringify(d.schema)} (expected ${CAPABILITY_SCHEMA})`)
|
|
61
|
+
if (!isValidName(d.capability)) throw new Error(`invalid capability name ${JSON.stringify(d.capability)}`)
|
|
62
|
+
if (!Array.isArray(d.runtimes) || d.runtimes.length === 0 || d.runtimes.some(r => typeof r !== 'string')) {
|
|
63
|
+
throw new Error('runtimes must be a non-empty array of runtime names')
|
|
64
|
+
}
|
|
65
|
+
for (const k of ['package', 'version'] as const) {
|
|
66
|
+
if (d[k] !== undefined && typeof d[k] !== 'string') throw new Error(`${k} must be a string`)
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
schema: CAPABILITY_SCHEMA,
|
|
70
|
+
capability: d.capability as string,
|
|
71
|
+
...(d.package ? { package: d.package as string } : {}),
|
|
72
|
+
...(d.version ? { version: d.version as string } : {}),
|
|
73
|
+
runtimes: [...new Set(d.runtimes as string[])],
|
|
74
|
+
provision: command(d.provision, 'provision'),
|
|
75
|
+
unprovision: command(d.unprovision, 'unprovision'),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CapabilityListing {
|
|
80
|
+
declarations: CapabilityDeclaration[]
|
|
81
|
+
/** Files that exist but do not validate — reported, never silently skipped. */
|
|
82
|
+
invalid: { file: string; error: string }[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function listCapabilities(options: StorageOptions = {}): CapabilityListing {
|
|
86
|
+
const dir = capabilitiesDir(options)
|
|
87
|
+
const out: CapabilityListing = { declarations: [], invalid: [] }
|
|
88
|
+
if (!existsSync(dir)) return out
|
|
89
|
+
for (const f of readdirSync(dir).filter(f => f.endsWith('.json')).sort()) {
|
|
90
|
+
try {
|
|
91
|
+
const decl = parseCapabilityDeclaration(JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
|
92
|
+
if (`${decl.capability}.json` !== f) throw new Error(`file name must be ${decl.capability}.json`)
|
|
93
|
+
out.declarations.push(decl)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
out.invalid.push({ file: join(dir, f), error: e instanceof Error ? e.message : String(e) })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeCapabilityDeclaration(decl: CapabilityDeclaration, options: StorageOptions = {}): string {
|
|
102
|
+
const dir = capabilitiesDir(options)
|
|
103
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
104
|
+
const path = join(dir, `${decl.capability}.json`)
|
|
105
|
+
writeFileAtomic(path, JSON.stringify(decl, null, 2) + '\n', 0o600)
|
|
106
|
+
return path
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeCapabilityDeclaration(name: string, options: StorageOptions = {}): boolean {
|
|
110
|
+
if (!isValidName(name)) throw new Error(`invalid capability name ${JSON.stringify(name)}`)
|
|
111
|
+
const path = join(capabilitiesDir(options), `${name}.json`)
|
|
112
|
+
if (!existsSync(path)) return false
|
|
113
|
+
rmSync(path)
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The (runtime) endpoints of a peer a capability applies to: declared by the peer,
|
|
118
|
+
* hostable as an agent by this core, and listed by the capability. */
|
|
119
|
+
export function capabilityEndpoints(decl: CapabilityDeclaration, peerRuntimes: readonly string[]): string[] {
|
|
120
|
+
return peerRuntimes.filter(r => isSupportedLocalRuntime(r) && decl.runtimes.includes(r))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface CapabilityRun {
|
|
124
|
+
capability: string
|
|
125
|
+
runtime: string
|
|
126
|
+
outcome: ProvisionCommandOutcome
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run one lifecycle command of every applicable capability for ONE peer. Never
|
|
131
|
+
* throws; callers decide how loud a failure is (birth/remove: warn and continue).
|
|
132
|
+
* `only` narrows to one capability (register/unregister sweeps).
|
|
133
|
+
*/
|
|
134
|
+
export function runCapabilityLifecycle(args: {
|
|
135
|
+
kind: 'provision' | 'unprovision'
|
|
136
|
+
personality: string
|
|
137
|
+
cwd: string
|
|
138
|
+
runtimes: readonly string[]
|
|
139
|
+
occasion: ProvisionOccasion
|
|
140
|
+
env?: NodeJS.ProcessEnv
|
|
141
|
+
only?: CapabilityDeclaration
|
|
142
|
+
rootDir?: string
|
|
143
|
+
}): CapabilityRun[] {
|
|
144
|
+
const decls = args.only ? [args.only] : listCapabilities({ env: args.env, rootDir: args.rootDir }).declarations
|
|
145
|
+
const runs: CapabilityRun[] = []
|
|
146
|
+
for (const decl of decls) {
|
|
147
|
+
for (const runtime of capabilityEndpoints(decl, args.runtimes)) {
|
|
148
|
+
const outcome = runProvisionCommand({
|
|
149
|
+
block: args.kind === 'provision' ? decl.provision : decl.unprovision,
|
|
150
|
+
cwd: args.cwd,
|
|
151
|
+
runtime,
|
|
152
|
+
personality: args.personality,
|
|
153
|
+
occasion: args.occasion,
|
|
154
|
+
env: args.env,
|
|
155
|
+
})
|
|
156
|
+
runs.push({ capability: decl.capability, runtime, outcome })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return runs
|
|
160
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -154,7 +154,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
154
154
|
let bestMt = -1
|
|
155
155
|
for (const rt of peer.runtimes) {
|
|
156
156
|
try {
|
|
157
|
-
const mt = getAdapter(rt).newestActivityMtime(peer.cwd)
|
|
157
|
+
const mt = getAdapter(rt).newestActivityMtime(peer.cwd, { personality: peer.personality, env })
|
|
158
158
|
if (mt !== null && mt > bestMt) {
|
|
159
159
|
bestMt = mt
|
|
160
160
|
lastActive = rt
|
|
@@ -367,7 +367,7 @@ export interface RefreshOutcome {
|
|
|
367
367
|
|
|
368
368
|
/** Runtimes that consume the layered system prompt (doctrine/fragments) — the only ones a soft-reload
|
|
369
369
|
* affects. Routers (notifier/telegram) and absent runtimes carry no doctrine. */
|
|
370
|
-
const AGENTIC_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
|
|
370
|
+
const AGENTIC_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex', 'opencode'])
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
373
|
* refresh <peer> [runtime] (or --all): arm a LAZY soft-reload. Each agentic (claude/codex) runtime of the
|
|
@@ -813,6 +813,9 @@ export async function compactPeer(
|
|
|
813
813
|
const control = opts.controlFn ?? routeControl
|
|
814
814
|
const compactDone = opts.compactDoneFn ?? waitForCompactDone
|
|
815
815
|
const waitDone = (rt: Runtime, baseline: ReturnType<typeof compactDoneBaseline>, woke: boolean): CompactPeerOutcome | null => {
|
|
816
|
+
// A structural runtime's control already returned only after the native compaction was recorded
|
|
817
|
+
// (docs/20) — there is no transcript marker to wait for.
|
|
818
|
+
if (getAdapter(rt).deliveryConfirm === 'structural') return null
|
|
816
819
|
const done = compactDone(
|
|
817
820
|
{
|
|
818
821
|
personality,
|
|
@@ -928,6 +931,8 @@ export interface RemoveOutcome {
|
|
|
928
931
|
/** Recipient-owned IAP attachment inbox cleanup. Completed attachment copies
|
|
929
932
|
* are state for the peer lifetime, so explicit peer removal owns their purge. */
|
|
930
933
|
attachmentInbox?: string
|
|
934
|
+
/** OpenCode private state dir (<root>/state/opencode/<p>) removed with the peer. */
|
|
935
|
+
opencodeState?: string
|
|
931
936
|
}
|
|
932
937
|
|
|
933
938
|
/**
|
|
@@ -1018,7 +1023,7 @@ export async function removePeerCli(
|
|
|
1018
1023
|
const slot = readMemoryProvider(env)
|
|
1019
1024
|
if (slot?.unprovision) {
|
|
1020
1025
|
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
1021
|
-
const agentic = peer.runtimes.filter(
|
|
1026
|
+
const agentic = peer.runtimes.filter(r => r === 'claude' || r === 'codex' || r === 'opencode')
|
|
1022
1027
|
for (const rt of agentic) {
|
|
1023
1028
|
const o = runProvisionCommand({
|
|
1024
1029
|
block: slot.unprovision,
|
|
@@ -1041,11 +1046,26 @@ export async function removePeerCli(
|
|
|
1041
1046
|
},
|
|
1042
1047
|
{ env },
|
|
1043
1048
|
)
|
|
1044
|
-
unprovisionOutcomes.push(
|
|
1049
|
+
unprovisionOutcomes.push(`memory/${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
1045
1050
|
}
|
|
1046
1051
|
}
|
|
1047
1052
|
} catch (e) {
|
|
1048
|
-
unprovisionOutcomes.push(`failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
1053
|
+
unprovisionOutcomes.push(`memory failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
1054
|
+
}
|
|
1055
|
+
// Capability-plugin unprovision (docs/21) — same joint, same best-effort contract.
|
|
1056
|
+
try {
|
|
1057
|
+
const { runCapabilityLifecycle } = await import('../capability/index.ts')
|
|
1058
|
+
for (const run of runCapabilityLifecycle({ kind: 'unprovision', personality, cwd: peer.cwd, runtimes: peer.runtimes, occasion: 'remove', env })) {
|
|
1059
|
+
const o = run.outcome
|
|
1060
|
+
appendLifecycleEvent(
|
|
1061
|
+
pluginLogsDir('iapeer', { env }),
|
|
1062
|
+
{ ev: 'capability-provision', capability: run.capability, identity: `${run.runtime}-${personality}`, occasion: 'remove', state: o.state, exit: o.exitCode ?? undefined, ms: o.durationMs, detail: o.detail },
|
|
1063
|
+
{ env },
|
|
1064
|
+
)
|
|
1065
|
+
unprovisionOutcomes.push(`${run.capability}/${run.runtime}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
1066
|
+
}
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
unprovisionOutcomes.push(`capabilities failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
1049
1069
|
}
|
|
1050
1070
|
// Codex pre-trust cleanup (reap-side counterpart of the birth-time
|
|
1051
1071
|
// preTrustCodexCwd): a removed codex peer must
|
|
@@ -1086,12 +1106,27 @@ export async function removePeerCli(
|
|
|
1086
1106
|
} catch (e) {
|
|
1087
1107
|
attachmentInbox = `FAILED: ${e instanceof Error ? e.message : String(e)}`
|
|
1088
1108
|
}
|
|
1109
|
+
// OpenCode private state (journal, state, plugin, control socket, provider fragments) lives under
|
|
1110
|
+
// <root>/state/opencode/<p>, outside the cwd — it must not outlive the peer and ambush a namesake.
|
|
1111
|
+
// After unprovision above, so the memory/capability owners removed their own files first.
|
|
1112
|
+
let opencodeState: string | undefined
|
|
1113
|
+
try {
|
|
1114
|
+
const { opencodePaths } = await import('../opencode/config.ts')
|
|
1115
|
+
const dir = opencodePaths(personality, { env }).dir
|
|
1116
|
+
if (existsSync(dir)) {
|
|
1117
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1118
|
+
opencodeState = `removed ${dir}`
|
|
1119
|
+
}
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
opencodeState = `FAILED: ${e instanceof Error ? e.message : String(e)}`
|
|
1122
|
+
}
|
|
1089
1123
|
return {
|
|
1090
1124
|
personality,
|
|
1091
1125
|
action: 'removed',
|
|
1092
1126
|
cwd: peer.cwd,
|
|
1093
1127
|
purgedState,
|
|
1094
1128
|
...(attachmentInbox ? { attachmentInbox } : {}),
|
|
1129
|
+
...(opencodeState ? { opencodeState } : {}),
|
|
1095
1130
|
...(plistTeardown ? { plistTeardown } : {}),
|
|
1096
1131
|
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
1097
1132
|
...(trustCleaned ? { codexTrust: trustCleaned } : {}),
|
|
@@ -1554,6 +1589,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1554
1589
|
{ sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
|
|
1555
1590
|
{ sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
|
|
1556
1591
|
{ sig: 'self-done', desc: '(agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one' },
|
|
1592
|
+
{ sig: 'capability <list | register <decl.json> | sync <name> | unregister <name>>', desc: 'capability-plugin lifecycle: per-peer provision/unprovision on every agent endpoint (docs/21)' },
|
|
1557
1593
|
{ sig: 'native-memory <off|on> (--peer <p> | --all)', desc: "gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)" },
|
|
1558
1594
|
{ sig: 'trust-hooks <hooks.json> [--check]', desc: 'pre-seed codex hooks trust for a file-form hooks.json (no modal); --check = drift report' },
|
|
1559
1595
|
{
|
|
@@ -1564,6 +1600,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1564
1600
|
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
|
|
1565
1601
|
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1566
1602
|
},
|
|
1603
|
+
{ sig: 'private-dir <peer> [--runtime r]', desc: 'print the core-owned private directory of a peer endpoint (docs/20: opencode journal/state/fragments). The ONLY way a process not launched by the core gets the path — never derive the layout. Without --runtime: the single declared runtime that has one. Exit 1 when none' },
|
|
1567
1604
|
{ sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
|
|
1568
1605
|
{ sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
|
|
1569
1606
|
{ sig: 'approve <id>', desc: 'approve a pending request — the decision clears it in every channel' },
|
|
@@ -1839,6 +1876,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1839
1876
|
if (tcc) out(tcc)
|
|
1840
1877
|
return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
1841
1878
|
}
|
|
1879
|
+
case 'capability': {
|
|
1880
|
+
// Capability-plugin lifecycle (docs/21): register / sync / unregister / list.
|
|
1881
|
+
const { capabilityCli } = await import('../capability/cli.ts')
|
|
1882
|
+
return capabilityCli(positionals, env, out, errOut)
|
|
1883
|
+
}
|
|
1842
1884
|
case 'native-memory': {
|
|
1843
1885
|
// The canonized runtime-memory lever (контракт «Слот памяти» §Native-память):
|
|
1844
1886
|
// off = explicit disable merged into the peer's runtime config files; on =
|
|
@@ -2237,6 +2279,32 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2237
2279
|
for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
2238
2280
|
return outcomes.some(o => o.action === 'failed') ? 1 : 0
|
|
2239
2281
|
}
|
|
2282
|
+
case 'private-dir': {
|
|
2283
|
+
const name = positionals[0]
|
|
2284
|
+
if (!name) return argErr(errOut, 'private-dir needs a peer — usage: iapeer private-dir <peer> [--runtime r]')
|
|
2285
|
+
const peer = findPeer(readPeersIndex({ env }), name)
|
|
2286
|
+
if (!peer) {
|
|
2287
|
+
errOut(`peer "${name}" is not registered\n`)
|
|
2288
|
+
return 1
|
|
2289
|
+
}
|
|
2290
|
+
const { endpointPrivateDir } = await import('../storage/index.ts')
|
|
2291
|
+
const wanted = typeof flags.runtime === 'string' ? flags.runtime : undefined
|
|
2292
|
+
if (wanted && !peer.runtimes.includes(wanted)) {
|
|
2293
|
+
errOut(`peer "${name}" does not declare runtime "${wanted}" (declared: ${peer.runtimes.join(', ')})\n`)
|
|
2294
|
+
return 1
|
|
2295
|
+
}
|
|
2296
|
+
const candidates = (wanted ? [wanted] : peer.runtimes).filter(rt => endpointPrivateDir(rt, name, { env }))
|
|
2297
|
+
if (candidates.length !== 1) {
|
|
2298
|
+
errOut(
|
|
2299
|
+
candidates.length === 0
|
|
2300
|
+
? `no private directory for "${name}"${wanted ? ` (${wanted})` : ''} — only opencode endpoints have one\n`
|
|
2301
|
+
: `"${name}" has several endpoints with a private directory (${candidates.join(', ')}) — pass --runtime\n`,
|
|
2302
|
+
)
|
|
2303
|
+
return 1
|
|
2304
|
+
}
|
|
2305
|
+
out(`${endpointPrivateDir(candidates[0]!, name, { env })}\n`)
|
|
2306
|
+
return 0
|
|
2307
|
+
}
|
|
2240
2308
|
case 'default-runtime': {
|
|
2241
2309
|
// The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
|
|
2242
2310
|
const rt = positionals[0]
|
|
@@ -2313,7 +2381,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2313
2381
|
}
|
|
2314
2382
|
// v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
|
|
2315
2383
|
if (o.unprovision?.length) {
|
|
2316
|
-
out(`
|
|
2384
|
+
out(`unprovision: ${o.unprovision.join(', ')}\n`)
|
|
2317
2385
|
}
|
|
2318
2386
|
// Codex pre-trust cleanup — the cwd's trust entry must die with the peer.
|
|
2319
2387
|
if (o.codexTrust) {
|
|
@@ -2328,6 +2396,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2328
2396
|
if (o.purgedState?.length) {
|
|
2329
2397
|
out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
|
|
2330
2398
|
}
|
|
2399
|
+
if (o.opencodeState) out(`opencode state: ${o.opencodeState}\n`)
|
|
2331
2400
|
if (o.attachmentInbox) {
|
|
2332
2401
|
out(`attachment inbox: ${o.attachmentInbox}\n`)
|
|
2333
2402
|
}
|
package/src/core/constants.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const RUNTIME_RE_SOURCE = '^[a-z][a-z0-9]{0,31}$'
|
|
|
11
11
|
|
|
12
12
|
export type Runtime = string
|
|
13
13
|
export type TmuxRuntime = Runtime
|
|
14
|
-
export const SUPPORTED_LOCAL_RUNTIMES = ['claude', 'codex'] as const
|
|
14
|
+
export const SUPPORTED_LOCAL_RUNTIMES = ['claude', 'codex', 'opencode'] as const
|
|
15
15
|
export type SupportedLocalRuntime = (typeof SUPPORTED_LOCAL_RUNTIMES)[number]
|
|
16
16
|
|
|
17
17
|
export const PEERS_SCHEMA_VERSION = 2
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
|
|
36
36
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
37
37
|
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
|
+
import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
|
|
38
39
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
40
|
+
import { endpointPrivateDir } from '../storage/index.ts'
|
|
39
41
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
40
42
|
import {
|
|
41
43
|
ephemeralQueueDepth,
|
|
@@ -79,6 +81,14 @@ export const FLEET_API_VERSION = 1
|
|
|
79
81
|
export interface FleetRuntimeStatus {
|
|
80
82
|
runtime: string
|
|
81
83
|
status: 'live' | 'asleep' | 'stopped'
|
|
84
|
+
/** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
|
|
85
|
+
* agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
|
|
86
|
+
* `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
|
|
87
|
+
* A declared runtime the running core cannot host (newer peer, older core) is `false`. */
|
|
88
|
+
agent: boolean
|
|
89
|
+
/** Core-owned private directory of this endpoint (docs/20: opencode journal/state). Absent when the
|
|
90
|
+
* runtime has none. Observers read it from here; they never derive the layout. */
|
|
91
|
+
private_dir?: string
|
|
82
92
|
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
83
93
|
attached?: boolean
|
|
84
94
|
/** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
|
|
@@ -125,6 +135,8 @@ export interface FleetHost {
|
|
|
125
135
|
memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
126
136
|
voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
127
137
|
fda: boolean | null
|
|
138
|
+
/** The agent runtimes this core build can host (its agent adapters), in preference order. */
|
|
139
|
+
agent_runtimes: string[]
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
|
|
@@ -228,6 +240,10 @@ export function buildFleetSnapshot(
|
|
|
228
240
|
* never emitted, which is exactly its "no claim" semantics. */
|
|
229
241
|
activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
|
|
230
242
|
): FleetSnapshot {
|
|
243
|
+
const privateDirOf = (runtime: string, personality: string): { private_dir?: string } => {
|
|
244
|
+
const d = endpointPrivateDir(runtime, personality, { env })
|
|
245
|
+
return d ? { private_dir: d } : {}
|
|
246
|
+
}
|
|
231
247
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
232
248
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
233
249
|
const attached =
|
|
@@ -238,6 +254,8 @@ export function buildFleetSnapshot(
|
|
|
238
254
|
return {
|
|
239
255
|
runtime: s.runtime,
|
|
240
256
|
status: s.status,
|
|
257
|
+
agent: isSupportedLocalRuntime(s.runtime),
|
|
258
|
+
...(privateDirOf(s.runtime, r.personality)),
|
|
241
259
|
...(attached ? { attached: true } : {}),
|
|
242
260
|
...(activity ? { activity } : {}),
|
|
243
261
|
}
|
|
@@ -289,6 +307,7 @@ export function buildFleetSnapshot(
|
|
|
289
307
|
? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
|
|
290
308
|
: null,
|
|
291
309
|
fda: probeFullDiskAccess(env),
|
|
310
|
+
agent_runtimes: [...SUPPORTED_LOCAL_RUNTIMES],
|
|
292
311
|
},
|
|
293
312
|
peers,
|
|
294
313
|
// ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawnSync } from 'child_process'
|
|
20
20
|
import { accessSync, constants as FS } from 'fs'
|
|
21
|
+
import { endpointPrivateDir } from '../storage/index.ts'
|
|
21
22
|
import type { MemoryProviderProvision } from '../status/index.ts'
|
|
22
23
|
|
|
23
24
|
/** The agreed occasion vocabulary (финален). */
|
|
@@ -48,8 +49,9 @@ export interface ProvisionCommandOutcome {
|
|
|
48
49
|
argv: string[]
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function substitute(arg: string, s: ProvisionCommandSpec): string {
|
|
52
|
+
function substitute(arg: string, s: ProvisionCommandSpec, privateDir: string | undefined): string {
|
|
52
53
|
return arg
|
|
54
|
+
.replaceAll('{privateDir}', privateDir ?? '{privateDir}')
|
|
53
55
|
.replaceAll('{cwd}', s.cwd)
|
|
54
56
|
.replaceAll('{runtime}', s.runtime)
|
|
55
57
|
.replaceAll('{personality}', s.personality)
|
|
@@ -70,9 +72,16 @@ function tail(text: string, max = 400): string | undefined {
|
|
|
70
72
|
* must not kill a birth or a remove).
|
|
71
73
|
*/
|
|
72
74
|
export function runProvisionCommand(spec: ProvisionCommandSpec): ProvisionCommandOutcome {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
+
const baseEnv = spec.env ?? process.env
|
|
76
|
+
const privateDir = endpointPrivateDir(spec.runtime, spec.personality, { env: baseEnv })
|
|
77
|
+
const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec, privateDir))]
|
|
75
78
|
const started = Date.now()
|
|
79
|
+
// {privateDir} for a runtime that has none is a declaration error — refuse loudly rather than run
|
|
80
|
+
// the command with a guessed or empty path.
|
|
81
|
+
if (!privateDir && spec.block.args.some(a => a.includes('{privateDir}'))) {
|
|
82
|
+
return { state: 'failed', exitCode: null, detail: `{privateDir} is undefined for runtime "${spec.runtime}"`, durationMs: 0, argv }
|
|
83
|
+
}
|
|
84
|
+
const env: NodeJS.ProcessEnv = privateDir ? { ...baseEnv, IAPEER_PRIVATE_DIR: privateDir } : baseEnv
|
|
76
85
|
try {
|
|
77
86
|
accessSync(spec.block.command, FS.X_OK)
|
|
78
87
|
} catch {
|
package/src/identity/index.ts
CHANGED
|
@@ -215,7 +215,7 @@ export function discoverPeerRuntimes(cwd: string, currentRuntime: Runtime): Runt
|
|
|
215
215
|
|
|
216
216
|
/** Agentic runtimes only an ARTIFICIAL peer can drive. A natural (human) or absent
|
|
217
217
|
* (no mind) peer has nobody to sit in a claude/codex session under its identity. */
|
|
218
|
-
const AGENTIC_GATED_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex'])
|
|
218
|
+
const AGENTIC_GATED_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'codex', 'opencode'])
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
221
|
* DISCOVERY GATE (хвост-2, boris 15.07): drop agentic runtimes a NON-artificial peer
|
package/src/init/index.ts
CHANGED
|
@@ -32,41 +32,19 @@ import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
|
|
|
32
32
|
import { claudeSettingsPath } from '../launch/nativeMemory.ts'
|
|
33
33
|
import { bootstrapFailed, launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
|
|
34
34
|
import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
|
|
35
|
+
import { defaultEnsureDepsDeps, ensureOpencodePluginDependency, type EnsureDepsDeps } from '../opencode/deps.ts'
|
|
35
36
|
import type { Intelligence } from '../core/constants.ts'
|
|
36
37
|
|
|
37
38
|
/** The MCP-server name the peer's `.mcp.json` uses for the foundation daemon. */
|
|
38
39
|
export const IAPEER_MCP_SERVER_NAME = 'iapeer'
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Daemon URL resolution lives in storage (shared with the launch adapters without an
|
|
42
|
+
// init↔launch import cycle); re-exported here for existing callers.
|
|
43
|
+
import { DEFAULT_DAEMON_MCP_PORT, resolveDaemonMcpUrl } from '../storage/index.ts'
|
|
44
|
+
export { DEFAULT_DAEMON_MCP_PORT, resolveDaemonMcpUrl }
|
|
43
45
|
|
|
44
46
|
const IAPEER_DOCTRINE_FILE = 'IAPEER.md'
|
|
45
47
|
|
|
46
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
-
// Daemon URL resolution (router.json published by the daemon, else the default)
|
|
48
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Resolve the daemon HTTP-MCP URL to write into a peer's `.mcp.json`. Primary: the
|
|
52
|
-
* `tcp` field of the daemon's discovery file `~/.iapeer/state/iapeer/router.json`
|
|
53
|
-
* (published live by the daemon). Fallback: the well-known default loopback URL —
|
|
54
|
-
* so `iapeer init` works even before the daemon is started (the daemon binds the
|
|
55
|
-
* same stable port; IAPEER_PORT overrides it at the daemon, not here).
|
|
56
|
-
*/
|
|
57
|
-
export function resolveDaemonMcpUrl(options: StorageOptions = {}): string {
|
|
58
|
-
const routerJson = join(pluginStateDir('iapeer', options), 'router.json')
|
|
59
|
-
try {
|
|
60
|
-
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
|
|
61
|
-
if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp
|
|
62
|
-
} catch {
|
|
63
|
-
/* no router.json (daemon not started) → the well-known default below */
|
|
64
|
-
}
|
|
65
|
-
const env = options.env ?? process.env
|
|
66
|
-
const port = env.IAPEER_PORT?.trim() || String(DEFAULT_DAEMON_MCP_PORT)
|
|
67
|
-
return `http://127.0.0.1:${port}/mcp`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
49
|
// claude `.mcp.json` wiring (project-scope; auto-enables on cold-start)
|
|
72
50
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -394,18 +372,33 @@ export function ensureGlobalDoctrineTemplate(env: NodeJS.ProcessEnv = process.en
|
|
|
394
372
|
*/
|
|
395
373
|
export function isRuntimeInstalled(runtime: Runtime, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
396
374
|
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return true
|
|
397
|
-
const override =
|
|
375
|
+
const override =
|
|
376
|
+
runtime === 'claude' ? env.IAPEER_CLAUDE_BIN
|
|
377
|
+
: runtime === 'codex' ? env.IAPEER_CODEX_BIN
|
|
378
|
+
: runtime === 'opencode' ? env.IAPEER_OPENCODE_BIN
|
|
379
|
+
: undefined
|
|
398
380
|
if (override?.trim()) return true
|
|
399
381
|
const home = env.HOME?.trim() || homedir()
|
|
400
382
|
if (runtime === 'claude' && existsSync(join(home, '.local', 'bin', 'claude'))) return true
|
|
401
383
|
if (Bun.which(runtime)) return true
|
|
402
384
|
if (runtime === 'claude' && existsSync(join(home, '.claude'))) return true
|
|
403
385
|
if (runtime === 'codex' && existsSync(join(home, '.codex'))) return true
|
|
386
|
+
// OpenCode's official installer puts the binary in ~/.opencode/bin (not on a launchd PATH).
|
|
387
|
+
if (runtime === 'opencode' && existsSync(join(home, '.opencode', 'bin', 'opencode'))) return true
|
|
404
388
|
return false
|
|
405
389
|
}
|
|
406
390
|
|
|
407
391
|
const AGENTIC_RUNTIMES: Runtime[] = ['claude', 'codex']
|
|
408
392
|
|
|
393
|
+
/** The OpenCode binary the core launches: IAPEER_OPENCODE_BIN, else the official installer's
|
|
394
|
+
* ~/.opencode/bin/opencode, else `opencode` on PATH (mirrors loadLifecycleConfig). */
|
|
395
|
+
export function resolveOpencodeBin(env: NodeJS.ProcessEnv = process.env): string {
|
|
396
|
+
const override = env.IAPEER_OPENCODE_BIN?.trim()
|
|
397
|
+
if (override) return override
|
|
398
|
+
const installed = join(env.HOME?.trim() || homedir(), '.opencode', 'bin', 'opencode')
|
|
399
|
+
return existsSync(installed) ? installed : 'opencode'
|
|
400
|
+
}
|
|
401
|
+
|
|
409
402
|
/**
|
|
410
403
|
* Resolve a peer's primary AGENTIC runtime — install-aware:
|
|
411
404
|
* 1. explicit (`--runtime`) → use it, but VALIDATE it is installed (clear error instead
|
|
@@ -469,6 +462,9 @@ export interface InitPeerOptions {
|
|
|
469
462
|
bootstrap?: boolean
|
|
470
463
|
env?: NodeJS.ProcessEnv
|
|
471
464
|
warn?: (message: string) => void
|
|
465
|
+
/** OpenCode plugin-dependency preinstall seam (tests). Absent → the real binary + npm; skipped
|
|
466
|
+
* entirely under IAPEER_TEST_SANDBOX unless injected. */
|
|
467
|
+
opencodeDeps?: EnsureDepsDeps
|
|
472
468
|
}
|
|
473
469
|
|
|
474
470
|
export interface InitPeerResult extends ProvisionResult {
|
|
@@ -505,6 +501,22 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
505
501
|
// decide the primary (resolvePrimaryRuntime), so the primary always matches the
|
|
506
502
|
// config that gets written (no `.codex`-only-but-primary-claude mismatch).
|
|
507
503
|
const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime, rt => isRuntimeInstalled(rt, env))
|
|
504
|
+
// OpenCode: satisfy the native plugin-dependency no-op BEFORE the runtime is registered, so a wake
|
|
505
|
+
// never waits on npm (docs/20). Fail-closed: a failure leaves the peer without the opencode runtime.
|
|
506
|
+
if (runtime === 'opencode') {
|
|
507
|
+
const sandboxed = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
|
|
508
|
+
if (opts.opencodeDeps || !sandboxed) {
|
|
509
|
+
const deps = opts.opencodeDeps ?? defaultEnsureDepsDeps(resolveOpencodeBin(env), env)
|
|
510
|
+
const outcomes = ensureOpencodePluginDependency(opts.cwd, env, deps)
|
|
511
|
+
const failed = outcomes.filter(o => o.state === 'failed')
|
|
512
|
+
if (failed.length > 0) {
|
|
513
|
+
throw new IapError(
|
|
514
|
+
`opencode plugin dependency preinstall failed — ${failed.map(f => `${f.dir}: ${f.detail ?? 'failed'}`).join('; ')}. ` +
|
|
515
|
+
`Without it OpenCode runs npm on every cold start. Fix npm access and re-run.`,
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
508
520
|
const provisioned = await provisionPeer({
|
|
509
521
|
cwd: opts.cwd,
|
|
510
522
|
runtime,
|