kernelpm 0.1.0
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/README.md +72 -0
- package/dist/cli.js +243 -0
- package/dist/client.js +137 -0
- package/dist/control.js +184 -0
- package/dist/daemon.js +189 -0
- package/dist/decisions.js +56 -0
- package/dist/files.js +226 -0
- package/dist/opencodeDriver.js +611 -0
- package/dist/protocol.js +12 -0
- package/dist/runtime.js +153 -0
- package/dist/session.js +102 -0
- package/dist/store.js +145 -0
- package/package.json +27 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ServeOpencodeDriver = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* The kernelpm opencode driver.
|
|
39
|
+
*
|
|
40
|
+
* A driver operates exactly one opencode session. Instead of scraping an
|
|
41
|
+
* interactive TTY, it talks to a headless `opencode serve` process over HTTP:
|
|
42
|
+
* it creates the session, sends prompts through the async prompt endpoint and
|
|
43
|
+
* reads the conversation back from the server-sent event stream, normalizing
|
|
44
|
+
* opencode's structured parts into kernelpm chat events.
|
|
45
|
+
*
|
|
46
|
+
* Like the previous tmux/claude driver, auto-approval is structural: the serve
|
|
47
|
+
* process is launched with an inline permission config that allows everything,
|
|
48
|
+
* so opencode never stops to ask.
|
|
49
|
+
*
|
|
50
|
+
* One `opencode serve` process is run per session (scoped to the session's
|
|
51
|
+
* working directory, since opencode binds the cwd at server start). The port
|
|
52
|
+
* and opencode session id are persisted so a restarted daemon can reattach to
|
|
53
|
+
* the surviving serve instead of orphaning it.
|
|
54
|
+
*/
|
|
55
|
+
const child_process_1 = require("child_process");
|
|
56
|
+
const net = __importStar(require("net"));
|
|
57
|
+
const decisions_1 = require("./decisions");
|
|
58
|
+
const POLL_MS = 1000;
|
|
59
|
+
const HEALTH_TIMEOUT_MS = 30_000;
|
|
60
|
+
const MAX_TOOL_SUMMARY = 400;
|
|
61
|
+
/**
|
|
62
|
+
* Inline permission config: allow everything, including the two safety guards
|
|
63
|
+
* that default to "ask". Matches the old `--permission-mode bypassPermissions`.
|
|
64
|
+
*/
|
|
65
|
+
const ALLOW_ALL_PERMISSION = JSON.stringify({
|
|
66
|
+
'*': 'allow',
|
|
67
|
+
edit: 'allow',
|
|
68
|
+
bash: 'allow',
|
|
69
|
+
webfetch: 'allow',
|
|
70
|
+
task: 'allow',
|
|
71
|
+
external_directory: 'allow',
|
|
72
|
+
doom_loop: 'allow',
|
|
73
|
+
});
|
|
74
|
+
class ServeOpencodeDriver {
|
|
75
|
+
sessionId;
|
|
76
|
+
cwd;
|
|
77
|
+
title;
|
|
78
|
+
opts;
|
|
79
|
+
cb;
|
|
80
|
+
port = 0;
|
|
81
|
+
baseUrl = '';
|
|
82
|
+
child = null;
|
|
83
|
+
exited = false;
|
|
84
|
+
pollTimer = null;
|
|
85
|
+
opencodeId;
|
|
86
|
+
/** opencode session id of the most recently seen assistant message. */
|
|
87
|
+
lastAssistantMessageId = null;
|
|
88
|
+
lastAssistantText = '';
|
|
89
|
+
/** Accumulated parts per opencode message id (insertion-ordered). */
|
|
90
|
+
partsByMessage = new Map();
|
|
91
|
+
messageInfo = new Map();
|
|
92
|
+
emittedText = new Set();
|
|
93
|
+
emittedToolStart = new Set();
|
|
94
|
+
emittedToolEnd = new Set();
|
|
95
|
+
emittedTurn = new Set();
|
|
96
|
+
abort = null;
|
|
97
|
+
status = 'starting';
|
|
98
|
+
constructor(sessionId, cwd, title, opts, cb, initial) {
|
|
99
|
+
this.sessionId = sessionId;
|
|
100
|
+
this.cwd = cwd;
|
|
101
|
+
this.title = title;
|
|
102
|
+
this.opts = opts;
|
|
103
|
+
this.cb = cb;
|
|
104
|
+
this.opencodeId = initial?.opencodeId ?? null;
|
|
105
|
+
if (initial?.opencodePort)
|
|
106
|
+
this.port = initial.opencodePort;
|
|
107
|
+
}
|
|
108
|
+
async start(resume) {
|
|
109
|
+
this.emitStatus('starting');
|
|
110
|
+
await this.launchOrAttachServe();
|
|
111
|
+
await this.ensureSession(resume);
|
|
112
|
+
this.startStreaming();
|
|
113
|
+
this.startPoll();
|
|
114
|
+
}
|
|
115
|
+
async sendMessage(text) {
|
|
116
|
+
await this.prompt(text);
|
|
117
|
+
this.emitStatus('working');
|
|
118
|
+
}
|
|
119
|
+
async answer(option) {
|
|
120
|
+
await this.prompt(option.send);
|
|
121
|
+
this.emitStatus('working');
|
|
122
|
+
}
|
|
123
|
+
async isAlive() {
|
|
124
|
+
if (this.exited)
|
|
125
|
+
return false;
|
|
126
|
+
try {
|
|
127
|
+
const r = await this.http('/global/health');
|
|
128
|
+
return r.ok;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
detach() {
|
|
135
|
+
this.stopWatching();
|
|
136
|
+
}
|
|
137
|
+
async kill() {
|
|
138
|
+
this.stopWatching();
|
|
139
|
+
if (this.opencodeId) {
|
|
140
|
+
try {
|
|
141
|
+
await this.http(`/session/${this.opencodeId}`, { method: 'DELETE' });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* session may already be gone */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.killServe();
|
|
148
|
+
this.emitStatus('exited');
|
|
149
|
+
}
|
|
150
|
+
/* --------------------------------- lifecycle ------------------------------- */
|
|
151
|
+
/** Reuse the surviving serve on the known port, or spawn a fresh one. */
|
|
152
|
+
async launchOrAttachServe() {
|
|
153
|
+
if (this.port && (await this.probeServe(this.port))) {
|
|
154
|
+
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.port = await freePort();
|
|
158
|
+
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
|
159
|
+
const args = ['serve', '--port', String(this.port), '--hostname', '127.0.0.1'];
|
|
160
|
+
const env = { ...process.env, OPENCODE_PERMISSION: ALLOW_ALL_PERMISSION };
|
|
161
|
+
if (this.opts.model)
|
|
162
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ model: this.opts.model });
|
|
163
|
+
this.child = (0, child_process_1.spawn)('opencode', args, { cwd: this.cwd, env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
164
|
+
this.child.on('exit', () => {
|
|
165
|
+
this.exited = true;
|
|
166
|
+
this.emitStatus('exited');
|
|
167
|
+
this.stopWatching();
|
|
168
|
+
});
|
|
169
|
+
this.child.on('error', (err) => {
|
|
170
|
+
console.error(`[kernelpm] opencode serve error for ${this.sessionId}:`, err.message);
|
|
171
|
+
});
|
|
172
|
+
if (!(await this.waitForHealth())) {
|
|
173
|
+
throw new Error(this.exited
|
|
174
|
+
? '`opencode serve` exited during startup — is opencode installed and logged in?'
|
|
175
|
+
: '`opencode serve` did not become healthy in time');
|
|
176
|
+
}
|
|
177
|
+
this.cb.onServeInfo?.({ opencodePort: this.port });
|
|
178
|
+
}
|
|
179
|
+
async ensureSession(resume) {
|
|
180
|
+
if (resume && this.opencodeId && (await this.sessionExists(this.opencodeId))) {
|
|
181
|
+
await this.seedFromHistory();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.opencodeId = await this.createSession();
|
|
185
|
+
this.cb.onServeInfo?.({ opencodeId: this.opencodeId });
|
|
186
|
+
}
|
|
187
|
+
async createSession() {
|
|
188
|
+
const body = {};
|
|
189
|
+
if (this.title)
|
|
190
|
+
body.title = this.title;
|
|
191
|
+
const r = await this.http('/session', { method: 'POST', body: JSON.stringify(body) });
|
|
192
|
+
if (!r.ok)
|
|
193
|
+
throw new Error(`could not create opencode session: ${await errText(r)}`);
|
|
194
|
+
const j = (await r.json());
|
|
195
|
+
return j.id;
|
|
196
|
+
}
|
|
197
|
+
async sessionExists(id) {
|
|
198
|
+
try {
|
|
199
|
+
const r = await this.http(`/session/${id}`);
|
|
200
|
+
return r.ok;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Mark existing history as already-emitted so a reattach doesn't replay it. */
|
|
207
|
+
async seedFromHistory() {
|
|
208
|
+
const messages = await this.listMessages();
|
|
209
|
+
for (const m of messages) {
|
|
210
|
+
this.messageInfo.set(m.info.id, m.info);
|
|
211
|
+
const map = new Map();
|
|
212
|
+
this.partsByMessage.set(m.info.id, map);
|
|
213
|
+
for (const part of m.parts ?? []) {
|
|
214
|
+
map.set(part.id, part);
|
|
215
|
+
this.emittedText.add(part.id);
|
|
216
|
+
if (part.type === 'tool') {
|
|
217
|
+
this.emittedToolStart.add(part.id);
|
|
218
|
+
this.emittedToolEnd.add(part.id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (m.info.role === 'assistant' && m.info.time?.completed) {
|
|
222
|
+
this.emittedTurn.add(m.info.id);
|
|
223
|
+
this.lastAssistantMessageId = m.info.id;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
this.cb.onServeInfo?.({ opencodeId: this.opencodeId });
|
|
227
|
+
}
|
|
228
|
+
/* ---------------------------------- watching ------------------------------- */
|
|
229
|
+
startStreaming() {
|
|
230
|
+
this.abort = new AbortController();
|
|
231
|
+
void this.runEventStream(this.abort.signal);
|
|
232
|
+
}
|
|
233
|
+
startPoll() {
|
|
234
|
+
if (this.pollTimer)
|
|
235
|
+
return;
|
|
236
|
+
this.pollTimer = setInterval(() => {
|
|
237
|
+
if (this.exited) {
|
|
238
|
+
this.emitStatus('exited');
|
|
239
|
+
this.stopWatching();
|
|
240
|
+
}
|
|
241
|
+
}, POLL_MS);
|
|
242
|
+
}
|
|
243
|
+
stopWatching() {
|
|
244
|
+
if (this.abort) {
|
|
245
|
+
this.abort.abort();
|
|
246
|
+
this.abort = null;
|
|
247
|
+
}
|
|
248
|
+
if (this.pollTimer) {
|
|
249
|
+
clearInterval(this.pollTimer);
|
|
250
|
+
this.pollTimer = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async runEventStream(signal) {
|
|
254
|
+
while (!signal.aborted) {
|
|
255
|
+
try {
|
|
256
|
+
const r = await this.http('/event', { headers: { Accept: 'text/event-stream' }, signal });
|
|
257
|
+
if (!r.ok || !r.body) {
|
|
258
|
+
if (this.exited)
|
|
259
|
+
return;
|
|
260
|
+
await delay(1000);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
await this.consumeSSE(r.body, signal);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (signal.aborted)
|
|
267
|
+
return;
|
|
268
|
+
// network blip or serve gone — the exit handler / probe will surface it
|
|
269
|
+
}
|
|
270
|
+
if (!signal.aborted && !this.exited)
|
|
271
|
+
await delay(1000);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async consumeSSE(body, signal) {
|
|
275
|
+
const reader = body.getReader();
|
|
276
|
+
const decoder = new TextDecoder();
|
|
277
|
+
let buf = '';
|
|
278
|
+
try {
|
|
279
|
+
while (!signal.aborted) {
|
|
280
|
+
const { done, value } = await reader.read();
|
|
281
|
+
if (done)
|
|
282
|
+
break;
|
|
283
|
+
buf += decoder.decode(value, { stream: true });
|
|
284
|
+
let idx;
|
|
285
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
286
|
+
const raw = buf.slice(0, idx);
|
|
287
|
+
buf = buf.slice(idx + 2);
|
|
288
|
+
this.handleSSEEvent(raw);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* stream closed — outer loop reconnects */
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
try {
|
|
297
|
+
reader.releaseLock();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* already released */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
handleSSEEvent(raw) {
|
|
305
|
+
const data = raw
|
|
306
|
+
.split('\n')
|
|
307
|
+
.filter((l) => l.startsWith('data:'))
|
|
308
|
+
.map((l) => l.slice(5).replace(/^ /, ''))
|
|
309
|
+
.join('\n');
|
|
310
|
+
if (!data)
|
|
311
|
+
return;
|
|
312
|
+
let payload;
|
|
313
|
+
try {
|
|
314
|
+
payload = JSON.parse(data);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// `/global/event` wraps bus events; `/event` sends them raw. Tolerate both.
|
|
320
|
+
const ev = payload.payload ?? payload;
|
|
321
|
+
this.dispatchEvent(ev);
|
|
322
|
+
}
|
|
323
|
+
dispatchEvent(ev) {
|
|
324
|
+
if (!ev || typeof ev.type !== 'string')
|
|
325
|
+
return;
|
|
326
|
+
const p = ev.properties ?? {};
|
|
327
|
+
switch (ev.type) {
|
|
328
|
+
case 'message.part.updated':
|
|
329
|
+
this.onPart(p.part);
|
|
330
|
+
break;
|
|
331
|
+
case 'message.updated':
|
|
332
|
+
this.onMessage(p.info);
|
|
333
|
+
break;
|
|
334
|
+
case 'session.status':
|
|
335
|
+
this.onSessionStatus(p);
|
|
336
|
+
break;
|
|
337
|
+
case 'session.idle':
|
|
338
|
+
this.onSessionIdle(p);
|
|
339
|
+
break;
|
|
340
|
+
case 'session.updated':
|
|
341
|
+
this.onSessionUpdated(p.info);
|
|
342
|
+
break;
|
|
343
|
+
// message.removed / message.part.removed (revert), session.error, etc. are
|
|
344
|
+
// not part of kernelpm's append-only model — ignored.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
onPart(part) {
|
|
348
|
+
if (!part || part.sessionID !== this.opencodeId)
|
|
349
|
+
return;
|
|
350
|
+
this.storePart(part);
|
|
351
|
+
if (part.type === 'tool')
|
|
352
|
+
this.handleToolLive(part);
|
|
353
|
+
// If this part landed after its message already completed, flush now.
|
|
354
|
+
const info = this.messageInfo.get(part.messageID);
|
|
355
|
+
if (info && info.role === 'assistant' && info.time?.completed)
|
|
356
|
+
this.flushMessage(part.messageID);
|
|
357
|
+
}
|
|
358
|
+
onMessage(info) {
|
|
359
|
+
if (!info || info.sessionID !== this.opencodeId)
|
|
360
|
+
return;
|
|
361
|
+
this.messageInfo.set(info.id, info);
|
|
362
|
+
if (info.role === 'assistant') {
|
|
363
|
+
this.lastAssistantMessageId = info.id;
|
|
364
|
+
if (info.time?.completed)
|
|
365
|
+
this.flushMessage(info.id);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
onSessionStatus(p) {
|
|
369
|
+
if (p.sessionID !== this.opencodeId || !p.status)
|
|
370
|
+
return;
|
|
371
|
+
if (p.status.type === 'busy' || p.status.type === 'retry')
|
|
372
|
+
this.emitStatus('working');
|
|
373
|
+
else if (p.status.type === 'idle')
|
|
374
|
+
this.handleIdle();
|
|
375
|
+
}
|
|
376
|
+
onSessionIdle(p) {
|
|
377
|
+
if (p.sessionID !== this.opencodeId)
|
|
378
|
+
return;
|
|
379
|
+
this.handleIdle();
|
|
380
|
+
}
|
|
381
|
+
onSessionUpdated(info) {
|
|
382
|
+
if (!info || info.id !== this.opencodeId)
|
|
383
|
+
return;
|
|
384
|
+
if (typeof info.title === 'string' && info.title.trim() && info.title !== this.title) {
|
|
385
|
+
this.cb.onTitle(info.title.trim());
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
handleIdle() {
|
|
389
|
+
// Fallback for the rare case where a turn finished without a completed
|
|
390
|
+
// message.updated reaching us. flushMessage is idempotent.
|
|
391
|
+
if (this.lastAssistantMessageId)
|
|
392
|
+
this.flushMessage(this.lastAssistantMessageId);
|
|
393
|
+
}
|
|
394
|
+
/* ------------------------------- part emission ----------------------------- */
|
|
395
|
+
storePart(part) {
|
|
396
|
+
let m = this.partsByMessage.get(part.messageID);
|
|
397
|
+
if (!m) {
|
|
398
|
+
m = new Map();
|
|
399
|
+
this.partsByMessage.set(part.messageID, m);
|
|
400
|
+
}
|
|
401
|
+
m.set(part.id, part);
|
|
402
|
+
}
|
|
403
|
+
/** Stream tool start/result live as it happens (each is its own event). */
|
|
404
|
+
handleToolLive(part) {
|
|
405
|
+
const events = [];
|
|
406
|
+
this.emitToolEvents(part, events);
|
|
407
|
+
if (events.length) {
|
|
408
|
+
this.cb.onEvents(events);
|
|
409
|
+
const st = part.state?.status;
|
|
410
|
+
if (st === 'running' || st === 'pending')
|
|
411
|
+
this.emitStatus('working');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Emit tool_use (once) and tool_result (once on completion/error). */
|
|
415
|
+
emitToolEvents(part, events) {
|
|
416
|
+
const toolId = part.callID || part.id;
|
|
417
|
+
if (!this.emittedToolStart.has(part.id)) {
|
|
418
|
+
this.emittedToolStart.add(part.id);
|
|
419
|
+
events.push({
|
|
420
|
+
kind: 'tool_use',
|
|
421
|
+
toolId,
|
|
422
|
+
name: part.tool,
|
|
423
|
+
input: part.state?.input ?? {},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const st = part.state;
|
|
427
|
+
if (!st)
|
|
428
|
+
return;
|
|
429
|
+
if (st.status === 'completed' && !this.emittedToolEnd.has(part.id)) {
|
|
430
|
+
this.emittedToolEnd.add(part.id);
|
|
431
|
+
events.push({
|
|
432
|
+
kind: 'tool_result',
|
|
433
|
+
toolId,
|
|
434
|
+
ok: true,
|
|
435
|
+
summary: truncate(st.output || st.title || '', MAX_TOOL_SUMMARY),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else if (st.status === 'error' && !this.emittedToolEnd.has(part.id)) {
|
|
439
|
+
this.emittedToolEnd.add(part.id);
|
|
440
|
+
events.push({
|
|
441
|
+
kind: 'tool_result',
|
|
442
|
+
toolId,
|
|
443
|
+
ok: false,
|
|
444
|
+
summary: truncate(st.error || '', MAX_TOOL_SUMMARY),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Emit a finished assistant message in order: text/reasoning blocks and tool
|
|
450
|
+
* results, then a single `turn_complete`. Fires once per message. Text is
|
|
451
|
+
* message-granular (the whole block at once), matching how the app renders.
|
|
452
|
+
*/
|
|
453
|
+
flushMessage(messageId) {
|
|
454
|
+
const info = this.messageInfo.get(messageId);
|
|
455
|
+
if (!info || info.role !== 'assistant' || !info.time?.completed)
|
|
456
|
+
return;
|
|
457
|
+
if (this.emittedTurn.has(messageId))
|
|
458
|
+
return;
|
|
459
|
+
const parts = this.partsByMessage.get(messageId);
|
|
460
|
+
const events = [];
|
|
461
|
+
let text = '';
|
|
462
|
+
if (parts) {
|
|
463
|
+
for (const part of parts.values()) {
|
|
464
|
+
if (part.type === 'text') {
|
|
465
|
+
if (part.synthetic || part.ignored)
|
|
466
|
+
continue;
|
|
467
|
+
if (!this.emittedText.has(part.id)) {
|
|
468
|
+
this.emittedText.add(part.id);
|
|
469
|
+
events.push({ kind: 'assistant_text', text: part.text });
|
|
470
|
+
}
|
|
471
|
+
text += part.text;
|
|
472
|
+
}
|
|
473
|
+
else if (part.type === 'reasoning') {
|
|
474
|
+
if (!this.emittedText.has(part.id)) {
|
|
475
|
+
this.emittedText.add(part.id);
|
|
476
|
+
events.push({ kind: 'assistant_thinking', text: part.text });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else if (part.type === 'tool') {
|
|
480
|
+
this.emitToolEvents(part, events);
|
|
481
|
+
if (part.state?.status === 'completed')
|
|
482
|
+
text += part.state.output || '';
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
events.push({ kind: 'turn_complete' });
|
|
487
|
+
this.emittedTurn.add(messageId);
|
|
488
|
+
this.lastAssistantMessageId = messageId;
|
|
489
|
+
this.lastAssistantText = text;
|
|
490
|
+
this.cb.onEvents(events);
|
|
491
|
+
const decision = (0, decisions_1.parseDecision)(text);
|
|
492
|
+
if (decision)
|
|
493
|
+
this.cb.onDecision(decision);
|
|
494
|
+
else
|
|
495
|
+
this.emitStatus('awaiting_input');
|
|
496
|
+
}
|
|
497
|
+
/* ---------------------------------- transport ------------------------------ */
|
|
498
|
+
async prompt(text) {
|
|
499
|
+
if (!this.opencodeId)
|
|
500
|
+
throw new Error('session not started');
|
|
501
|
+
const body = JSON.stringify({ parts: [{ type: 'text', text }] });
|
|
502
|
+
const r = await this.http(`/session/${this.opencodeId}/prompt_async`, {
|
|
503
|
+
method: 'POST',
|
|
504
|
+
body,
|
|
505
|
+
});
|
|
506
|
+
if (!r.ok && r.status !== 204) {
|
|
507
|
+
throw new Error(`opencode prompt failed: ${await errText(r)}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async listMessages() {
|
|
511
|
+
try {
|
|
512
|
+
const r = await this.http(`/session/${this.opencodeId}/message`);
|
|
513
|
+
if (!r.ok)
|
|
514
|
+
return [];
|
|
515
|
+
return (await r.json());
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
http(path, init = {}) {
|
|
522
|
+
return fetch(this.baseUrl + path, init);
|
|
523
|
+
}
|
|
524
|
+
async probeServe(port) {
|
|
525
|
+
try {
|
|
526
|
+
const r = await fetch(`http://127.0.0.1:${port}/global/health`);
|
|
527
|
+
if (!r.ok)
|
|
528
|
+
return false;
|
|
529
|
+
const j = (await r.json());
|
|
530
|
+
return j.healthy === true;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async waitForHealth() {
|
|
537
|
+
const start = Date.now();
|
|
538
|
+
while (Date.now() - start < HEALTH_TIMEOUT_MS) {
|
|
539
|
+
if (this.exited)
|
|
540
|
+
return false;
|
|
541
|
+
if (await this.probeServe(this.port))
|
|
542
|
+
return true;
|
|
543
|
+
await delay(250);
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
killServe() {
|
|
548
|
+
this.exited = true;
|
|
549
|
+
if (this.child) {
|
|
550
|
+
try {
|
|
551
|
+
this.child.kill('SIGTERM');
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
/* already gone */
|
|
555
|
+
}
|
|
556
|
+
const c = this.child;
|
|
557
|
+
setTimeout(() => {
|
|
558
|
+
try {
|
|
559
|
+
if (!c.killed)
|
|
560
|
+
c.kill('SIGKILL');
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
/* ignore */
|
|
564
|
+
}
|
|
565
|
+
}, 3000).unref();
|
|
566
|
+
this.child = null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
emitStatus(status) {
|
|
570
|
+
if (status === this.status)
|
|
571
|
+
return;
|
|
572
|
+
this.status = status;
|
|
573
|
+
this.cb.onStatus(status);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
exports.ServeOpencodeDriver = ServeOpencodeDriver;
|
|
577
|
+
/* --------------------------------- helpers --------------------------------- */
|
|
578
|
+
/** Grab an ephemeral free port from the OS for `opencode serve` to listen on. */
|
|
579
|
+
function freePort() {
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const srv = net.createServer();
|
|
582
|
+
srv.unref();
|
|
583
|
+
srv.on('error', reject);
|
|
584
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
585
|
+
const addr = srv.address();
|
|
586
|
+
if (addr && typeof addr === 'object') {
|
|
587
|
+
const port = addr.port;
|
|
588
|
+
srv.close(() => resolve(port));
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
srv.close();
|
|
592
|
+
reject(new Error('could not allocate a port'));
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
function delay(ms) {
|
|
598
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
599
|
+
}
|
|
600
|
+
function truncate(s, n) {
|
|
601
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
602
|
+
}
|
|
603
|
+
async function errText(r) {
|
|
604
|
+
try {
|
|
605
|
+
const t = await r.text();
|
|
606
|
+
return t.trim() || `${r.status} ${r.statusText}`;
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return `${r.status} ${r.statusText}`;
|
|
610
|
+
}
|
|
611
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* kernelpm wire protocol.
|
|
4
|
+
*
|
|
5
|
+
* One JSON object per line, exchanged over the daemon's Unix control socket.
|
|
6
|
+
* The broker reuses these exact shapes app<->broker, wrapping each with a
|
|
7
|
+
* serverId for multiplexing (see backend/broker.ts). Keep this file dependency
|
|
8
|
+
* free — it is the single source of truth shared across daemon, broker and app.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.PROTOCOL_VERSION = void 0;
|
|
12
|
+
exports.PROTOCOL_VERSION = 1;
|