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