pi-onlyne 0.8.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/README.md +325 -203
- package/README.zh.md +322 -0
- package/onlyne.json.example +6 -0
- package/package.json +19 -34
- package/relay.toml.example +18 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +923 -0
- package/src/agent.test.mjs +1079 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +300 -0
- package/src/pi-surface.mjs +140 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -113
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -576
- package/dist/onlyne.d.ts +0 -42
- package/dist/onlyne.js +0 -158
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -49
- package/dist/swarm-slot.js +0 -78
- package/dist/swarm.d.ts +0 -45
- package/dist/swarm.js +0 -197
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
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
|
+
}
|