pi-onlyne 0.9.1 → 1.0.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/src/agent.mjs ADDED
@@ -0,0 +1,923 @@
1
+ // The agent side of the onlyne adapter protocol: one connection to
2
+ // `<role workspace>/.onlyne/run/s`, the hello/welcome handshake, assign
3
+ // delivery, turn-state reports, the completion exit, probe, recycle and
4
+ // detach — plus reconnect when the client restarts under it.
5
+ //
6
+ // Everything pi-specific lives behind `surface` (see pi-surface.mjs): this
7
+ // module decides *what* the protocol says and hands the *effects* to the
8
+ // surface, which is why the whole state machine is testable against a plain
9
+ // Node unix socket.
10
+
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { createConnection } from "node:net";
13
+ import { join } from "node:path";
14
+ import { createFrameDecoder, encodeFrame } from "./frame.mjs";
15
+ import {
16
+ DEFAULT_HEARTBEAT_MS,
17
+ assignAckArgs,
18
+ completeReport,
19
+ detachArgs,
20
+ heartbeatReport,
21
+ helloArgs,
22
+ headOf,
23
+ hostBinding,
24
+ imagePart,
25
+ injectionText,
26
+ normalizeOutcome,
27
+ readyReport,
28
+ sendEnvelope,
29
+ sessionRegisterArgs,
30
+ SEQ_BASE,
31
+ settledReport,
32
+ stdinTaskText,
33
+ welcomeFrom,
34
+ } from "./protocol.mjs";
35
+ import { DEFAULT_RELAY, FORCED_PREFIX, relayEnabled, relayRefusal } from "./relay.mjs";
36
+
37
+ /** Reconnect ladder in milliseconds, capped like the client's own. */
38
+ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
39
+ /** How long the socket gets to answer the hello before it is torn down. */
40
+ export const HELLO_TIMEOUT_MS = 5_000;
41
+ /** Default bound on one request round trip. */
42
+ export const REQUEST_TIMEOUT_MS = 30_000;
43
+ /** How long after a turn end the plugin waits for `agent_settled` before it acts. */
44
+ export const SETTLE_FALLBACK_MS = 2_000;
45
+
46
+ /** Capabilities this plugin implements on the wire. */
47
+ export const CAPABILITIES = ["register", "report", "inject", "recycle"];
48
+
49
+ /** Mime guess for an attachment the host did not name. */
50
+ function extensionForMime(mime) {
51
+ switch (mime) {
52
+ case "image/png":
53
+ return "png";
54
+ case "image/jpeg":
55
+ return "jpg";
56
+ case "image/gif":
57
+ return "gif";
58
+ case "image/webp":
59
+ return "webp";
60
+ default:
61
+ return "bin";
62
+ }
63
+ }
64
+
65
+ /** Ids the host mints are uuids; anything else is flattened before it names a file. */
66
+ function safeSegment(value) {
67
+ return String(value ?? "unknown").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
68
+ }
69
+
70
+ export class OnlyneAgent {
71
+ /**
72
+ * @param {{
73
+ * socketPath: string,
74
+ * cwd: string,
75
+ * role: string,
76
+ * sessionId: string,
77
+ * taskId: string,
78
+ * surface: any,
79
+ * log?: (line: string, data?: unknown) => void,
80
+ * relay?: { required?: string[], count?: number | null },
81
+ * capabilities?: string[],
82
+ * heartbeatMs?: number,
83
+ * ladder?: number[],
84
+ * requestTimeoutMs?: number,
85
+ * helloTimeoutMs?: number,
86
+ * settleFallbackMs?: number,
87
+ * createConnection?: (path: string) => any,
88
+ * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
89
+ * }} options
90
+ */
91
+ constructor(options) {
92
+ this.socketPath = options.socketPath;
93
+ this.cwd = options.cwd;
94
+ this.role = options.role;
95
+ this.sessionId = options.sessionId;
96
+ this.envTaskId = options.taskId;
97
+ this.surface = options.surface;
98
+ this.log = options.log ?? (() => {});
99
+ /** The relay guard's policy; the default guards nothing (`relay.mjs`). */
100
+ this.relay = options.relay ?? DEFAULT_RELAY;
101
+ this.capabilities = options.capabilities ?? CAPABILITIES;
102
+ this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
103
+ this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
104
+ this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
105
+ this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
106
+ this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
107
+ this.createConnection = options.createConnection ?? ((path) => createConnection(path));
108
+ // The pane this process was spawned in, reported on every heartbeat so the
109
+ // supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
110
+ // once: the environment of a process never changes. Injected by the tests,
111
+ // which must not depend on the pane they run in.
112
+ this.host = options.host !== undefined ? options.host : hostBinding(process.env);
113
+ this.timer = options.timer ?? {
114
+ set: (fn, ms) => setTimeout(fn, ms),
115
+ clear: (handle) => clearTimeout(handle),
116
+ };
117
+
118
+ this.closed = false;
119
+ this.connected = false;
120
+ this.socket = null;
121
+ this.welcome = null;
122
+ this.generation = 1;
123
+ this.seq = SEQ_BASE;
124
+ this.nextId = 1;
125
+ /** @type {Map<number, { resolve: (value: any) => void, timer: any }>} */
126
+ this.pending = new Map();
127
+ this.attempt = 0;
128
+ this.reconnectHandle = null;
129
+ this.heartbeatHandle = null;
130
+ this.settleHandle = null;
131
+ /** @type {Map<string, any>} */
132
+ this.tasks = new Map();
133
+ /**
134
+ * Every role this session handed something to through a `send` the client
135
+ * accepted: the relay guard's only evidence (`relay.mjs`).
136
+ *
137
+ * Process memory, scoped to this session because the agent is: a reconnect
138
+ * keeps it (the same process re-dials the same client), and a session that
139
+ * starts fresh starts empty rather than guessing at what an earlier process
140
+ * sent — the guard judges this session's own deliveries, not history.
141
+ */
142
+ this.deliveredTo = new Set();
143
+ this.injectedTasks = new Set();
144
+ this.deliveredProse = new Set();
145
+ /** Pushes that arrived before the handshake finished; see `onFrame`. */
146
+ this.handshaking = false;
147
+ this.deferredPushes = [];
148
+ this.agentState = "ready";
149
+ this.lastError = null;
150
+ /** Whether pi has already been asked to end this process (`exitSession`). */
151
+ this.exitRequested = false;
152
+ /** Agent phase of the last heartbeat this connection sent, if any. */
153
+ this.lastPhase = null;
154
+ this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
155
+ }
156
+
157
+ // ---------------------------------------------------------------- lifecycle
158
+
159
+ /** Open the connection and keep it open until `stop`. */
160
+ start() {
161
+ this.closed = false;
162
+ this.connect();
163
+ }
164
+
165
+ /**
166
+ * Leave: tell the host this plugin is going away, then stop reconnecting.
167
+ * @param {string} reason
168
+ */
169
+ stop(reason = "quit") {
170
+ if (this.closed) return;
171
+ this.closed = true;
172
+ this.clearTimers();
173
+ const socket = this.socket;
174
+ if (this.connected && socket && !socket.destroyed) {
175
+ // Best effort: the detach frame is queued and the socket is ended so the
176
+ // bytes leave before the FIN. pi does not wait for the host's answer.
177
+ this.write({ id: this.nextId++, op: "detach", args: detachArgs(reason) });
178
+ socket.end();
179
+ } else if (socket && !socket.destroyed) {
180
+ socket.destroy();
181
+ }
182
+ this.connected = false;
183
+ this.socket = null;
184
+ this.surface.status?.("onlyne: detached");
185
+ }
186
+
187
+ /** One line for `/onlyne status`. */
188
+ status() {
189
+ return {
190
+ connected: this.connected,
191
+ socket: this.socketPath,
192
+ role: this.role,
193
+ sessionId: this.sessionId,
194
+ generation: this.generation,
195
+ agentState: this.agentState,
196
+ tasks: this.activeTasks().map((task) => task.taskId),
197
+ pendingCompletion: this.pendingCompletion?.taskId ?? null,
198
+ lastError: this.lastError ? String(this.lastError.message ?? this.lastError) : null,
199
+ stats: { ...this.stats },
200
+ };
201
+ }
202
+
203
+ // ------------------------------------------------------------- transport
204
+
205
+ connect() {
206
+ if (this.closed || this.socket) return;
207
+ let socket;
208
+ try {
209
+ socket = this.createConnection(this.socketPath);
210
+ } catch (error) {
211
+ this.noteFailure(error);
212
+ this.scheduleReconnect();
213
+ return;
214
+ }
215
+ this.socket = socket;
216
+ // The error listener goes on before anything else can throw: an `error` event
217
+ // with no handler takes the whole pi process down, and a plugin bug must
218
+ // never do that to its host.
219
+ socket.on("error", (error) => this.noteFailure(error));
220
+ socket.on("close", () => this.onClose(socket));
221
+ let decoder;
222
+ try {
223
+ decoder = createFrameDecoder({
224
+ onFrame: (frame) => this.onFrame(frame),
225
+ onError: (error) => {
226
+ this.log(`framing fault: ${error.message}`);
227
+ this.noteFailure(error);
228
+ this.dropSocket();
229
+ },
230
+ });
231
+ } catch (error) {
232
+ this.noteFailure(error);
233
+ this.dropSocket();
234
+ return;
235
+ }
236
+ socket.on("connect", () => {
237
+ void this.onConnect();
238
+ });
239
+ socket.on("data", (chunk) => decoder.push(chunk));
240
+ }
241
+
242
+ onClose(socket) {
243
+ if (this.socket !== socket) return;
244
+ this.dropSocket();
245
+ }
246
+
247
+ /** Tear the current socket down and schedule the next attempt. */
248
+ dropSocket() {
249
+ const socket = this.socket;
250
+ this.socket = null;
251
+ this.connected = false;
252
+ this.handshaking = false;
253
+ this.deferredPushes = [];
254
+ this.rejectAll("connection lost");
255
+ this.stopHeartbeat();
256
+ this.surface.status?.(this.closed ? "onlyne: detached" : "onlyne: reconnecting");
257
+ if (socket && !socket.destroyed) socket.destroy();
258
+ this.scheduleReconnect();
259
+ }
260
+
261
+ scheduleReconnect() {
262
+ if (this.closed || this.reconnectHandle) return;
263
+ const delay = this.ladder[Math.min(this.attempt, this.ladder.length - 1)];
264
+ this.attempt += 1;
265
+ this.stats.reconnects += 1;
266
+ this.log(`reconnecting in ${delay}ms`);
267
+ this.reconnectHandle = this.timer.set(() => {
268
+ this.reconnectHandle = null;
269
+ if (!this.closed) this.connect();
270
+ }, delay);
271
+ }
272
+
273
+ async onConnect() {
274
+ this.attempt = 0;
275
+ // From here until the welcome is adopted, pushes queue instead of running.
276
+ this.handshaking = true;
277
+ this.log("connected; sending hello");
278
+ const args = helloArgs({
279
+ role: this.role,
280
+ session: this.sessionId,
281
+ taskId: this.envTaskId,
282
+ pid: process.pid,
283
+ capabilities: this.capabilities,
284
+ });
285
+ let body;
286
+ try {
287
+ body = await this.request("hello", args, { timeoutMs: this.helloTimeoutMs });
288
+ } catch (error) {
289
+ this.noteFailure(error);
290
+ this.dropSocket();
291
+ return;
292
+ }
293
+ const welcome = welcomeFrom(body);
294
+ if (!welcome) {
295
+ this.noteFailure(new Error("hello answered without a welcome"));
296
+ this.dropSocket();
297
+ return;
298
+ }
299
+ this.welcome = welcome;
300
+ this.connected = true;
301
+ // A fresh connection has reported nothing: the next beat is news.
302
+ this.lastPhase = null;
303
+ this.agentState = this.tasks.size > 0 ? "idle" : "ready";
304
+ this.surface.status?.(`onlyne: ${welcome.role}`);
305
+ this.log(`welcome role=${welcome.role} generation=${welcome.generation} capabilities=${welcome.hostCapabilities.join(",")}`);
306
+ this.surface.welcome?.(welcome);
307
+ const prose = welcome.prose.trim();
308
+ if (prose && !this.deliveredProse.has(prose)) {
309
+ this.deliveredProse.add(prose);
310
+ this.surface.proseContext?.(prose, welcome);
311
+ }
312
+
313
+ if (this.envTaskId) {
314
+ await this.request("session_register", sessionRegisterArgs({
315
+ sessionId: this.sessionId,
316
+ taskId: this.envTaskId,
317
+ generation: this.generation,
318
+ pid: process.pid,
319
+ title: `onlyne:${this.role}:${this.sessionId}`,
320
+ })).catch((error) => this.log(`session_register refused: ${error.message}`));
321
+ }
322
+ await this.reportReady();
323
+ if (this.pendingCompletion) await this.flushPendingCompletion();
324
+ // The frames that waited for the handshake: the welcome is adopted by now,
325
+ // so the role prose is context before any assignment opens a turn.
326
+ this.handshaking = false;
327
+ this.drainDeferred();
328
+ if (this.tasks.size > 0) await this.heartbeat().catch(() => {});
329
+ this.startHeartbeat();
330
+ }
331
+
332
+ /**
333
+ * Run the pushes that arrived during the handshake, in arrival order. Called
334
+ * once the welcome has been adopted; a socket that died first already
335
+ * discarded the queue.
336
+ */
337
+ drainDeferred() {
338
+ const queued = this.deferredPushes;
339
+ this.deferredPushes = [];
340
+ for (const frame of queued) this.onFrame(frame);
341
+ }
342
+
343
+ /**
344
+ * Write one frame. Never throws: a dead socket is a reconnect, not a crash.
345
+ * @param {any} frame
346
+ */
347
+ write(frame) {
348
+ const socket = this.socket;
349
+ if (!socket || socket.destroyed || !socket.writable) return false;
350
+ try {
351
+ socket.write(encodeFrame(frame));
352
+ return true;
353
+ } catch (error) {
354
+ this.noteFailure(error);
355
+ this.dropSocket();
356
+ return false;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Send one request and wait for its response body.
362
+ * @param {string} op
363
+ * @param {any} args
364
+ * @param {{ timeoutMs?: number }} [options]
365
+ */
366
+ request(op, args, options = {}) {
367
+ const id = this.nextId++;
368
+ const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
369
+ return new Promise((resolve, reject) => {
370
+ const timer = this.timer.set(() => {
371
+ this.pending.delete(id);
372
+ reject(new Error(`${op} timed out after ${timeoutMs}ms`));
373
+ }, timeoutMs);
374
+ this.pending.set(id, { resolve, reject, timer });
375
+ if (!this.write({ id, op, args })) {
376
+ this.pending.delete(id);
377
+ this.timer.clear(timer);
378
+ reject(new Error(`${op} not delivered: socket is down`));
379
+ }
380
+ });
381
+ }
382
+
383
+ onFrame(frame) {
384
+ if (!frame || typeof frame !== "object") return;
385
+ if (frame.reply_to !== undefined && frame.reply_to !== null) {
386
+ const entry = this.pending.get(frame.reply_to);
387
+ if (!entry) return;
388
+ this.pending.delete(frame.reply_to);
389
+ this.timer.clear(entry.timer);
390
+ if (frame.ok) entry.resolve(frame.data ?? null);
391
+ else entry.reject(new Error(`${frame.error?.code ?? "error"}: ${frame.error?.message ?? "refused"}`));
392
+ return;
393
+ }
394
+ // A push can share one TCP chunk with the hello reply, and the decoder
395
+ // hands frames over in arrival order: without this gate an `assign` would
396
+ // be injected before the welcome that carries the role prose and the
397
+ // generation, which is the order the host's ready barrier (§6) assumes.
398
+ // Pushes wait for the handshake; replies never do, or waiting would
399
+ // deadlock the very request the handshake is waiting on.
400
+ if (this.handshaking) {
401
+ this.deferredPushes.push(frame);
402
+ return;
403
+ }
404
+ const op = frame.op;
405
+ const args = frame.args ?? {};
406
+ if (op === "assign") void this.onAssign(args);
407
+ else if (op === "probe") void this.probe();
408
+ else if (op === "recycle") void this.onRecycle(args);
409
+ else if (op === "config_get") void this.onConfigGet(args);
410
+ else if (op === "bye") {
411
+ this.log(`host bye: ${args.reason ?? "unspecified"}`);
412
+ this.dropSocket();
413
+ } else if (op) this.log(`ignoring host op ${op}`);
414
+ }
415
+
416
+ rejectAll(reason) {
417
+ for (const [id, entry] of this.pending) {
418
+ this.pending.delete(id);
419
+ this.timer.clear(entry.timer);
420
+ entry.reject(new Error(reason));
421
+ }
422
+ }
423
+
424
+ noteFailure(error) {
425
+ this.lastError = error;
426
+ this.log(`socket error: ${error?.message ?? error}`);
427
+ }
428
+
429
+ // --------------------------------------------------------------- reports
430
+
431
+ async reportReady() {
432
+ const taskId = this.envTaskId;
433
+ if (!taskId || !this.connected) return;
434
+ this.seq += 1;
435
+ try {
436
+ await this.request("report", readyReport({
437
+ taskId,
438
+ sessionId: this.sessionId,
439
+ generation: this.generation,
440
+ seq: this.seq,
441
+ }));
442
+ this.stats.reports += 1;
443
+ this.log(`ready reported for ${taskId}`);
444
+ } catch (error) {
445
+ this.log(`ready refused: ${error.message}`);
446
+ }
447
+ }
448
+
449
+ /**
450
+ * One heartbeat for the task this connection serves.
451
+ *
452
+ * A task this plugin already completed gets none. `observed` is a full
453
+ * snapshot, so a heartbeat sent after the completion report would put
454
+ * `delivery: none` and `outcome: pending` back over the terminal tuple the
455
+ * host derived from it, and the reducer accepts that snapshot: the session
456
+ * would read `idle` again after having read `exited`. The e2e case
457
+ * `crates/onlyne-testkit/e2e/pi-live.sh` caught exactly this race, where a
458
+ * turn-end heartbeat left over from the finishing turn landed three
459
+ * milliseconds behind the completion.
460
+ */
461
+ async heartbeat(agent = this.agentState) {
462
+ if (!this.connected) return;
463
+ const taskId = this.activeTaskId();
464
+ if (!taskId) return;
465
+ if (this.tasks.get(taskId)?.completed) return;
466
+ this.agentState = agent;
467
+ this.seq += 1;
468
+ await this.request("report", heartbeatReport({
469
+ taskId,
470
+ generation: this.generation,
471
+ seq: this.seq,
472
+ agent,
473
+ host: this.host,
474
+ }));
475
+ this.stats.reports += 1;
476
+ this.lastPhase = agent;
477
+ }
478
+
479
+ /** Tasks still owing a completion; a finished task keeps its record. */
480
+ activeTasks() {
481
+ return [...this.tasks.values()].filter((task) => !task.completed);
482
+ }
483
+
484
+ /** The task this connection is working on right now, if any. */
485
+ activeTaskId() {
486
+ return this.activeTasks()[0]?.taskId ?? this.envTaskId;
487
+ }
488
+
489
+ startHeartbeat() {
490
+ if (this.heartbeatHandle) return;
491
+ this.heartbeatHandle = this.timer.set(() => {
492
+ this.heartbeatHandle = null;
493
+ if (!this.closed && this.connected) {
494
+ void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
495
+ }
496
+ this.startHeartbeat();
497
+ }, this.heartbeatMs);
498
+ }
499
+
500
+ stopHeartbeat() {
501
+ if (!this.heartbeatHandle) return;
502
+ this.timer.clear(this.heartbeatHandle);
503
+ this.heartbeatHandle = null;
504
+ }
505
+
506
+ clearTimers() {
507
+ this.stopHeartbeat();
508
+ if (this.reconnectHandle) {
509
+ this.timer.clear(this.reconnectHandle);
510
+ this.reconnectHandle = null;
511
+ }
512
+ if (this.settleHandle) {
513
+ this.timer.clear(this.settleHandle);
514
+ this.settleHandle = null;
515
+ }
516
+ this.rejectAll("agent stopped");
517
+ }
518
+
519
+ // ------------------------------------------------------------ host → plugin
520
+
521
+ async onAssign(args) {
522
+ const envelope = args.envelope ?? {};
523
+ const taskId = args.task_id ?? envelope.causality?.task ?? null;
524
+ if (!taskId) {
525
+ this.log("assign carried no task id; ignored");
526
+ return;
527
+ }
528
+ if (this.injectedTasks.has(taskId)) {
529
+ this.stats.duplicates += 1;
530
+ this.log(`assign for ${taskId} already injected; acking without a second injection`);
531
+ await this.ack(taskId, true, "duplicate");
532
+ return;
533
+ }
534
+ this.injectedTasks.add(taskId);
535
+ this.stats.assigns += 1;
536
+ if (typeof args.generation === "number") this.generation = args.generation;
537
+
538
+ const attachments = this.writeAttachments(taskId, envelope);
539
+ const prose = typeof args.prose === "string" ? args.prose.trim() : "";
540
+ const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
541
+ if (proseIsNew) this.deliveredProse.add(prose);
542
+ const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
543
+
544
+ this.tasks.set(taskId, {
545
+ taskId,
546
+ envelopeId: envelope.id ?? null,
547
+ // Who handed this task over: the relay guard's count mode does not count
548
+ // a send straight back to it (`relay.mjs`).
549
+ upstream: envelope.from?.role?.role ?? null,
550
+ turnsSinceAssign: 0,
551
+ turns: 0,
552
+ errored: false,
553
+ head: "",
554
+ failed: false,
555
+ });
556
+ this.agentState = "running";
557
+ this.log(`assign ${taskId} from ${JSON.stringify(envelope.from ?? null)}; injecting ${text.length} chars`);
558
+ this.surface.wakeUser?.(text, attachments.map((item) => item.part));
559
+ this.surface.customEntry?.("onlyne-assign", {
560
+ taskId,
561
+ envelopeId: envelope.id ?? null,
562
+ kind: envelope.kind ?? "task",
563
+ proseInjected: proseIsNew,
564
+ prose,
565
+ attachments: attachments.map((item) => item.path),
566
+ });
567
+ this.surface.status?.(`onlyne: ${taskId.slice(0, 8)} running`);
568
+ this.startHeartbeat();
569
+ await this.ack(taskId, true, null);
570
+ }
571
+
572
+ async ack(taskId, accepted, reason) {
573
+ try {
574
+ await this.request("assign_ack", assignAckArgs({ taskId, accepted, reason }));
575
+ } catch (error) {
576
+ this.log(`assign_ack refused: ${error.message}`);
577
+ }
578
+ }
579
+
580
+ /** The host asked for a fresh observation: one heartbeat is the answer. */
581
+ async probe() {
582
+ try {
583
+ await this.heartbeat();
584
+ this.log("probe answered with a heartbeat");
585
+ } catch (error) {
586
+ this.log(`probe heartbeat refused: ${error.message}`);
587
+ }
588
+ }
589
+
590
+ async onConfigGet(args) {
591
+ const task = stdinTaskText(args);
592
+ if (task) {
593
+ // The no-`inject` route: the host hands the payload over as a config key.
594
+ this.log("task body received through config_get/stdin");
595
+ this.surface.wakeUser?.(`[onlyne] task body (delivered as stdin):\n\n${task.text}`, []);
596
+ return;
597
+ }
598
+ this.log(`config_get ${args?.key ?? "?"} is not implemented by this plugin`);
599
+ }
600
+
601
+ async onRecycle(args) {
602
+ this.stats.recycles += 1;
603
+ const taskId = args.task_id ?? this.activeTaskId();
604
+ this.log(`recycle task=${taskId ?? "?"} reason=${args.reason ?? "?"} outcome=${args.outcome ?? "-"}`);
605
+ if (taskId && args.outcome && this.tasks.get(taskId) && !this.tasks.get(taskId).completed) {
606
+ await this.complete(taskId, args.outcome, `recycled: ${args.reason ?? "operator"}`, {
607
+ exitProcess: false,
608
+ }).catch((error) => this.log(`recycle completion refused: ${error.message}`));
609
+ }
610
+ this.stop(`recycle:${args.reason ?? "operator"}`);
611
+ this.exitSession(args.reason ?? "recycle");
612
+ }
613
+
614
+ // ------------------------------------------------------------ pi → plugin
615
+
616
+ /** A turn started: the plugin's own agent fact is `running`. */
617
+ onTurnStart() {
618
+ const task = [...this.tasks.values()].find((item) => !item.completed);
619
+ if (task) task.turns += 1;
620
+ void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
621
+ }
622
+
623
+ /** A turn ended: the agent is idle, and the settle window starts. */
624
+ onTurnEnd() {
625
+ for (const task of this.tasks.values()) {
626
+ if (!task.completed) task.turnsSinceAssign += 1;
627
+ }
628
+ void this.heartbeat("idle").catch((error) => this.log(`heartbeat refused: ${error.message}`));
629
+ this.armSettleFallback();
630
+ }
631
+
632
+ /** pi will not continue on its own: run the completion exit. */
633
+ onSettled() {
634
+ this.clearSettleFallback();
635
+ this.trySettle();
636
+ }
637
+
638
+ /**
639
+ * The one completion trigger. pi keeps `isIdle()` false while it is running,
640
+ * retrying, compacting, or holding a queued continuation, so a settle signal
641
+ * that arrives during any of those waits instead of reporting a premature
642
+ * outcome.
643
+ */
644
+ trySettle() {
645
+ if (this.closed || this.activeTasks().length === 0) return;
646
+ if (this.surface.isIdle?.() === false) {
647
+ this.armSettleFallback();
648
+ return;
649
+ }
650
+ void this.settleNow().catch((error) => this.log(`completion failed: ${error.message}`));
651
+ }
652
+
653
+ /** A failed turn: the task's outcome is `failed` unless it already ended. */
654
+ onTurnError(text) {
655
+ for (const task of this.tasks.values()) {
656
+ if (task.completed) continue;
657
+ task.failed = true;
658
+ task.errored = true;
659
+ if (text) task.head = headOf(text);
660
+ }
661
+ this.trySettle();
662
+ }
663
+
664
+ /**
665
+ * The last assistant text seen, kept as the completion summary for a task the
666
+ * model never handed an explicit argument over for.
667
+ *
668
+ * This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
669
+ * reported byte for byte by `completeFromTool` and is never written back
670
+ * here, so the sentence a turn happened to end on cannot stand in for a
671
+ * payload the tool call carried.
672
+ */
673
+ noteAssistantText(text) {
674
+ const flat = headOf(text);
675
+ if (!flat) return;
676
+ for (const task of this.tasks.values()) {
677
+ if (!task.completed) task.head = flat;
678
+ }
679
+ }
680
+
681
+ armSettleFallback() {
682
+ this.clearSettleFallback();
683
+ if (this.closed || this.activeTasks().length === 0) return;
684
+ this.settleHandle = this.timer.set(() => {
685
+ this.settleHandle = null;
686
+ this.trySettle();
687
+ }, this.settleFallbackMs);
688
+ }
689
+
690
+ clearSettleFallback() {
691
+ if (!this.settleHandle) return;
692
+ this.timer.clear(this.settleHandle);
693
+ this.settleHandle = null;
694
+ }
695
+
696
+ /**
697
+ * The auto outcome rule: every active task whose turn produced output ends
698
+ * `done` (or `failed` when the turn errored), with the last assistant text as
699
+ * its head. That fallback is the only head this path may report — an explicit
700
+ * `onlyne_complete` argument is the tool path's, and this rule runs after it,
701
+ * for the tasks it left unsettled. A task assigned but not yet turned is left
702
+ * alone: the injected message has not run yet, and completing now would lie.
703
+ */
704
+ async settleNow() {
705
+ for (const task of [...this.tasks.values()]) {
706
+ if (task.completed) continue;
707
+ // A turn has to have run: a full turn end is the ordinary proof, and an
708
+ // errored turn is proof enough on its own (pi may skip the clean turn_end).
709
+ if (task.turnsSinceAssign === 0 && !task.errored) continue;
710
+ await this.complete(task.taskId, task.failed ? "failed" : "done", task.head);
711
+ }
712
+ }
713
+
714
+ /**
715
+ * `onlyne_complete`: an explicit outcome from the model, which wins over the
716
+ * auto rule.
717
+ *
718
+ * A non-empty `text` is the completion body: it is what the model handed
719
+ * over, reported verbatim as the ledger's one-line `head`, so the last
720
+ * assistant text never stands in for it. An absent or blank `text` carries no
721
+ * deliverable at all and falls back to that assistant text.
722
+ *
723
+ * The relay guard runs first, for every outcome, when the workspace's
724
+ * `relay.toml` names a minimum downstream handoff: a session that still owes
725
+ * one cannot report a terminal fact. A refusal throws before anything is
726
+ * written, queued or detached — no completion report, no exit, no state on
727
+ * the task — so the session stays live and the same call lands once the
728
+ * handoff has gone out. `force: true` with a non-empty `reason` waives the
729
+ * guard and stamps the head with `relay-guard-forced: <reason>`, which is the
730
+ * ledger's audit trail for a completion that skipped the guard.
731
+ *
732
+ * @param {{ outcome?: string, text?: string, force?: boolean, reason?: string }} input
733
+ */
734
+ async completeFromTool(input = {}) {
735
+ const task = [...this.tasks.values()].find((item) => !item.completed);
736
+ const taskId = task?.taskId ?? this.envTaskId;
737
+ if (!taskId) throw new Error("onlyne: no task is assigned to this session");
738
+ const explicit = headOf(input.text);
739
+ let head = explicit || task?.head || "";
740
+ if (relayEnabled(this.relay)) {
741
+ const refusal = relayRefusal(this.relay, this.deliveredTo, {
742
+ role: this.role,
743
+ upstream: task?.upstream ?? null,
744
+ });
745
+ if (refusal) {
746
+ const reason = headOf(input.reason);
747
+ if (input.force !== true || !reason) {
748
+ this.log(refusal);
749
+ throw new Error(`onlyne: ${refusal}`);
750
+ }
751
+ head = headOf(`${FORCED_PREFIX}${reason}${explicit ? ` | ${explicit}` : ""}`);
752
+ this.log(`relay guard waived for ${taskId}: ${reason}`);
753
+ }
754
+ }
755
+ return this.complete(taskId, normalizeOutcome(input.outcome), head);
756
+ }
757
+
758
+ /**
759
+ * Report the terminal fact. If the socket is down the report is remembered and
760
+ * flushed on the next hello, so a completion survives a client restart.
761
+ *
762
+ * The `report` request is the durable handover, and it is the reason the exit
763
+ * waits for its answer: the client replies from `serve_connection`
764
+ * (`crates/onlyne-client/src/adapter_socket.rs`) only after `on_out` settled
765
+ * the session row, acked the delivery and wrote the `Completion` envelope —
766
+ * onto the server link, or into `client.db` intents when that link is down
767
+ * (`crates/onlyne-client/src/dispatch.rs` `on_out`). A returned response
768
+ * therefore means this process can leave without losing the outcome, and a
769
+ * rejected or queued report must never exit.
770
+ *
771
+ * @param {{ exitProcess?: boolean }} [options] `false` for the recycle path,
772
+ * which ends the process after its own detach frame instead.
773
+ */
774
+ async complete(taskId, outcome, head, options = {}) {
775
+ const exitProcess = options.exitProcess ?? true;
776
+ const normalized = normalizeOutcome(outcome);
777
+ const summary = headOf(head);
778
+ const task = this.tasks.get(taskId);
779
+ if (task?.completed) return { taskId, outcome: normalized, head: summary, duplicate: true };
780
+ if (task) task.completed = true;
781
+ const report = completeReport({ taskId, outcome: normalized, head: summary });
782
+ if (!this.connected) {
783
+ this.pendingCompletion = { taskId, report, outcome: normalized, exitProcess };
784
+ this.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized} (queued)`);
785
+ this.log(`completion for ${taskId} queued: socket is down`);
786
+ return { taskId, outcome: normalized, head: summary, queued: true };
787
+ }
788
+ await this.request("report", report);
789
+ this.stats.completions += 1;
790
+ this.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized}`);
791
+ this.surface.customEntry?.("onlyne-complete", { taskId, outcome: normalized, head: summary });
792
+ this.log(`completion ${taskId} ${normalized} head=${JSON.stringify(summary.slice(0, 60))}`);
793
+ if (this.activeTasks().length === 0) {
794
+ if (exitProcess) {
795
+ await this.reportSettled(taskId, normalized).catch((error) =>
796
+ this.log(`settled observation refused: ${error.message}`),
797
+ );
798
+ }
799
+ this.stopHeartbeat();
800
+ if (exitProcess) this.exitSession(normalized);
801
+ }
802
+ return { taskId, outcome: normalized, head: summary };
803
+ }
804
+
805
+ /**
806
+ * Ask pi to end the process this session runs in.
807
+ *
808
+ * One `ctx.shutdown()` per process: pi marks the request and runs its
809
+ * teardown at `agent_settled` (`modes/interactive/interactive-mode.js`,
810
+ * `modes/rpc/rpc-mode.js` in pi 0.85.1), which then emits `session_shutdown`
811
+ * and that hook detaches this connection. The client daemon is unaffected:
812
+ * the role runtime outlives every session it spawns.
813
+ */
814
+ exitSession(reason) {
815
+ if (this.exitRequested) return;
816
+ this.exitRequested = true;
817
+ this.surface.exit?.(reason);
818
+ }
819
+
820
+ /**
821
+ * One last observation before the process leaves: the tuple the host settled
822
+ * plus `agent: idle`.
823
+ *
824
+ * The completion settles the row from the tuple the client holds, which still
825
+ * says `running` when the turn that finished was the last report sent, and
826
+ * nothing observes the process afterwards. This report is what makes an
827
+ * exited session read idle. It is skipped when the last beat was already
828
+ * idle — the settled tuple is then already right — and it is a request for
829
+ * the same reason the completion is: the answer is the handover, and a
830
+ * failure here must not stop the exit that the durable completion earned.
831
+ */
832
+ async reportSettled(taskId, outcome) {
833
+ if (!this.connected || this.lastPhase === "idle") return false;
834
+ this.seq += 1;
835
+ await this.request("report", settledReport({
836
+ taskId,
837
+ outcome,
838
+ generation: this.generation,
839
+ seq: this.seq,
840
+ host: this.host,
841
+ }));
842
+ this.stats.reports += 1;
843
+ this.lastPhase = "idle";
844
+ this.log(`settled observation for ${taskId}: agent idle, outcome ${outcome}`);
845
+ return true;
846
+ }
847
+
848
+ async flushPendingCompletion() {
849
+ const pending = this.pendingCompletion;
850
+ if (!pending || !this.connected) return;
851
+ this.pendingCompletion = null;
852
+ try {
853
+ await this.request("report", pending.report);
854
+ this.stats.completions += 1;
855
+ this.log(`queued completion for ${pending.taskId} flushed after reconnect`);
856
+ if (pending.exitProcess && this.activeTasks().length === 0) {
857
+ await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
858
+ this.log(`settled observation refused: ${error.message}`),
859
+ );
860
+ this.exitSession(pending.outcome);
861
+ }
862
+ } catch (error) {
863
+ this.pendingCompletion = pending;
864
+ this.log(`queued completion still refused: ${error.message}`);
865
+ }
866
+ }
867
+
868
+ /**
869
+ * `onlyne_send`: submit one envelope.
870
+ * @param {{ to: string, text?: string, kind?: string, imagePath?: string | null }} input
871
+ */
872
+ async sendFromTool(input) {
873
+ if (!this.connected) throw new Error("onlyne: client socket is not connected");
874
+ const kind = input.kind === "task" ? "task" : "note";
875
+ let image = null;
876
+ if (input.imagePath) {
877
+ const bytes = readFileSync(input.imagePath);
878
+ image = imagePart({ data: bytes, mime, name: input.imagePath.split("/").pop() ?? null });
879
+ }
880
+ const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
881
+ const data = await this.request("send", envelope);
882
+ // Recorded only after the client answered the `send`: a refused envelope was
883
+ // never a handoff, and the relay guard must not read one as delivered. Any
884
+ // kind counts — `note` and `task` are both the session reaching that role.
885
+ this.deliveredTo.add(String(input.to));
886
+ return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
887
+ }
888
+
889
+ // ------------------------------------------------------------ attachments
890
+
891
+ /**
892
+ * Write one inbound image to the workspace and return the path plus the pi
893
+ * image part, so the model both sees the picture and can address the file.
894
+ */
895
+ writeAttachments(taskId, envelope) {
896
+ const image = envelope?.body?.image;
897
+ if (!image || typeof image.data_base64 !== "string") return [];
898
+ const mime = typeof image.mime === "string" && image.mime ? image.mime : "image/png";
899
+ const name = safeSegment(image.name ?? `image.${extensionForMime(mime)}`);
900
+ const dir = join(this.cwd, ".onlyne", "tmp", "attachments");
901
+ const path = join(dir, `${safeSegment(taskId)}-${safeSegment(envelope.id)}-${name}`);
902
+ try {
903
+ const bytes = Buffer.from(image.data_base64, "base64");
904
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
905
+ writeFileSync(path, bytes, { mode: 0o600 });
906
+ this.log(`attachment written to ${path} (${bytes.length} bytes)`);
907
+ return [{ path, part: { type: "image", mime, data: image.data_base64, name } }];
908
+ } catch (error) {
909
+ this.log(`attachment write failed: ${error.message}`);
910
+ return [];
911
+ }
912
+ }
913
+ }
914
+
915
+ /** Mime type for one attachment path the model asked to send. */
916
+ export function mimeForPath(path) {
917
+ const lower = String(path).toLowerCase();
918
+ if (lower.endsWith(".png")) return "image/png";
919
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
920
+ if (lower.endsWith(".gif")) return "image/gif";
921
+ if (lower.endsWith(".webp")) return "image/webp";
922
+ throw new Error(`onlyne: unsupported image type for ${path}`);
923
+ }