@agfpd/iapeer 0.4.114 → 0.4.115
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 +6 -3
- package/docs/20-opencode.md +226 -0
- package/package.json +1 -1
- package/src/daemon/fleet.ts +10 -0
- package/src/opencode/config.ts +102 -0
- package/src/opencode/control.ts +55 -0
- package/src/opencode/iapeer-plugin.d.mts +4 -0
- package/src/opencode/iapeer-plugin.mjs +283 -0
package/docs/15-fleet-api.md
CHANGED
|
@@ -38,7 +38,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
38
38
|
"uptimeSecs": 32400,
|
|
39
39
|
"memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
|
|
40
40
|
"voice": { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
|
|
41
|
-
"fda": true
|
|
41
|
+
"fda": true,
|
|
42
|
+
"agent_runtimes": ["claude", "codex"]
|
|
42
43
|
},
|
|
43
44
|
"peers": [
|
|
44
45
|
{
|
|
@@ -48,8 +49,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
48
49
|
"default_runtime": "claude",
|
|
49
50
|
"cwd": "/Users/me/.iapeer/peers/boris",
|
|
50
51
|
"runtimes": [
|
|
51
|
-
{ "runtime": "claude", "status": "live", "attached": true, "activity": "working" },
|
|
52
|
-
{ "runtime": "codex", "status": "asleep" }
|
|
52
|
+
{ "runtime": "claude", "status": "live", "agent": true, "attached": true, "activity": "working" },
|
|
53
|
+
{ "runtime": "codex", "status": "asleep", "agent": true }
|
|
53
54
|
],
|
|
54
55
|
"last_active_runtime": "claude",
|
|
55
56
|
"last_active_ms": 1751731100000,
|
|
@@ -77,6 +78,8 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
77
78
|
Field notes:
|
|
78
79
|
|
|
79
80
|
- `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
|
|
81
|
+
- `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
|
|
82
|
+
- `host.agent_runtimes` — the agent runtimes this core build can host, in preference order. It grows when an agent adapter ships (e.g. `opencode`, docs/20).
|
|
80
83
|
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
|
|
81
84
|
- `runtimes[].activity` — **turn activity**: is this runtime *in a turn* right now. See «Activity» below. Additive; absent on a pre-activity daemon, on every non-`live` runtime, and whenever the daemon has no evidence — clients MUST treat absence as **no claim**, never as `idle`.
|
|
82
85
|
- `attached` — any runtime attached (peer-level convenience).
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# 20 — OpenCode agent runtime
|
|
2
|
+
|
|
3
|
+
**Implementation in progress; not a released capability.** This is the acceptance
|
|
4
|
+
contract, not a claim that the matrix below has passed. Existing Claude/Codex
|
|
5
|
+
behaviour and the signing-phase activation restrictions remain unchanged.
|
|
6
|
+
|
|
7
|
+
## Ownership and mechanism
|
|
8
|
+
|
|
9
|
+
OpenCode is an **agent adapter**, not an infrastructure router. Its native process
|
|
10
|
+
and TUI belong to the existing pty supervisor; wake, locks, crash guard, idle-reap,
|
|
11
|
+
resume/fresh choice, identity, routing and operator commands stay foundation-owned.
|
|
12
|
+
It does not acquire a launchd KeepAlive plist or bypass H4.
|
|
13
|
+
|
|
14
|
+
Provider and model configuration belongs to OpenCode configuration/launch.env.
|
|
15
|
+
NVIDIA is one provider, not a dependency of the adapter. Credentials are environment
|
|
16
|
+
references or native credential storage, never embedded in generated artifacts.
|
|
17
|
+
|
|
18
|
+
The native `agent.iapeer.prompt` replaces the provider's built-in base prompt.
|
|
19
|
+
The native environment, tools and project-instruction channels remain enabled, as
|
|
20
|
+
they do for Claude/Codex. A user-message prefix is not a system-prompt replacement.
|
|
21
|
+
The file is re-composed on every process launch, including resume; live processes
|
|
22
|
+
keep their launch snapshot. Foreign config/plugins are preserved, not overwritten.
|
|
23
|
+
OpenCode's inline config **merges** other sources; managed config/MDM can override
|
|
24
|
+
it. Effective-config checks must refuse a conflicting required setting rather
|
|
25
|
+
than claim success, erase foreign settings, or circumvent an administrative gate.
|
|
26
|
+
|
|
27
|
+
Structural delivery/readiness/control use the native plugin SDK, not a guessed
|
|
28
|
+
screen glyph or a successful process spawn. The core-owned plugin must be present
|
|
29
|
+
and healthy before incoming delivery. The native TUI remains the attach surface.
|
|
30
|
+
An acknowledgement means the **specific message** is persisted/accepted, not merely
|
|
31
|
+
that bytes reached a socket. Message IDs, session IDs, tool events and errors must
|
|
32
|
+
remain available to history/observability clients. No mandatory command may succeed
|
|
33
|
+
as a silent no-op.
|
|
34
|
+
|
|
35
|
+
## Memory-provider ABI (agreed with iapeer-memory)
|
|
36
|
+
|
|
37
|
+
- Existing slot `provision-peer --personality <p> --runtime opencode --cwd <cwd>`.
|
|
38
|
+
- Canonical child ABI: `PEER_PERSONALITY`, `PEER_RUNTIME=opencode`,
|
|
39
|
+
`PEER_IDENTITY=opencode-<p>`; `IAPEER_IDENTITY` is an alias set by the core.
|
|
40
|
+
- Private directory: `<IAPEER_ROOT>/peers/<p>/opencode/` (default root `~/.iapeer`).
|
|
41
|
+
Memory owns `iapeer-memory.json` and `iapeer-memory-plugin.mjs` there. The core
|
|
42
|
+
reads the JSON fragment on each launch and merges its `mcp` and `plugin` fields
|
|
43
|
+
into runtime overrides. Missing means no memory surfaces; malformed/present
|
|
44
|
+
means an explicit failure. Other keys are rejected, not given core authority.
|
|
45
|
+
- MCP fragment uses `type: remote`, `oauth: false`, and
|
|
46
|
+
`headers: {"X-IAPeer-Identity": "{env:PEER_IDENTITY}"}`. Memory owns its MCP and
|
|
47
|
+
write-attribution hooks; foundation owns IAP, doctrine and approvals.
|
|
48
|
+
- Guide/index remain ordinary global/local `.iapeer/fragments` input to the
|
|
49
|
+
foundation composer. They are not duplicated in a separate OpenCode memory.
|
|
50
|
+
- Unprovision receives `--personality` as well as `--cwd` and `--runtime` so removal
|
|
51
|
+
cannot target another personality sharing a working directory.
|
|
52
|
+
|
|
53
|
+
## Observability ABI (agreed with web-runtime, telegram-runtime, statusline-injector)
|
|
54
|
+
|
|
55
|
+
**Contract under implementation — no live producer yet.** Consumers must not treat
|
|
56
|
+
a synthetic fixture as proof of the producer; the core ships a native source harness.
|
|
57
|
+
|
|
58
|
+
The core is the **single producer**. Consumers read only these files, never the
|
|
59
|
+
native OpenCode SQLite DB or a second SDK client:
|
|
60
|
+
|
|
61
|
+
- `<IAPEER_ROOT>/peers/<p>/opencode/events.jsonl` — append-only journal, one JSON
|
|
62
|
+
object per line:
|
|
63
|
+
`{schema:1, seq, time:<epoch ms>, identity:"opencode-<p>", launchID,
|
|
64
|
+
sessionID:<native ses_…|null>, type, properties, nativeID:<event.id|null>}`.
|
|
65
|
+
- `seq` is monotonic across process restarts and resumes. The journal is never
|
|
66
|
+
rotated or renamed on fresh. Consumers dedup by `seq`.
|
|
67
|
+
- Native events are passed through **unchanged** (`type` + `properties`):
|
|
68
|
+
`message.updated` (info: tokens, cost, modelID, providerID, time.completed),
|
|
69
|
+
`message.part.updated` (the full part with id, tool args/output),
|
|
70
|
+
`message.part.delta`, `session.status`, `session.error`, `session.created`,
|
|
71
|
+
`session.compacted`, `permission.asked`/`permission.replied`.
|
|
72
|
+
- Core events: `runtime.ready` `{session:<native session info>, resumed:boolean}`;
|
|
73
|
+
`runtime.stopped` `{reason, exitCode?:number, signal?:string}`. A resume emits
|
|
74
|
+
`runtime.ready` with the existing session; it never fabricates `session.created`.
|
|
75
|
+
**Every process launch that reaches readiness writes exactly one `runtime.ready`**
|
|
76
|
+
with a new `launchID`, including resume, so the main session is recoverable from
|
|
77
|
+
the journal alone. A launch that fails before readiness writes `runtime.error`
|
|
78
|
+
`{error}` under its `launchID` instead.
|
|
79
|
+
- Consumers: web-runtime, telegram-runtime, statusline-injector and iapeer-memory's
|
|
80
|
+
weekly collector (it relies on the path, `time`, unchanged `type`/`properties` and
|
|
81
|
+
no rotation). Changing any of these requires notifying all four.
|
|
82
|
+
- Native bootstrap snapshots can repeat part ids, so consumers must **upsert parts by id**.
|
|
83
|
+
- `<IAPEER_ROOT>/peers/<p>/opencode/state.json`, written atomically:
|
|
84
|
+
`{schema:1, identity, cwd, sessionID, launchID, startedAt, lastEventSeq,
|
|
85
|
+
lastActivityMs, status:"starting|idle|busy|approval|error|stopped", error?}`.
|
|
86
|
+
- `lastEventSeq` is a fence: every journal line with `seq ≤ lastEventSeq` is
|
|
87
|
+
durable on disk before the state write.
|
|
88
|
+
- Busy/idle comes from `status`, not from the pane-log mtime.
|
|
89
|
+
- An abrupt process death (SIGKILL) is recorded by the core supervisor; the
|
|
90
|
+
in-process plugin alone cannot promise `stopped`. A stale `busy` after a death
|
|
91
|
+
is resolved from supervisor liveness.
|
|
92
|
+
- Session identity: exactly one native session per personality. Fresh starts a new
|
|
93
|
+
ID; resume keeps the same one.
|
|
94
|
+
- Turn completion: correlate assistant `info.time.completed` with terminal tool
|
|
95
|
+
parts. `session.status` idle is a native observation only: it does not guarantee
|
|
96
|
+
that no event is still in flight. The live ordering is still to be verified.
|
|
97
|
+
- Control (`interrupt`/`new`/`compact`) and approvals use the existing Fleet API and
|
|
98
|
+
approval faces. There is no new face API.
|
|
99
|
+
- Endpoint selection: send and control from one channel dialogue anchor on the
|
|
100
|
+
same endpoint. A send without a runtime resolves to the profile's
|
|
101
|
+
`default_runtime`. `delivery.delivered_to.runtime` is authoritative for activity.
|
|
102
|
+
The live runtime is not a selector when two endpoints are live.
|
|
103
|
+
|
|
104
|
+
Open core contracts this ABI depends on:
|
|
105
|
+
|
|
106
|
+
- **Agent-endpoint discovery** — the declared profile runtimes plus the agent
|
|
107
|
+
adapters the core supports, for channel menus. Not `Bun.which` and not a
|
|
108
|
+
consumer-side hardcode.
|
|
109
|
+
- **Capability-plugin lifecycle** — a stable declaration through which a
|
|
110
|
+
registered capability plugin (e.g. statusline-injector) gets
|
|
111
|
+
provision/unprovision per peer and runtime, like the memory slot. Until it
|
|
112
|
+
exists, a manual install is not reported as final.
|
|
113
|
+
|
|
114
|
+
## Acceptance matrix
|
|
115
|
+
|
|
116
|
+
`OPEN` includes implementation and/or missing live proof. Unit tests, config-file
|
|
117
|
+
existence and isolated SDK probes alone do not close an end-to-end row. Row IDs are
|
|
118
|
+
stable interfaces for the independent acceptance harness.
|
|
119
|
+
|
|
120
|
+
| ID | Mandatory invariant and normal triggers | Owner / evidence required | State |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| OC01 | create/init/add-runtime; discovery intelligence gate; no default-runtime migration | core; cold peer and shared-cwd contrast | OPEN |
|
|
123
|
+
| OC02 | explicit/default endpoint, simultaneous runtimes, exact per-request identity | core; real MCP headers and routing contrast | OPEN |
|
|
124
|
+
| OC03 | cold/warm inbound, busy queue, attachments, response, no loss/duplicate | core; native records, IAP history, 3/3 full cycles | OPEN |
|
|
125
|
+
| OC04 | process launch, MCP import, actual readiness, missing/invalid config/plugin failure | core; native process and effective-config contrast | OPEN |
|
|
126
|
+
| OC05 | attach/detach, human input coexistence, full native history | core + channel clients; actual PTY and history replay | OPEN |
|
|
127
|
+
| OC06 | stop/start, idle-reap/resume, refresh/new/self-fresh/self-done | core; each original trigger and same/fresh session IDs | OPEN |
|
|
128
|
+
| OC07 | abrupt death, crash-loop guard, restart/recovery, durable queued work | core; actual death and recovery, not synthetic mid-pipeline events | OPEN |
|
|
129
|
+
| OC08 | base system swap, native instructions, fresh doctrine on resumed history | core; wire capture before/after full process restart | MECHANISM VERIFIED; adapter proof OPEN |
|
|
130
|
+
| OC09 | memory MCP, shared personality, guide/index, native write/edit/patch authorship, removal | memory provider + core slot; isolated vault and actual tool writes | OPEN |
|
|
131
|
+
| OC10 | gated/yolo, native risky action → broker allow/deny/timeout/cancel; direct attach race | core + approval faces; real risky triggers, no production cards | OPEN |
|
|
132
|
+
| OC11 | interrupt and compact, completion/failure detection; unsupported runtime-specific commands fail loud | core; native turn interruption/compaction records | OPEN |
|
|
133
|
+
| OC12 | native skills and MCP extensions; foreign configuration preserved | core + capability owners; actual tool/skill calls | OPEN |
|
|
134
|
+
| OC13 | state, busy/idle/tool work, errors/notices, usage where available, full owner-channel history | core event contract + web/telegram/statusline owners | OPEN |
|
|
135
|
+
| OC14 | install/update/remove/rollback; signature/exec failure preserves previous state | core + provider installers; exact installed published artifact | OPEN |
|
|
136
|
+
| OC15 | provider-independent config and NVIDIA synthetic cloud trial | core + linus; native model/tool behaviour, no fleet secrets/memory | OPEN |
|
|
137
|
+
| OC16 | Claude/Codex and H4/path-isolation regressions | core + PM; hermetic suite and coordinated native regression | OPEN |
|
|
138
|
+
|
|
139
|
+
Codex thread goals and third-party Claude/Codex marketplace plugins are not
|
|
140
|
+
cross-runtime guarantees. Their availability is reported explicitly. Native
|
|
141
|
+
OpenCode skills, core MCP and the mandatory memory/approval surfaces are required;
|
|
142
|
+
calling those optional would not satisfy this contract. Unavailable provider
|
|
143
|
+
metrics are null/unknown, never fabricated zeroes.
|
|
144
|
+
|
|
145
|
+
## Verified mechanism baseline — 2026-10-17
|
|
146
|
+
|
|
147
|
+
Native npm `opencode-darwin-arm64@1.19.3`, executable SHA-256
|
|
148
|
+
`6d3e6458fce4b649edf974c19959d5dbfe1e7f255a503dcfc01215b24ad3ea5b`.
|
|
149
|
+
The official source tag `v1.19.3` resolves to
|
|
150
|
+
`e3c4eb242585dcd77be1a867391b7c25fd47ce52`; this is a source reference, not
|
|
151
|
+
cryptographic provenance of the npm binary (npm metadata has no gitHead).
|
|
152
|
+
|
|
153
|
+
The native server was started against a local synthetic OpenAI-compatible provider,
|
|
154
|
+
fresh HOME/XDG directories and a synthetic working directory. The first wire system
|
|
155
|
+
starts with doctrine ONE; after full process stop/start, the same session's next
|
|
156
|
+
system starts with doctrine TWO and contains no ONE. Both user turns survive;
|
|
157
|
+
native AGENTS.md remains in both system messages. This establishes swap/resume
|
|
158
|
+
semantics, not fleet delivery or the acceptance cycles. Production and NVIDIA were
|
|
159
|
+
not contacted. The original probe retained the two requests and native events;
|
|
160
|
+
the reproducible packaged proof is part of the implementation deliverable.
|
|
161
|
+
|
|
162
|
+
Official references (checked against native 1.19.3, not assumed from latest docs):
|
|
163
|
+
[CLI](https://opencode.ai/docs/cli/),
|
|
164
|
+
[agents](https://opencode.ai/docs/agents/),
|
|
165
|
+
[plugins](https://opencode.ai/docs/plugins/),
|
|
166
|
+
[MCP](https://opencode.ai/docs/mcp-servers/),
|
|
167
|
+
[config precedence](https://opencode.ai/docs/config/#precedence-order),
|
|
168
|
+
[NVIDIA](https://opencode.ai/docs/providers/#nvidia),
|
|
169
|
+
[native prompt selection](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/session/llm/request.ts),
|
|
170
|
+
[native permission lifecycle](https://github.com/anomalyco/opencode/blob/v1.19.3/packages/opencode/src/permission/index.ts).
|
|
171
|
+
|
|
172
|
+
## Core plugin and native harness — 2026-10-17
|
|
173
|
+
|
|
174
|
+
`src/opencode/iapeer-plugin.mjs` is the core-owned plugin. It is embedded in the
|
|
175
|
+
binary as text and written to `<dir>/iapeer-plugin.mjs` before every launch. Its
|
|
176
|
+
launch environment is `IAPEER_OPENCODE_DIR`, `PEER_IDENTITY`,
|
|
177
|
+
`IAPEER_OPENCODE_LAUNCH_ID` and, for resume, `IAPEER_OPENCODE_SESSION`. It produces
|
|
178
|
+
the observability ABI above and serves `<dir>/control.sock`
|
|
179
|
+
(`src/opencode/control.ts`). The socket takes one JSON line per connection:
|
|
180
|
+
- `ping` returns the identity, session and status;
|
|
181
|
+
- `deliver {text}` acknowledges `{messageID}` only after the native
|
|
182
|
+
`message.updated` for that exact core-chosen ID is observed;
|
|
183
|
+
- `interrupt` runs the native abort;
|
|
184
|
+
- `select` publishes `tui.session.select`.
|
|
185
|
+
|
|
186
|
+
Readiness runs after plugin init returns, because calling the in-process client
|
|
187
|
+
during plugin loading would wait on the instance being built.
|
|
188
|
+
|
|
189
|
+
`scripts/opencode-native-harness.ts` runs the real binary (`opencode serve`) with a
|
|
190
|
+
synthetic provider and an isolated HOME and root. Run of 2026-10-17 on native 1.19.3:
|
|
191
|
+
16/16 checks passed.
|
|
192
|
+
- Readiness and ping.
|
|
193
|
+
- Idle delivery acknowledged after persistence; doctrine on the wire.
|
|
194
|
+
- A delivery during a running slow turn was acknowledged in 8 ms and processed after
|
|
195
|
+
that turn. The native queue is durable.
|
|
196
|
+
- Interrupt.
|
|
197
|
+
- Contiguous `seq` and the state fence.
|
|
198
|
+
- Full process restart with `--session`: same session, `resumed=true`, `seq`
|
|
199
|
+
continues, the new doctrine replaces the old one, native `AGENTS.md` and earlier
|
|
200
|
+
history are kept.
|
|
201
|
+
|
|
202
|
+
This proves the producer and plugin mechanics under `serve`. It does not prove the
|
|
203
|
+
TUI-under-supervisor adapter, matrix rows or acceptance cycles.
|
|
204
|
+
|
|
205
|
+
Native findings that bind the adapter:
|
|
206
|
+
- **Cold config dir means npm on the critical path.** Before loading any external
|
|
207
|
+
plugin, OpenCode runs `npm install @opencode-ai/plugin@<version>` in each config
|
|
208
|
+
dir that lacks it (`Config.waitForDependencies`). On 17.10 this took about 60 s
|
|
209
|
+
on one run and hung for over 4 min on others: a single npm https connection
|
|
210
|
+
stalled. The adapter must install and verify this dependency at
|
|
211
|
+
`install-runtime`/update time, not on the wake path. The harness uses
|
|
212
|
+
`--seed-config` for offline determinism.
|
|
213
|
+
- **`serve` builds the directory instance lazily** (on the first request); the TUI
|
|
214
|
+
does it at startup.
|
|
215
|
+
- **A SIGTERM during startup can leave the process running.** It killed the npm
|
|
216
|
+
child, then initialization continued. Stop must escalate TERM → KILL. The plugin's
|
|
217
|
+
`dispose` does not run on a signal, so `runtime.stopped` for a signal death is the
|
|
218
|
+
supervisor's job.
|
|
219
|
+
|
|
220
|
+
## Delivery gate
|
|
221
|
+
|
|
222
|
+
Source changes and isolated native artifacts are allowed. npm publication and host
|
|
223
|
+
activation are separate coordinated stages. Neither this document nor the runtime
|
|
224
|
+
task authorizes production START, neighbouring service restarts, auth/MFA changes,
|
|
225
|
+
or a signing-gate bypass. Final acceptance uses the exact installed published
|
|
226
|
+
bytes and a PM-owned activation window with recovery instructions.
|
package/package.json
CHANGED
package/src/daemon/fleet.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
37
37
|
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
|
+
import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
|
|
38
39
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
39
40
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
40
41
|
import {
|
|
@@ -79,6 +80,11 @@ export const FLEET_API_VERSION = 1
|
|
|
79
80
|
export interface FleetRuntimeStatus {
|
|
80
81
|
runtime: string
|
|
81
82
|
status: 'live' | 'asleep' | 'stopped'
|
|
83
|
+
/** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
|
|
84
|
+
* agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
|
|
85
|
+
* `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
|
|
86
|
+
* A declared runtime the running core cannot host (newer peer, older core) is `false`. */
|
|
87
|
+
agent: boolean
|
|
82
88
|
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
83
89
|
attached?: boolean
|
|
84
90
|
/** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
|
|
@@ -125,6 +131,8 @@ export interface FleetHost {
|
|
|
125
131
|
memory: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
126
132
|
voice: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
127
133
|
fda: boolean | null
|
|
134
|
+
/** The agent runtimes this core build can host (its agent adapters), in preference order. */
|
|
135
|
+
agent_runtimes: string[]
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
/** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
|
|
@@ -238,6 +246,7 @@ export function buildFleetSnapshot(
|
|
|
238
246
|
return {
|
|
239
247
|
runtime: s.runtime,
|
|
240
248
|
status: s.status,
|
|
249
|
+
agent: isSupportedLocalRuntime(s.runtime),
|
|
241
250
|
...(attached ? { attached: true } : {}),
|
|
242
251
|
...(activity ? { activity } : {}),
|
|
243
252
|
}
|
|
@@ -289,6 +298,7 @@ export function buildFleetSnapshot(
|
|
|
289
298
|
? { provider: voice.provider, version: voice.version, heartbeatAgeSecs: heartbeatAgeSecs(voice, nowMs) }
|
|
290
299
|
: null,
|
|
291
300
|
fda: probeFullDiskAccess(env),
|
|
301
|
+
agent_runtimes: [...SUPPORTED_LOCAL_RUNTIMES],
|
|
292
302
|
},
|
|
293
303
|
peers,
|
|
294
304
|
// ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Private, personality-scoped OpenCode configuration. This module is preparatory:
|
|
2
|
+
// declaring a runtime as supported is gated on the complete adapter, not this file.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { isValidName } from '../core/constants.ts'
|
|
7
|
+
import { resolveGlobalRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
8
|
+
import pluginSource from './iapeer-plugin.mjs' with { type: 'text' }
|
|
9
|
+
|
|
10
|
+
export const OPENCODE_AGENT = 'iapeer'
|
|
11
|
+
export const OPENCODE_EXTENSION_FRAGMENTS = ['iapeer-memory', 'statusline-injector'] as const
|
|
12
|
+
type JsonObject = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
export function opencodePaths(personality: string, options: StorageOptions = {}) {
|
|
15
|
+
if (!isValidName(personality)) throw new Error(`invalid OpenCode personality: ${personality}`)
|
|
16
|
+
const dir = join(options.rootDir ?? resolveGlobalRoot(options.env), 'peers', personality, 'opencode')
|
|
17
|
+
return {
|
|
18
|
+
dir,
|
|
19
|
+
plugin: join(dir, 'iapeer-plugin.mjs'),
|
|
20
|
+
state: join(dir, 'state.json'),
|
|
21
|
+
events: join(dir, 'events.jsonl'),
|
|
22
|
+
config: join(dir, 'opencode.json'), // optional operator-owned provider/model config
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function object(value: unknown, source: string): JsonObject {
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${source}: expected a JSON object`)
|
|
28
|
+
return value as JsonObject
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** OpenCode-like object merge; plugin arrays are additive, with stable dedup. */
|
|
32
|
+
export function mergeOpencodeConfig(base: JsonObject, extra: JsonObject): JsonObject {
|
|
33
|
+
const out = { ...base }
|
|
34
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
35
|
+
if (key === 'plugin' && Array.isArray(value)) {
|
|
36
|
+
const all = [...(Array.isArray(out[key]) ? out[key] as unknown[] : []), ...value]
|
|
37
|
+
out[key] = all.filter((v, i) => all.findIndex(x => JSON.stringify(x) === JSON.stringify(v)) === i)
|
|
38
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
39
|
+
const prior = out[key]
|
|
40
|
+
out[key] = mergeOpencodeConfig(prior && typeof prior === 'object' && !Array.isArray(prior) ? prior as JsonObject : {}, value as JsonObject)
|
|
41
|
+
} else out[key] = value
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readOpencodeExtension(path: string): JsonObject {
|
|
47
|
+
const fragment = object(JSON.parse(readFileSync(path, 'utf8')), path)
|
|
48
|
+
for (const key of Object.keys(fragment)) {
|
|
49
|
+
if (key !== 'mcp' && key !== 'plugin') throw new Error(`${path}: unsupported extension field ${key}`)
|
|
50
|
+
}
|
|
51
|
+
if (fragment.mcp !== undefined) {
|
|
52
|
+
const mcp = object(fragment.mcp, `${path}: mcp`)
|
|
53
|
+
if ('iapeer' in mcp) throw new Error(`${path}: extension cannot replace core IAP MCP`)
|
|
54
|
+
for (const [name, config] of Object.entries(mcp)) object(config, `${path}: mcp.${name}`)
|
|
55
|
+
}
|
|
56
|
+
if (fragment.plugin !== undefined && (!Array.isArray(fragment.plugin) || fragment.plugin.some(p => typeof p !== 'string' || !p.startsWith('file:///')))) {
|
|
57
|
+
throw new Error(`${path}: plugins must be absolute file URLs`)
|
|
58
|
+
}
|
|
59
|
+
return fragment
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ComposeOpencodeConfigInput {
|
|
63
|
+
personality: string
|
|
64
|
+
doctrine: string
|
|
65
|
+
daemonUrl: string
|
|
66
|
+
/** Existing OPENCODE_CONFIG_CONTENT. It is merged, never discarded. */
|
|
67
|
+
inline?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** No writes and no env resolution except through injected StorageOptions. */
|
|
71
|
+
export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options: StorageOptions = {}): JsonObject {
|
|
72
|
+
if (!input.doctrine.trim()) throw new Error('OpenCode requires a non-empty composed system prompt')
|
|
73
|
+
const paths = opencodePaths(input.personality, options)
|
|
74
|
+
let result: JsonObject = existsSync(paths.config) ? object(JSON.parse(readFileSync(paths.config, 'utf8')), paths.config) : {}
|
|
75
|
+
if (input.inline !== undefined) result = mergeOpencodeConfig(result, object(JSON.parse(input.inline), 'OPENCODE_CONFIG_CONTENT'))
|
|
76
|
+
for (const stem of OPENCODE_EXTENSION_FRAGMENTS) {
|
|
77
|
+
const path = join(paths.dir, `${stem}.json`)
|
|
78
|
+
if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
|
|
79
|
+
}
|
|
80
|
+
// Last writer for required fields; foreign MCP servers, agents, plugins and
|
|
81
|
+
// provider settings survive. Effective managed overrides still need a live gate.
|
|
82
|
+
return mergeOpencodeConfig(result, {
|
|
83
|
+
autoupdate: false,
|
|
84
|
+
share: 'disabled',
|
|
85
|
+
default_agent: OPENCODE_AGENT,
|
|
86
|
+
agent: { [OPENCODE_AGENT]: { mode: 'primary', prompt: input.doctrine } },
|
|
87
|
+
mcp: { iapeer: { type: 'remote', url: input.daemonUrl, enabled: true, oauth: false, headers: { 'X-IAPeer-Identity': '{env:PEER_IDENTITY}' } } },
|
|
88
|
+
plugin: [pathToFileURL(paths.plugin).href],
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Installer/launcher must pass only trusted core source. Provider secrets stay in
|
|
93
|
+
* native env/config, never interpolated into this executable source artifact. */
|
|
94
|
+
export function writeOpencodePlugin(personality: string, source: string, options: StorageOptions = {}): string {
|
|
95
|
+
const paths = opencodePaths(personality, options)
|
|
96
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 })
|
|
97
|
+
writeFileAtomic(paths.plugin, source, 0o600)
|
|
98
|
+
return paths.plugin
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The core-owned plugin source shipped with this package (embedded in the compiled binary). */
|
|
102
|
+
export const OPENCODE_PLUGIN_SOURCE: string = pluginSource
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Core side of the OpenCode control socket served by the core-owned plugin
|
|
2
|
+
// (src/opencode/iapeer-plugin.mjs). One newline-terminated JSON request per
|
|
3
|
+
// connection, one JSON response line back. Used for readiness, delivery, interrupt.
|
|
4
|
+
import { connect } from 'node:net'
|
|
5
|
+
|
|
6
|
+
export type OpencodeControlRequest =
|
|
7
|
+
| { op: 'ping' }
|
|
8
|
+
| { op: 'deliver'; text: string }
|
|
9
|
+
| { op: 'interrupt' }
|
|
10
|
+
| { op: 'select' }
|
|
11
|
+
|
|
12
|
+
export interface OpencodeControlResponse {
|
|
13
|
+
ok: boolean
|
|
14
|
+
error?: string
|
|
15
|
+
identity?: string
|
|
16
|
+
launchID?: string
|
|
17
|
+
sessionID?: string | null
|
|
18
|
+
messageID?: string
|
|
19
|
+
status?: string
|
|
20
|
+
lastEventSeq?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function opencodeControl(
|
|
24
|
+
socketPath: string,
|
|
25
|
+
request: OpencodeControlRequest,
|
|
26
|
+
timeoutMs = 20000,
|
|
27
|
+
): Promise<OpencodeControlResponse> {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
let buf = ''
|
|
30
|
+
let settled = false
|
|
31
|
+
const done = (r: OpencodeControlResponse) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
sock.destroy()
|
|
36
|
+
resolve(r)
|
|
37
|
+
}
|
|
38
|
+
const sock = connect(socketPath)
|
|
39
|
+
const timer = setTimeout(() => done({ ok: false, error: `control ${request.op}: no response within ${timeoutMs}ms` }), timeoutMs)
|
|
40
|
+
sock.setEncoding('utf8')
|
|
41
|
+
sock.on('connect', () => sock.write(JSON.stringify(request) + '\n'))
|
|
42
|
+
sock.on('data', chunk => {
|
|
43
|
+
buf += chunk
|
|
44
|
+
const nl = buf.indexOf('\n')
|
|
45
|
+
if (nl < 0) return
|
|
46
|
+
try {
|
|
47
|
+
done(JSON.parse(buf.slice(0, nl)) as OpencodeControlResponse)
|
|
48
|
+
} catch {
|
|
49
|
+
done({ ok: false, error: `control ${request.op}: malformed response` })
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
sock.on('error', e => done({ ok: false, error: `control ${request.op}: ${e.message}` }))
|
|
53
|
+
sock.on('close', () => done({ ok: false, error: `control ${request.op}: connection closed without a response` }))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
// ── message acknowledgements: resolved by the native persist event ──────────
|
|
149
|
+
const waiters = new Map()
|
|
150
|
+
function awaitPersisted(messageID) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
waiters.delete(messageID)
|
|
154
|
+
reject(new Error('message ' + messageID + ' not persisted within ' + DELIVER_ACK_MS + 'ms'))
|
|
155
|
+
}, DELIVER_ACK_MS)
|
|
156
|
+
waiters.set(messageID, () => {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
waiters.delete(messageID)
|
|
159
|
+
resolve()
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function handle(req) {
|
|
165
|
+
switch (req && req.op) {
|
|
166
|
+
case 'ping':
|
|
167
|
+
return { ok: true, identity, launchID, sessionID: state.sessionID, status: state.status, lastEventSeq: state.lastEventSeq }
|
|
168
|
+
case 'deliver': {
|
|
169
|
+
if (typeof req.text !== 'string' || !req.text) return { ok: false, error: 'deliver: empty text' }
|
|
170
|
+
const sessionID = await ensureSession()
|
|
171
|
+
const messageID = ascendingId('msg')
|
|
172
|
+
const persisted = awaitPersisted(messageID)
|
|
173
|
+
const res = await client.session.promptAsync({
|
|
174
|
+
path: { id: sessionID },
|
|
175
|
+
body: { messageID, agent: 'iapeer', parts: [{ type: 'text', text: req.text }] },
|
|
176
|
+
})
|
|
177
|
+
if (res.error) {
|
|
178
|
+
waiters.delete(messageID)
|
|
179
|
+
persisted.catch(() => {})
|
|
180
|
+
return { ok: false, error: 'prompt_async: ' + JSON.stringify(res.error) }
|
|
181
|
+
}
|
|
182
|
+
await persisted
|
|
183
|
+
return { ok: true, sessionID, messageID }
|
|
184
|
+
}
|
|
185
|
+
case 'interrupt': {
|
|
186
|
+
if (!state.sessionID) return { ok: false, error: 'no session' }
|
|
187
|
+
const res = await client.session.abort({ path: { id: state.sessionID } })
|
|
188
|
+
return res.error ? { ok: false, error: JSON.stringify(res.error) } : { ok: true }
|
|
189
|
+
}
|
|
190
|
+
case 'select':
|
|
191
|
+
await selectInTui()
|
|
192
|
+
return { ok: true, sessionID: state.sessionID }
|
|
193
|
+
default:
|
|
194
|
+
return { ok: false, error: 'unknown op ' + JSON.stringify(req && req.op) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── control socket ──────────────────────────────────────────────────────────
|
|
199
|
+
rmSync(paths.socket, { force: true })
|
|
200
|
+
const control = createServer(conn => {
|
|
201
|
+
let buf = ''
|
|
202
|
+
conn.setEncoding('utf8')
|
|
203
|
+
conn.on('data', chunk => {
|
|
204
|
+
buf += chunk
|
|
205
|
+
const nl = buf.indexOf('\n')
|
|
206
|
+
if (nl < 0) return
|
|
207
|
+
const line = buf.slice(0, nl)
|
|
208
|
+
buf = ''
|
|
209
|
+
let req
|
|
210
|
+
try {
|
|
211
|
+
req = JSON.parse(line)
|
|
212
|
+
} catch {
|
|
213
|
+
conn.end(JSON.stringify({ ok: false, error: 'bad request' }) + '\n')
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
handle(req).then(
|
|
217
|
+
r => conn.end(JSON.stringify(r) + '\n'),
|
|
218
|
+
e => conn.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }) + '\n'),
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
conn.on('error', () => {})
|
|
222
|
+
})
|
|
223
|
+
await new Promise((resolve, reject) => {
|
|
224
|
+
control.once('error', reject)
|
|
225
|
+
control.listen(paths.socket, () => resolve())
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Readiness runs AFTER init returns: calling the in-process client while OpenCode is
|
|
229
|
+
// still loading this plugin would wait on the very instance state being built.
|
|
230
|
+
// The socket is up and the session is known (fresh: created now) → runtime.ready —
|
|
231
|
+
// the core's readiness signal, never a screen glyph.
|
|
232
|
+
setTimeout(async () => {
|
|
233
|
+
const resumed = Boolean(state.sessionID)
|
|
234
|
+
try {
|
|
235
|
+
const sessionID = await ensureSession()
|
|
236
|
+
const got = await client.session.get({ path: { id: sessionID } })
|
|
237
|
+
if (!got.data) throw new Error('session ' + sessionID + ' not found: ' + JSON.stringify(got.error ?? null))
|
|
238
|
+
if (state.status === 'starting') state.status = 'idle'
|
|
239
|
+
journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
|
|
240
|
+
} catch (e) {
|
|
241
|
+
state.status = 'error'
|
|
242
|
+
state.error = e instanceof Error ? e.message : String(e)
|
|
243
|
+
journal('runtime.error', { error: state.error }, null, state.sessionID)
|
|
244
|
+
}
|
|
245
|
+
writeState()
|
|
246
|
+
// The TUI may not be subscribed yet; the core re-selects on demand via op:select.
|
|
247
|
+
setTimeout(() => void selectInTui(), 500)
|
|
248
|
+
}, 0)
|
|
249
|
+
writeState()
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
event: async ({ event }) => {
|
|
253
|
+
const type = event && event.type
|
|
254
|
+
if (!JOURNALED.has(type)) return
|
|
255
|
+
const p = event.properties
|
|
256
|
+
const sessionID = sessionOf(type, p)
|
|
257
|
+
if (type === 'message.updated' && p && p.info && waiters.has(p.info.id)) waiters.get(p.info.id)()
|
|
258
|
+
if (sessionID && sessionID === state.sessionID) {
|
|
259
|
+
if (type === 'session.status' && p.status) {
|
|
260
|
+
state.status = p.status.type === 'busy' || p.status.type === 'retry' ? 'busy' : p.status.type === 'idle' ? 'idle' : state.status
|
|
261
|
+
delete state.error
|
|
262
|
+
} else if (type === 'session.idle') state.status = 'idle'
|
|
263
|
+
else if (type === 'permission.asked') state.status = 'approval'
|
|
264
|
+
else if (type === 'permission.replied' && state.status === 'approval') state.status = 'busy'
|
|
265
|
+
else if (type === 'session.error') {
|
|
266
|
+
state.status = 'error'
|
|
267
|
+
state.error = JSON.stringify(p.error ?? null)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
journal(type, p, event.id ?? null, sessionID)
|
|
271
|
+
writeState()
|
|
272
|
+
},
|
|
273
|
+
dispose: async () => {
|
|
274
|
+
state.status = 'stopped'
|
|
275
|
+
journal('runtime.stopped', { reason: 'dispose' }, null, state.sessionID)
|
|
276
|
+
writeState()
|
|
277
|
+
await new Promise(resolve => control.close(() => resolve()))
|
|
278
|
+
rmSync(paths.socket, { force: true })
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default { id: 'iapeer', server }
|