@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.
- 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 +79 -7
- package/src/core/constants.ts +1 -1
- package/src/daemon/fleet.ts +9 -0
- package/src/daemon/main.ts +21 -1
- 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
|
@@ -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
|
+
}
|
package/src/provision/index.ts
CHANGED
|
@@ -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(
|
|
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
|
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
|
}
|