@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.
- package/docs/15-fleet-api.md +2 -1
- package/docs/20-opencode.md +103 -6
- 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 +9 -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 +24 -5
- package/src/opencode/control.ts +1 -0
- package/src/opencode/deps.ts +110 -0
- package/src/opencode/iapeer-plugin.mjs +79 -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/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
|
@@ -37,6 +37,7 @@ import type { IncomingMessage, ServerResponse } from 'http'
|
|
|
37
37
|
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
38
|
import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
|
|
39
39
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
40
|
+
import { endpointPrivateDir } from '../storage/index.ts'
|
|
40
41
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
41
42
|
import {
|
|
42
43
|
ephemeralQueueDepth,
|
|
@@ -85,6 +86,9 @@ export interface FleetRuntimeStatus {
|
|
|
85
86
|
* `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
|
|
86
87
|
* A declared runtime the running core cannot host (newer peer, older core) is `false`. */
|
|
87
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
|
|
88
92
|
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
89
93
|
attached?: boolean
|
|
90
94
|
/** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
|
|
@@ -236,6 +240,10 @@ export function buildFleetSnapshot(
|
|
|
236
240
|
* never emitted, which is exactly its "no claim" semantics. */
|
|
237
241
|
activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
|
|
238
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
|
+
}
|
|
239
247
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
240
248
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
241
249
|
const attached =
|
|
@@ -247,6 +255,7 @@ export function buildFleetSnapshot(
|
|
|
247
255
|
runtime: s.runtime,
|
|
248
256
|
status: s.status,
|
|
249
257
|
agent: isSupportedLocalRuntime(s.runtime),
|
|
258
|
+
...(privateDirOf(s.runtime, r.personality)),
|
|
250
259
|
...(attached ? { attached: true } : {}),
|
|
251
260
|
...(activity ? { activity } : {}),
|
|
252
261
|
}
|
|
@@ -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,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// opencode RuntimeAdapter — the "HOW to launch / observe ONE OpenCode session" half of the
|
|
2
|
+
// launch contract (docs/20-opencode.md). Unlike claude/codex, NOTHING here reads the screen:
|
|
3
|
+
// readiness, delivery and control go through the core-owned in-process plugin
|
|
4
|
+
// (src/opencode/iapeer-plugin.mjs) — its runtime.ready journal line, its control socket and its
|
|
5
|
+
// per-message persistence ack. The native TUI is still hosted by the pty supervisor (the human
|
|
6
|
+
// attach surface), but the core never pastes into it.
|
|
7
|
+
//
|
|
8
|
+
// Per-launch preparation (prepareLaunch):
|
|
9
|
+
// • write the plugin source into <root>/state/opencode/<p>/ (re-written every launch — the
|
|
10
|
+
// installed copy is never trusted to be current);
|
|
11
|
+
// • compose OPENCODE_CONFIG_CONTENT: the operator's opencode.json + inline env config, the memory
|
|
12
|
+
// and capability fragments, then the core-required fields (agent.iapeer.prompt = the freshly
|
|
13
|
+
// composed doctrine, the iapeer MCP, the plugin) — doctrine is re-read on EVERY process launch,
|
|
14
|
+
// including resume;
|
|
15
|
+
// • the plugin ABI env: IAPEER_OPENCODE_DIR / _LAUNCH_ID / _SESSION (resume only).
|
|
16
|
+
//
|
|
17
|
+
// State is personality-scoped (not cwd-scoped: two personalities may share a cwd), so the
|
|
18
|
+
// observation methods take the optional ObserveContext; without it they answer null/not-ok.
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from 'node:crypto'
|
|
21
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import { composeOpencodeConfig, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
|
|
23
|
+
import { opencodeControl } from '../../opencode/control.ts'
|
|
24
|
+
import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
|
|
25
|
+
import { resolveDaemonMcpUrl } from '../../storage/index.ts'
|
|
26
|
+
import { resolveFleetBase } from '../../approval/brokerClient.ts'
|
|
27
|
+
import type {
|
|
28
|
+
ApprovalMode,
|
|
29
|
+
ControlCommand,
|
|
30
|
+
ControlPlan,
|
|
31
|
+
LaunchAdapterConfig,
|
|
32
|
+
LaunchSpec,
|
|
33
|
+
ObserveContext,
|
|
34
|
+
PreparedLaunch,
|
|
35
|
+
RuntimeAdapter,
|
|
36
|
+
} from '../types.ts'
|
|
37
|
+
|
|
38
|
+
/** Compaction is a model turn; bound it like a long turn, not like a socket round-trip. */
|
|
39
|
+
export const OPENCODE_COMPACT_TIMEOUT_MS = 600_000
|
|
40
|
+
|
|
41
|
+
/** Journal byte offset at prepare time, per launchID — structuralReady scans only THIS launch's lines. */
|
|
42
|
+
const readyOffsets = new Map<string, number>()
|
|
43
|
+
|
|
44
|
+
function newLaunchID(): string {
|
|
45
|
+
return `lch_${Date.now().toString(36)}${randomBytes(6).toString('hex')}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function paths(personality: string, env?: NodeJS.ProcessEnv) {
|
|
49
|
+
return opencodePaths(personality, { env })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function controlSocket(personality: string, env?: NodeJS.ProcessEnv): string {
|
|
53
|
+
return `${paths(personality, env).dir}/control.sock`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const opencodeAdapter: RuntimeAdapter = {
|
|
57
|
+
runtime: 'opencode',
|
|
58
|
+
kind: 'tui',
|
|
59
|
+
usesDoctrine: true,
|
|
60
|
+
// No pty submit surface: the core never pastes into the OpenCode TUI.
|
|
61
|
+
deliveryMarkers: { promptGlyphs: [] },
|
|
62
|
+
deliveryConfirm: 'structural',
|
|
63
|
+
|
|
64
|
+
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
65
|
+
const argv = [cfg.opencodeBin ?? 'opencode']
|
|
66
|
+
if (spec.resume && spec.resumeRef) argv.push('--session', spec.resumeRef)
|
|
67
|
+
argv.push('--agent', OPENCODE_AGENT)
|
|
68
|
+
// yolo (default) = the fleet's bypass posture; gated launches without it so native
|
|
69
|
+
// permission requests reach the approval path (docs/17, OC10).
|
|
70
|
+
if ((spec.approvalMode ?? 'yolo') === 'yolo') argv.push('--auto')
|
|
71
|
+
if (spec.extraArgs?.length) argv.push(...spec.extraArgs)
|
|
72
|
+
return argv
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
prepareLaunch(spec, baseEnv, cfg): PreparedLaunch {
|
|
76
|
+
const env = cfg.env ?? process.env
|
|
77
|
+
if (!spec.systemPromptFile || !existsSync(spec.systemPromptFile)) {
|
|
78
|
+
throw new Error(`opencode: composed system prompt missing (${spec.systemPromptFile ?? 'none'})`)
|
|
79
|
+
}
|
|
80
|
+
const doctrine = readFileSync(spec.systemPromptFile, 'utf8')
|
|
81
|
+
const p = paths(spec.personality, env)
|
|
82
|
+
writeOpencodePlugin(spec.personality, OPENCODE_PLUGIN_SOURCE, { env })
|
|
83
|
+
const config = composeOpencodeConfig(
|
|
84
|
+
{
|
|
85
|
+
personality: spec.personality,
|
|
86
|
+
doctrine,
|
|
87
|
+
daemonUrl: resolveDaemonMcpUrl({ env }),
|
|
88
|
+
inline: baseEnv.OPENCODE_CONFIG_CONTENT,
|
|
89
|
+
approvalMode: spec.approvalMode ?? 'yolo',
|
|
90
|
+
},
|
|
91
|
+
{ env },
|
|
92
|
+
)
|
|
93
|
+
const launchID = newLaunchID()
|
|
94
|
+
readyOffsets.set(launchID, existsSync(p.events) ? statSync(p.events).size : 0)
|
|
95
|
+
const out: Record<string, string> = {
|
|
96
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
|
|
97
|
+
IAPEER_OPENCODE_DIR: p.dir,
|
|
98
|
+
IAPEER_OPENCODE_LAUNCH_ID: launchID,
|
|
99
|
+
IAPEER_IDENTITY: spec.identity,
|
|
100
|
+
// A fleet peer never self-updates (config autoupdate:false says the same; the flag also covers
|
|
101
|
+
// native paths that read only the env). Updates are an operator action.
|
|
102
|
+
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
|
103
|
+
// docs/17: the plugin bridges native permission asks to the daemon broker only when gated.
|
|
104
|
+
IAPEER_APPROVAL_MODE: spec.approvalMode ?? 'yolo',
|
|
105
|
+
IAPEER_FLEET_URL: resolveFleetBase(env),
|
|
106
|
+
}
|
|
107
|
+
if (spec.resume && spec.resumeRef) out.IAPEER_OPENCODE_SESSION = spec.resumeRef
|
|
108
|
+
return { env: out, readyToken: launchID }
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async structuralReady(spec, launchID, deadlineMs, alive, env) {
|
|
112
|
+
const p = paths(spec.personality, env)
|
|
113
|
+
let offset = readyOffsets.get(launchID) ?? 0
|
|
114
|
+
try {
|
|
115
|
+
while (Date.now() < deadlineMs) {
|
|
116
|
+
const { lines, end } = readJournalFrom(p.events, offset)
|
|
117
|
+
offset = end
|
|
118
|
+
for (const l of lines) {
|
|
119
|
+
if (l.launchID !== launchID) continue
|
|
120
|
+
if (l.type === 'runtime.ready') {
|
|
121
|
+
// The control socket is up before readiness is journaled; one ping proves it serves.
|
|
122
|
+
const ping = await opencodeControl(controlSocket(spec.personality, env), { op: 'ping' }, 5000)
|
|
123
|
+
if (!ping.ok) return { ok: false, reason: `runtime.ready seen but control ping failed: ${ping.error}` }
|
|
124
|
+
if (ping.launchID !== launchID) return { ok: false, reason: `control socket answers launch ${ping.launchID}, expected ${launchID}` }
|
|
125
|
+
return { ok: true }
|
|
126
|
+
}
|
|
127
|
+
if (l.type === 'runtime.error') {
|
|
128
|
+
const e = (l.properties as { error?: unknown } | null)?.error
|
|
129
|
+
return { ok: false, reason: `runtime.error: ${typeof e === 'string' ? e : JSON.stringify(e)}` }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!alive()) return { ok: false, reason: 'process exited before runtime.ready' }
|
|
133
|
+
await new Promise(r => setTimeout(r, 500))
|
|
134
|
+
}
|
|
135
|
+
return { ok: false, reason: 'no runtime.ready before the boot deadline (plugin not loaded?)' }
|
|
136
|
+
} finally {
|
|
137
|
+
readyOffsets.delete(launchID)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async deliver(target, text, env) {
|
|
142
|
+
const r = await opencodeControl(controlSocket(target.personality, env), { op: 'deliver', text })
|
|
143
|
+
return r.ok ? { ok: true, messageID: r.messageID } : { ok: false, error: r.error ?? 'deliver failed' }
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async control(target, command, env) {
|
|
147
|
+
if (command.name === 'interrupt') {
|
|
148
|
+
const r = await opencodeControl(controlSocket(target.personality, env), { op: 'interrupt' })
|
|
149
|
+
return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'interrupt failed' }
|
|
150
|
+
}
|
|
151
|
+
if (command.name === 'compact') {
|
|
152
|
+
// Blocks until the native summary turn completed and session.compacted is recorded.
|
|
153
|
+
const r = await opencodeControl(controlSocket(target.personality, env), { op: 'compact' }, OPENCODE_COMPACT_TIMEOUT_MS)
|
|
154
|
+
return r.ok ? { ok: true } : { ok: false, error: r.error ?? 'compact failed' }
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, error: `opencode does not support control command "${command.name}"` }
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// ── Screen predicates: deliberately inert (structural runtime). ──────────────
|
|
160
|
+
bootDialogKeys(): string[] | null {
|
|
161
|
+
return null
|
|
162
|
+
},
|
|
163
|
+
isInputReady(_pane: string, _mode?: ApprovalMode): boolean {
|
|
164
|
+
return false
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
newestActivityMtime(_cwd: string, ctx?: ObserveContext): number | null {
|
|
168
|
+
return ctx ? fileMtimeMs(paths(ctx.personality, ctx.env).events) : null
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Busy/idle come from the plugin's state, not from a file mtime: lastActivityMs advances only on
|
|
172
|
+
* journaled native events. A busy/approval status counts as activity NOW (a long tool call). */
|
|
173
|
+
lastTurnMtime(_cwd: string, ctx?: ObserveContext): number | null {
|
|
174
|
+
if (!ctx) return null
|
|
175
|
+
const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
|
|
176
|
+
if (!s) return null
|
|
177
|
+
if (s.status === 'busy' || s.status === 'approval') return Date.now()
|
|
178
|
+
return s.lastActivityMs
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
resolveResume(_cwd: string, ctx?: ObserveContext): { ok: boolean; ref?: string; reason?: string } {
|
|
182
|
+
if (!ctx) return { ok: false, reason: 'opencode resume needs the personality (no observation context)' }
|
|
183
|
+
const s = readOpencodeState(paths(ctx.personality, ctx.env).state)
|
|
184
|
+
if (!s?.sessionID) return { ok: false, reason: `no recorded OpenCode session for "${ctx.personality}"` }
|
|
185
|
+
return { ok: true, ref: s.sessionID }
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// Control goes through `control` above; there is no pty key plan.
|
|
189
|
+
executeControl(_command: ControlCommand): ControlPlan | null {
|
|
190
|
+
return null
|
|
191
|
+
},
|
|
192
|
+
}
|
package/src/launch/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { telegramAdapter } from './adapters/telegram.ts'
|
|
|
25
25
|
import { notifierAdapter } from './adapters/notifier.ts'
|
|
26
26
|
import { voicetalkAdapter } from './adapters/voicetalk.ts'
|
|
27
27
|
import { webAdapter } from './adapters/web.ts'
|
|
28
|
+
import { opencodeAdapter } from './adapters/opencode.ts'
|
|
28
29
|
import type {
|
|
29
30
|
LaunchConfig,
|
|
30
31
|
LaunchFn,
|
|
@@ -97,6 +98,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
|
|
|
97
98
|
if (runtime === 'notifier') return notifierAdapter
|
|
98
99
|
if (runtime === 'voicetalk') return voicetalkAdapter
|
|
99
100
|
if (runtime === 'web') return webAdapter
|
|
101
|
+
if (runtime === 'opencode') return opencodeAdapter
|
|
100
102
|
throw new Error(`no launch adapter for runtime "${runtime}"`)
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -189,6 +191,19 @@ export const launch: LaunchFn = async (
|
|
|
189
191
|
// loads here, default-on — and the router branch never reaches it).
|
|
190
192
|
if (adapter.kind === 'tui' || adapter.kind === 'router') {
|
|
191
193
|
const invocation = buildLaunchInvocation(spec, adapter, cfg)
|
|
194
|
+
// Structural runtimes (opencode) prepare launch artifacts + extra env BEFORE spawn. The identity
|
|
195
|
+
// ABI stays authoritative: prepared env never overrides PEER_PERSONALITY/RUNTIME/IDENTITY.
|
|
196
|
+
let readyToken: string | undefined
|
|
197
|
+
if (adapter.prepareLaunch) {
|
|
198
|
+
try {
|
|
199
|
+
const prepared = adapter.prepareLaunch(spec, invocation.env, cfg)
|
|
200
|
+
const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
|
|
201
|
+
invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
|
|
202
|
+
readyToken = prepared.readyToken
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
192
207
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
|
|
193
208
|
const paneLogPath = `${cfg.logDir}/${identity}.log`
|
|
194
209
|
// SESSION-START BOUNDARY (premature-ready fix): the pane-log is a SHARED append-only file across a
|
|
@@ -219,6 +234,18 @@ export const launch: LaunchFn = async (
|
|
|
219
234
|
if (hostSessionAlive(identity, env)) return ready(identity)
|
|
220
235
|
return fail(identity, 'hosted router session not alive immediately after bringup')
|
|
221
236
|
}
|
|
237
|
+
// STRUCTURAL runtime (opencode): readiness = the runtime's own ready record for THIS launch, and
|
|
238
|
+
// the first message goes through the adapter's deliver, whose ack means THIS message is persisted
|
|
239
|
+
// natively (a durable native queue) — so there is no pane gate and no activity ready-gate.
|
|
240
|
+
if (adapter.structuralReady && readyToken) {
|
|
241
|
+
const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
|
|
242
|
+
if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
|
|
243
|
+
if (firstMessage.trim().length === 0) return ready(identity)
|
|
244
|
+
if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
|
|
245
|
+
const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
|
|
246
|
+
if (!d.ok) return fail(identity, `structural deliver failed: ${d.error}`)
|
|
247
|
+
return ready(identity)
|
|
248
|
+
}
|
|
222
249
|
// BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
|
|
223
250
|
// surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
|
|
224
251
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
|
@@ -234,7 +261,7 @@ export const launch: LaunchFn = async (
|
|
|
234
261
|
// alone push mtime past the baseline, so the gate below fires on the boot write — NOT on the
|
|
235
262
|
// model PROCESSING firstMessage — returning ready/taskDelivered while the message is unseen
|
|
236
263
|
// (silent loss). Snapped here — post-boot, pre-deliver — only the model's own turn advances it.
|
|
237
|
-
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
264
|
+
const baselineMtime = adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0
|
|
238
265
|
// INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
|
|
239
266
|
// session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
|
|
240
267
|
// paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
|
|
@@ -255,7 +282,7 @@ export const launch: LaunchFn = async (
|
|
|
255
282
|
while (Date.now() < readyDeadline) {
|
|
256
283
|
await sleep(2000)
|
|
257
284
|
if (!hostSessionAlive(identity, env)) return fail(identity, 'hosted session vanished during ready-gate')
|
|
258
|
-
if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
|
|
285
|
+
if ((adapter.newestActivityMtime(cwd, { personality: spec.personality, env }) ?? 0) > baselineMtime) return ready(identity)
|
|
259
286
|
}
|
|
260
287
|
return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
|
|
261
288
|
}
|