@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,110 @@
|
|
|
1
|
+
// Keep npm OFF the OpenCode wake path (docs/20 §Native findings). Before loading any external
|
|
2
|
+
// plugin, native OpenCode (1.19.3 core/src/npm.ts Npm.install) reifies `@opencode-ai/plugin` in
|
|
3
|
+
// EVERY config directory it scans, unless that directory already has `node_modules` AND the name
|
|
4
|
+
// is in the root of its `package-lock.json` (the version is not re-checked once locked). A cold
|
|
5
|
+
// directory therefore costs one npm https round-trip at startup — observed 60 s and >4 min stalls.
|
|
6
|
+
// The core satisfies that exact no-op condition at runtime-attach time (an operator verb), so a
|
|
7
|
+
// wake never waits on the network.
|
|
8
|
+
//
|
|
9
|
+
// Directories (native ConfigPaths.directories): the global config dir ($OPENCODE_CONFIG_DIR, else
|
|
10
|
+
// $XDG_CONFIG_HOME/opencode, else ~/.config/opencode), every existing `.opencode` from the peer cwd
|
|
11
|
+
// upwards, and ~/.opencode (where the official installer puts the binary). Non-writable dirs are
|
|
12
|
+
// skipped natively and here.
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const OPENCODE_PLUGIN_PACKAGE = '@opencode-ai/plugin'
|
|
19
|
+
|
|
20
|
+
export function opencodeConfigDirs(cwd: string, env: NodeJS.ProcessEnv = process.env): string[] {
|
|
21
|
+
const home = env.HOME?.trim() || homedir()
|
|
22
|
+
const globalDir = env.OPENCODE_CONFIG_DIR?.trim() || join(env.XDG_CONFIG_HOME?.trim() || join(home, '.config'), 'opencode')
|
|
23
|
+
const dirs = [globalDir]
|
|
24
|
+
let dir = resolve(cwd)
|
|
25
|
+
while (true) {
|
|
26
|
+
const candidate = join(dir, '.opencode')
|
|
27
|
+
if (existsSync(candidate)) dirs.push(candidate)
|
|
28
|
+
const parent = dirname(dir)
|
|
29
|
+
if (parent === dir || dir === home) break
|
|
30
|
+
dir = parent
|
|
31
|
+
}
|
|
32
|
+
const homeDir = join(home, '.opencode')
|
|
33
|
+
if (existsSync(homeDir)) dirs.push(homeDir)
|
|
34
|
+
return [...new Set(dirs)]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The exact native no-op condition for one directory. */
|
|
38
|
+
export function pluginDependencySatisfied(dir: string): boolean {
|
|
39
|
+
if (!existsSync(join(dir, 'node_modules'))) return false
|
|
40
|
+
try {
|
|
41
|
+
const lock = JSON.parse(readFileSync(join(dir, 'package-lock.json'), 'utf8')) as { packages?: Record<string, Record<string, Record<string, string>>> }
|
|
42
|
+
const root = lock.packages?.[''] ?? {}
|
|
43
|
+
return [root.dependencies, root.devDependencies, root.peerDependencies, root.optionalDependencies].some(d => d && OPENCODE_PLUGIN_PACKAGE in d)
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writable(dir: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
accessSync(dir, constants.W_OK)
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DependencyOutcome {
|
|
59
|
+
dir: string
|
|
60
|
+
state: 'present' | 'installed' | 'skipped-readonly' | 'failed'
|
|
61
|
+
detail?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface EnsureDepsDeps {
|
|
65
|
+
/** Native binary version (the plugin package is pinned to it, as native does). */
|
|
66
|
+
version: () => string
|
|
67
|
+
/** Run `npm install` in `dir` (throws on failure). */
|
|
68
|
+
npmInstall: (dir: string, spec: string) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function defaultEnsureDepsDeps(bin: string, env: NodeJS.ProcessEnv): EnsureDepsDeps {
|
|
72
|
+
return {
|
|
73
|
+
version: () => execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 20000, env }).trim(),
|
|
74
|
+
npmInstall: (dir, spec) =>
|
|
75
|
+
void execFileSync('npm', ['install', '--no-audit', '--no-fund', '--save', spec], { cwd: dir, env, stdio: 'pipe', timeout: 300000 }),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Satisfy the native no-op condition in every config dir. Never throws; the caller decides. */
|
|
80
|
+
export function ensureOpencodePluginDependency(cwd: string, env: NodeJS.ProcessEnv, deps: EnsureDepsDeps): DependencyOutcome[] {
|
|
81
|
+
// Fail-closed sandbox guard (the IAPEER_TEST_SANDBOX contract): these dirs resolve from HOME, which
|
|
82
|
+
// the IAPEER_ROOT isolation does not cover — a test that forgot to isolate HOME would write into the
|
|
83
|
+
// operator's real OpenCode config (it happened once, 17.10).
|
|
84
|
+
if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') && resolve(env.HOME?.trim() || homedir()) === resolve(homedir())) {
|
|
85
|
+
throw new Error('IAPEER_TEST_SANDBOX: refusing to preinstall into the real HOME — inject an isolated HOME')
|
|
86
|
+
}
|
|
87
|
+
const dirs = opencodeConfigDirs(cwd, env)
|
|
88
|
+
mkdirSync(dirs[0]!, { recursive: true }) // the global config dir always exists natively
|
|
89
|
+
let version: string | undefined
|
|
90
|
+
const out: DependencyOutcome[] = []
|
|
91
|
+
for (const dir of dirs) {
|
|
92
|
+
if (pluginDependencySatisfied(dir)) {
|
|
93
|
+
out.push({ dir, state: 'present' })
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (!writable(dir)) {
|
|
97
|
+
out.push({ dir, state: 'skipped-readonly' })
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
version ??= deps.version()
|
|
102
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error(`unexpected opencode --version output: ${JSON.stringify(version)}`)
|
|
103
|
+
deps.npmInstall(dir, `${OPENCODE_PLUGIN_PACKAGE}@${version}`)
|
|
104
|
+
out.push(pluginDependencySatisfied(dir) ? { dir, state: 'installed' } : { dir, state: 'failed', detail: 'npm finished but the lock does not record the package' })
|
|
105
|
+
} catch (e) {
|
|
106
|
+
out.push({ dir, state: 'failed', detail: e instanceof Error ? e.message.split('\n')[0] : String(e) })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// iapeer core plugin for OpenCode — the core writes this file before every launch
|
|
2
|
+
// (src/opencode/iapeer-plugin.mjs in @agfpd/iapeer). Do not edit the installed copy.
|
|
3
|
+
//
|
|
4
|
+
// Responsibilities (docs/20-opencode.md):
|
|
5
|
+
// • the single PRODUCER of the observability ABI: events.jsonl (append-only,
|
|
6
|
+
// seq monotonic across restarts) + state.json (atomic; lastEventSeq is a fence);
|
|
7
|
+
// • the structural control surface: a unix socket the core uses for readiness,
|
|
8
|
+
// message delivery (acknowledged only once THIS message is persisted natively),
|
|
9
|
+
// interrupt and TUI session selection.
|
|
10
|
+
// Plain ESM, no dependencies: it runs inside OpenCode's own server worker.
|
|
11
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { createServer } from 'node:net'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const SCHEMA = 1
|
|
16
|
+
const DELIVER_ACK_MS = 15000
|
|
17
|
+
// Native events forwarded into the journal unchanged (type + properties).
|
|
18
|
+
const JOURNALED = new Set([
|
|
19
|
+
'message.updated',
|
|
20
|
+
'message.removed',
|
|
21
|
+
'message.part.updated',
|
|
22
|
+
'message.part.removed',
|
|
23
|
+
'message.part.delta',
|
|
24
|
+
'session.status',
|
|
25
|
+
'session.idle',
|
|
26
|
+
'session.error',
|
|
27
|
+
'session.created',
|
|
28
|
+
'session.updated',
|
|
29
|
+
'session.compacted',
|
|
30
|
+
'permission.asked',
|
|
31
|
+
'permission.replied',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
// Same layout as OpenCode's own ascending identifiers, so a core-chosen messageID
|
|
35
|
+
// sorts correctly in the native history.
|
|
36
|
+
const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
37
|
+
let idLast = 0
|
|
38
|
+
let idCounter = 0
|
|
39
|
+
function ascendingId(prefix) {
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
if (now !== idLast) {
|
|
42
|
+
idLast = now
|
|
43
|
+
idCounter = 0
|
|
44
|
+
}
|
|
45
|
+
idCounter++
|
|
46
|
+
const value = BigInt(now) * 0x1000n + BigInt(idCounter)
|
|
47
|
+
let time = ''
|
|
48
|
+
for (let i = 0; i < 6; i++) time += Number((value >> BigInt(40 - 8 * i)) & 0xffn).toString(16).padStart(2, '0')
|
|
49
|
+
const bytes = crypto.getRandomValues(new Uint8Array(14))
|
|
50
|
+
return prefix + '_' + time + Array.from(bytes, b => ID_CHARS[b % 62]).join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sessionOf(type, p) {
|
|
54
|
+
if (!p || typeof p !== 'object') return null
|
|
55
|
+
if (typeof p.sessionID === 'string') return p.sessionID
|
|
56
|
+
if (p.info && typeof p.info.sessionID === 'string') return p.info.sessionID
|
|
57
|
+
if (p.part && typeof p.part.sessionID === 'string') return p.part.sessionID
|
|
58
|
+
if (type.startsWith('session.') && p.info && typeof p.info.id === 'string') return p.info.id
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Last `seq` in an existing journal: read only the tail (the journal never shrinks). */
|
|
63
|
+
function lastSeq(path) {
|
|
64
|
+
if (!existsSync(path)) return 0
|
|
65
|
+
const fd = openSync(path, 'r')
|
|
66
|
+
try {
|
|
67
|
+
const size = fstatSync(fd).size
|
|
68
|
+
let span = 4096
|
|
69
|
+
while (true) {
|
|
70
|
+
const start = Math.max(0, size - span)
|
|
71
|
+
const buf = Buffer.alloc(size - start)
|
|
72
|
+
readSync(fd, buf, 0, buf.length, start)
|
|
73
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean)
|
|
74
|
+
// the first line may be cut unless we read from 0
|
|
75
|
+
for (let i = lines.length - 1; i >= (start === 0 ? 0 : 1); i--) {
|
|
76
|
+
try {
|
|
77
|
+
const seq = JSON.parse(lines[i]).seq
|
|
78
|
+
if (Number.isInteger(seq)) return seq
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
if (start === 0) return 0
|
|
82
|
+
span *= 4
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
closeSync(fd)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function server(input) {
|
|
90
|
+
const env = process.env
|
|
91
|
+
const dir = env.IAPEER_OPENCODE_DIR
|
|
92
|
+
const identity = env.PEER_IDENTITY
|
|
93
|
+
// Not launched by iapeer (or by an incompatible core): stay inert rather than guess.
|
|
94
|
+
if (!dir || !identity) return {}
|
|
95
|
+
const client = input.client
|
|
96
|
+
const launchID = env.IAPEER_OPENCODE_LAUNCH_ID || ascendingId('lch')
|
|
97
|
+
const paths = {
|
|
98
|
+
events: join(dir, 'events.jsonl'),
|
|
99
|
+
state: join(dir, 'state.json'),
|
|
100
|
+
socket: join(dir, 'control.sock'),
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
103
|
+
|
|
104
|
+
let seq = lastSeq(paths.events)
|
|
105
|
+
const state = {
|
|
106
|
+
schema: SCHEMA,
|
|
107
|
+
identity,
|
|
108
|
+
cwd: input.directory,
|
|
109
|
+
sessionID: env.IAPEER_OPENCODE_SESSION || null,
|
|
110
|
+
launchID,
|
|
111
|
+
startedAt: Date.now(),
|
|
112
|
+
lastEventSeq: seq,
|
|
113
|
+
lastActivityMs: Date.now(),
|
|
114
|
+
status: 'starting',
|
|
115
|
+
}
|
|
116
|
+
function writeState() {
|
|
117
|
+
const tmp = paths.state + '.' + process.pid + '.tmp'
|
|
118
|
+
writeFileSync(tmp, JSON.stringify(state) + '\n', { mode: 0o600 })
|
|
119
|
+
renameSync(tmp, paths.state)
|
|
120
|
+
}
|
|
121
|
+
function journal(type, properties, nativeID, sessionID) {
|
|
122
|
+
seq++
|
|
123
|
+
const line = { schema: SCHEMA, seq, time: Date.now(), identity, launchID, sessionID: sessionID ?? null, type, properties, nativeID: nativeID ?? null }
|
|
124
|
+
appendFileSync(paths.events, JSON.stringify(line) + '\n', { mode: 0o600 })
|
|
125
|
+
state.lastEventSeq = seq // fence: the line above is on disk before the state write below
|
|
126
|
+
state.lastActivityMs = line.time
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── session: exactly one per identity ──────────────────────────────────────
|
|
130
|
+
let ensuring
|
|
131
|
+
async function ensureSession() {
|
|
132
|
+
if (state.sessionID) return state.sessionID
|
|
133
|
+
ensuring ??= (async () => {
|
|
134
|
+
const res = await client.session.create({ body: { title: identity } })
|
|
135
|
+
if (!res.data || typeof res.data.id !== 'string') throw new Error('session.create failed: ' + JSON.stringify(res.error ?? null))
|
|
136
|
+
state.sessionID = res.data.id
|
|
137
|
+
return state.sessionID
|
|
138
|
+
})().finally(() => {
|
|
139
|
+
ensuring = undefined
|
|
140
|
+
})
|
|
141
|
+
return ensuring
|
|
142
|
+
}
|
|
143
|
+
async function selectInTui() {
|
|
144
|
+
if (!state.sessionID) return
|
|
145
|
+
await client.tui.publish({ body: { type: 'tui.session.select', properties: { sessionID: state.sessionID } } }).catch(() => {})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Model of the last assistant turn — compaction runs on it. Seeded from history on resume.
|
|
149
|
+
let lastModel = null
|
|
150
|
+
let compactedAfter = 0
|
|
151
|
+
function noteModel(info) {
|
|
152
|
+
if (info && info.role === 'assistant' && typeof info.providerID === 'string' && typeof info.modelID === 'string') {
|
|
153
|
+
lastModel = { providerID: info.providerID, modelID: info.modelID }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── message acknowledgements: resolved by the native persist event ──────────
|
|
158
|
+
const waiters = new Map()
|
|
159
|
+
function awaitPersisted(messageID) {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
waiters.delete(messageID)
|
|
163
|
+
reject(new Error('message ' + messageID + ' not persisted within ' + DELIVER_ACK_MS + 'ms'))
|
|
164
|
+
}, DELIVER_ACK_MS)
|
|
165
|
+
waiters.set(messageID, () => {
|
|
166
|
+
clearTimeout(timer)
|
|
167
|
+
waiters.delete(messageID)
|
|
168
|
+
resolve()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function handle(req) {
|
|
174
|
+
switch (req && req.op) {
|
|
175
|
+
case 'ping':
|
|
176
|
+
return { ok: true, identity, launchID, sessionID: state.sessionID, status: state.status, lastEventSeq: state.lastEventSeq }
|
|
177
|
+
case 'deliver': {
|
|
178
|
+
if (typeof req.text !== 'string' || !req.text) return { ok: false, error: 'deliver: empty text' }
|
|
179
|
+
const sessionID = await ensureSession()
|
|
180
|
+
const messageID = ascendingId('msg')
|
|
181
|
+
const persisted = awaitPersisted(messageID)
|
|
182
|
+
const res = await client.session.promptAsync({
|
|
183
|
+
path: { id: sessionID },
|
|
184
|
+
body: { messageID, agent: 'iapeer', parts: [{ type: 'text', text: req.text }] },
|
|
185
|
+
})
|
|
186
|
+
if (res.error) {
|
|
187
|
+
waiters.delete(messageID)
|
|
188
|
+
persisted.catch(() => {})
|
|
189
|
+
return { ok: false, error: 'prompt_async: ' + JSON.stringify(res.error) }
|
|
190
|
+
}
|
|
191
|
+
await persisted
|
|
192
|
+
return { ok: true, sessionID, messageID }
|
|
193
|
+
}
|
|
194
|
+
case 'interrupt': {
|
|
195
|
+
if (!state.sessionID) return { ok: false, error: 'no session' }
|
|
196
|
+
const res = await client.session.abort({ path: { id: state.sessionID } })
|
|
197
|
+
return res.error ? { ok: false, error: JSON.stringify(res.error) } : { ok: true }
|
|
198
|
+
}
|
|
199
|
+
case 'compact': {
|
|
200
|
+
// Native compaction (POST /session/:id/summarize) runs the summary turn to completion before
|
|
201
|
+
// it returns; the ack additionally requires the native `session.compacted` record for THIS
|
|
202
|
+
// session after the request (a silent no-op is never reported as success).
|
|
203
|
+
if (!state.sessionID) return { ok: false, error: 'no session' }
|
|
204
|
+
if (!lastModel) return { ok: false, error: 'nothing to compact (no assistant turn in this session yet)' }
|
|
205
|
+
const before = seq
|
|
206
|
+
const res = await client.session.summarize({ path: { id: state.sessionID }, body: { providerID: lastModel.providerID, modelID: lastModel.modelID } })
|
|
207
|
+
if (res.error) return { ok: false, error: 'summarize: ' + JSON.stringify(res.error) }
|
|
208
|
+
for (let i = 0; i < 50 && compactedAfter <= before; i++) await new Promise(r => setTimeout(r, 100))
|
|
209
|
+
if (compactedAfter <= before) return { ok: false, error: 'summarize returned but no session.compacted was recorded' }
|
|
210
|
+
return { ok: true, sessionID: state.sessionID, lastEventSeq: compactedAfter }
|
|
211
|
+
}
|
|
212
|
+
case 'select':
|
|
213
|
+
await selectInTui()
|
|
214
|
+
return { ok: true, sessionID: state.sessionID }
|
|
215
|
+
default:
|
|
216
|
+
return { ok: false, error: 'unknown op ' + JSON.stringify(req && req.op) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── human approval (gated, docs/17 + docs/20 OC10) ───────────────────────────
|
|
221
|
+
// Native 1.19.3 never calls the `permission.ask` plugin hook, so the bridge rides the
|
|
222
|
+
// `permission.asked` event: a blocking broker request per native permission, answered back
|
|
223
|
+
// through the native reply route. Every failure direction is reject (fail-safe). A human who
|
|
224
|
+
// answers the native TUI prompt first wins: `permission.replied` aborts the broker request,
|
|
225
|
+
// the daemon sees the transport close and clears the card from every face.
|
|
226
|
+
const gated = env.IAPEER_APPROVAL_MODE === 'gated'
|
|
227
|
+
const fleetUrl = env.IAPEER_FLEET_URL
|
|
228
|
+
const approvals = new Map()
|
|
229
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s)
|
|
230
|
+
function approvalCard(p) {
|
|
231
|
+
const patterns = Array.isArray(p.patterns) ? p.patterns.map(String) : []
|
|
232
|
+
const meta = p.metadata && typeof p.metadata === 'object' ? p.metadata : {}
|
|
233
|
+
const head = patterns.join('\n') || String(p.permission)
|
|
234
|
+
const extra = Object.keys(meta).length ? '\n\n' + clip(JSON.stringify(meta, null, 2), 6000) : ''
|
|
235
|
+
return { tool: String(p.permission), content: clip(head, 2000) + extra, summary: clip(`${p.permission}: ${patterns.join(' ') || '?'}`, 200) }
|
|
236
|
+
}
|
|
237
|
+
async function replyNative(p, response) {
|
|
238
|
+
await client
|
|
239
|
+
.postSessionIdPermissionsPermissionId({ path: { id: p.sessionID, permissionID: p.id }, body: { response } })
|
|
240
|
+
.catch(() => {}) // already answered (human in the TUI) → native NotFound; nothing to do
|
|
241
|
+
}
|
|
242
|
+
async function brokerApproval(p) {
|
|
243
|
+
const controller = new AbortController()
|
|
244
|
+
approvals.set(p.id, controller)
|
|
245
|
+
let response = 'reject'
|
|
246
|
+
try {
|
|
247
|
+
if (!fleetUrl) throw new Error('no IAPEER_FLEET_URL')
|
|
248
|
+
const headers = { 'content-type': 'application/json' }
|
|
249
|
+
if (env.IAPEER_BEARER_TOKEN) headers.authorization = 'Bearer ' + env.IAPEER_BEARER_TOKEN
|
|
250
|
+
const card = approvalCard(p)
|
|
251
|
+
const res = await fetch(fleetUrl + '/fleet/v1/approvals', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers,
|
|
254
|
+
body: JSON.stringify({ personality: env.PEER_PERSONALITY, runtime: 'opencode', kind: 'tool', ...card }),
|
|
255
|
+
signal: controller.signal,
|
|
256
|
+
})
|
|
257
|
+
if (!res.ok) throw new Error('broker ' + res.status)
|
|
258
|
+
const d = await res.json()
|
|
259
|
+
if (d && d.decision === 'allow') response = 'once'
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (controller.signal.aborted) return // the human answered natively; never double-reply
|
|
262
|
+
} finally {
|
|
263
|
+
approvals.delete(p.id)
|
|
264
|
+
}
|
|
265
|
+
await replyNative(p, response)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── control socket ──────────────────────────────────────────────────────────
|
|
269
|
+
rmSync(paths.socket, { force: true })
|
|
270
|
+
const control = createServer(conn => {
|
|
271
|
+
let buf = ''
|
|
272
|
+
conn.setEncoding('utf8')
|
|
273
|
+
conn.on('data', chunk => {
|
|
274
|
+
buf += chunk
|
|
275
|
+
const nl = buf.indexOf('\n')
|
|
276
|
+
if (nl < 0) return
|
|
277
|
+
const line = buf.slice(0, nl)
|
|
278
|
+
buf = ''
|
|
279
|
+
let req
|
|
280
|
+
try {
|
|
281
|
+
req = JSON.parse(line)
|
|
282
|
+
} catch {
|
|
283
|
+
conn.end(JSON.stringify({ ok: false, error: 'bad request' }) + '\n')
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
handle(req).then(
|
|
287
|
+
r => conn.end(JSON.stringify(r) + '\n'),
|
|
288
|
+
e => conn.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }) + '\n'),
|
|
289
|
+
)
|
|
290
|
+
})
|
|
291
|
+
conn.on('error', () => {})
|
|
292
|
+
})
|
|
293
|
+
await new Promise((resolve, reject) => {
|
|
294
|
+
control.once('error', reject)
|
|
295
|
+
control.listen(paths.socket, () => resolve())
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// Readiness runs AFTER init returns: calling the in-process client while OpenCode is
|
|
299
|
+
// still loading this plugin would wait on the very instance state being built.
|
|
300
|
+
// The socket is up and the session is known (fresh: created now) → runtime.ready —
|
|
301
|
+
// the core's readiness signal, never a screen glyph.
|
|
302
|
+
setTimeout(async () => {
|
|
303
|
+
const resumed = Boolean(state.sessionID)
|
|
304
|
+
try {
|
|
305
|
+
const sessionID = await ensureSession()
|
|
306
|
+
const got = await client.session.get({ path: { id: sessionID } })
|
|
307
|
+
if (!got.data) throw new Error('session ' + sessionID + ' not found: ' + JSON.stringify(got.error ?? null))
|
|
308
|
+
if (resumed) {
|
|
309
|
+
const hist = await client.session.messages({ path: { id: sessionID } }).catch(() => null)
|
|
310
|
+
for (const m of (hist && hist.data) || []) noteModel(m && m.info)
|
|
311
|
+
}
|
|
312
|
+
if (state.status === 'starting') state.status = 'idle'
|
|
313
|
+
journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
|
|
314
|
+
} catch (e) {
|
|
315
|
+
state.status = 'error'
|
|
316
|
+
state.error = e instanceof Error ? e.message : String(e)
|
|
317
|
+
journal('runtime.error', { error: state.error }, null, state.sessionID)
|
|
318
|
+
}
|
|
319
|
+
writeState()
|
|
320
|
+
// The TUI may not be subscribed yet; the core re-selects on demand via op:select.
|
|
321
|
+
setTimeout(() => void selectInTui(), 500)
|
|
322
|
+
}, 0)
|
|
323
|
+
writeState()
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
event: async ({ event }) => {
|
|
327
|
+
const type = event && event.type
|
|
328
|
+
if (!JOURNALED.has(type)) return
|
|
329
|
+
const p = event.properties
|
|
330
|
+
const sessionID = sessionOf(type, p)
|
|
331
|
+
if (type === 'message.updated' && p && p.info && waiters.has(p.info.id)) waiters.get(p.info.id)()
|
|
332
|
+
if (type === 'message.updated' && p && sessionID === state.sessionID) noteModel(p.info)
|
|
333
|
+
if (gated && type === 'permission.asked' && p && typeof p.id === 'string' && !approvals.has(p.id)) void brokerApproval(p)
|
|
334
|
+
if (type === 'permission.replied' && p && approvals.has(p.requestID)) approvals.get(p.requestID).abort()
|
|
335
|
+
if (sessionID && sessionID === state.sessionID) {
|
|
336
|
+
if (type === 'session.status' && p.status) {
|
|
337
|
+
state.status = p.status.type === 'busy' || p.status.type === 'retry' ? 'busy' : p.status.type === 'idle' ? 'idle' : state.status
|
|
338
|
+
delete state.error
|
|
339
|
+
} else if (type === 'session.idle') state.status = 'idle'
|
|
340
|
+
else if (type === 'permission.asked') state.status = 'approval'
|
|
341
|
+
else if (type === 'permission.replied' && state.status === 'approval') state.status = 'busy'
|
|
342
|
+
else if (type === 'session.error') {
|
|
343
|
+
state.status = 'error'
|
|
344
|
+
state.error = JSON.stringify(p.error ?? null)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
journal(type, p, event.id ?? null, sessionID)
|
|
348
|
+
if (type === 'session.compacted' && sessionID === state.sessionID) compactedAfter = seq
|
|
349
|
+
writeState()
|
|
350
|
+
},
|
|
351
|
+
dispose: async () => {
|
|
352
|
+
for (const c of approvals.values()) c.abort()
|
|
353
|
+
state.status = 'stopped'
|
|
354
|
+
journal('runtime.stopped', { reason: 'dispose' }, null, state.sessionID)
|
|
355
|
+
writeState()
|
|
356
|
+
await new Promise(resolve => control.close(() => resolve()))
|
|
357
|
+
rmSync(paths.socket, { force: true })
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export default { id: 'iapeer', server }
|
|
@@ -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
|