pi-onlyne 2.2.0 → 2.4.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 +26 -7
- package/README.zh.md +19 -5
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +139 -14
- package/src/agent.test.mjs +83 -1
- package/src/config.mjs +179 -4
- package/src/config.test.mjs +59 -1
- package/src/index.ts +177 -20
- package/src/pi-surface.mjs +80 -10
- package/src/pi-surface.test.mjs +102 -4
- package/src/resident-claim.test.mjs +461 -0
- package/src/session-scope.mjs +53 -0
- package/src/session-scope.test.mjs +34 -0
- package/src/socket.mjs +104 -4
- package/src/socket.test.mjs +157 -3
package/src/agent.mjs
CHANGED
|
@@ -57,8 +57,23 @@ export const HELLO_TIMEOUT_MS = 5_000;
|
|
|
57
57
|
/** Default bound on one request round trip. */
|
|
58
58
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
59
59
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Capabilities this plugin implements on the wire.
|
|
62
|
+
*
|
|
63
|
+
* `resume` is the one that changes what a `task` scope does with this runtime.
|
|
64
|
+
* Pi keeps its conversation in its own store (`--session-dir`), so a session the
|
|
65
|
+
* client releases is one it can bring back by starting the role's `resume` argv
|
|
66
|
+
* — `pi --session <id>` — over the same store. Without the declaration the
|
|
67
|
+
* client keeps the process instead and the scope degrades to "process alive,
|
|
68
|
+
* session alive"; with it, an idle `task` session has its process released and
|
|
69
|
+
* the family's next delivery lands in the same conversation.
|
|
70
|
+
*
|
|
71
|
+
* The release is this plugin's own exit, not a kill: `onRecycle` stops and calls
|
|
72
|
+
* `exitSession`, and pi flushes its session file at `agent_settled`. The client
|
|
73
|
+
* asks first and closes the container only after a grace, which is what makes
|
|
74
|
+
* the ordering safe (`AGENTS.md` §10).
|
|
75
|
+
*/
|
|
76
|
+
export const CAPABILITIES = ["register", "report", "inject", "recycle", "resume"];
|
|
62
77
|
|
|
63
78
|
export class OnlyneAgent {
|
|
64
79
|
/**
|
|
@@ -78,14 +93,17 @@ export class OnlyneAgent {
|
|
|
78
93
|
* helloTimeoutMs?: number,
|
|
79
94
|
* createConnection?: (path: string) => any,
|
|
80
95
|
* timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
|
|
96
|
+
* prose?: string,
|
|
81
97
|
* }} options
|
|
82
98
|
*/
|
|
83
99
|
constructor(options) {
|
|
84
100
|
this.socketPath = options.socketPath;
|
|
101
|
+
this.resolveSocket = typeof options.resolveSocket === "function" ? options.resolveSocket : null;
|
|
85
102
|
this.cwd = options.cwd;
|
|
86
103
|
this.role = options.role;
|
|
87
104
|
this.sessionId = options.sessionId;
|
|
88
105
|
this.envTaskId = options.taskId;
|
|
106
|
+
this.resident = Boolean(options.resident);
|
|
89
107
|
this.surface = options.surface;
|
|
90
108
|
/** The widget panel's model (`activity.mjs`); seeded with the local role. */
|
|
91
109
|
this.activity = options.activity ?? createActivity();
|
|
@@ -154,6 +172,18 @@ export class OnlyneAgent {
|
|
|
154
172
|
*/
|
|
155
173
|
this.injectedDeliveries = new Set();
|
|
156
174
|
this.deliveredProse = new Set();
|
|
175
|
+
/**
|
|
176
|
+
* The newest role prose the client sent. `welcome` carries it and an
|
|
177
|
+
* `assign` names it again; it is kept here, not only in the surface,
|
|
178
|
+
* because a surface is replaced while this process keeps running — a
|
|
179
|
+
* `/new` or `/resume` re-binds the plugin onto a fresh one — and the prose
|
|
180
|
+
* is the role's instruction layer, not a message that could be replayed
|
|
181
|
+
* from the transcript.
|
|
182
|
+
*/
|
|
183
|
+
this.latestProse = typeof options.prose === "string" ? options.prose : "";
|
|
184
|
+
// A surface handed over at construction is owed the prose this agent was
|
|
185
|
+
// started with: it holds none of its own yet.
|
|
186
|
+
if (this.latestProse) this.surface?.roleProse?.(this.latestProse);
|
|
157
187
|
/** Pushes that arrived before the handshake finished; see `onFrame`. */
|
|
158
188
|
this.handshaking = false;
|
|
159
189
|
this.deferredPushes = [];
|
|
@@ -224,10 +254,35 @@ export class OnlyneAgent {
|
|
|
224
254
|
};
|
|
225
255
|
}
|
|
226
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Hand the newest role prose to the current surface again.
|
|
259
|
+
*
|
|
260
|
+
* The surface keeps the prose for the instruction layer, and a surface made
|
|
261
|
+
* after the handshake — or after this process moved onto another session —
|
|
262
|
+
* starts with none of it. The delivery guard must not be the reason it stays
|
|
263
|
+
* empty, so whoever binds a surface calls this.
|
|
264
|
+
* @returns {boolean} whether there was prose to hand over
|
|
265
|
+
*/
|
|
266
|
+
syncRoleProse() {
|
|
267
|
+
if (typeof this.latestProse !== "string" || this.latestProse.length === 0) return false;
|
|
268
|
+
this.surface?.roleProse?.(this.latestProse);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
227
272
|
// ------------------------------------------------------------- transport
|
|
228
273
|
|
|
229
274
|
connect() {
|
|
230
275
|
if (this.closed || this.socket) return;
|
|
276
|
+
if (this.resolveSocket) {
|
|
277
|
+
try {
|
|
278
|
+
const refreshed = this.resolveSocket();
|
|
279
|
+
if (typeof refreshed === "string" && refreshed.length > 0) {
|
|
280
|
+
this.socketPath = refreshed;
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
/* keep previous socketPath */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
231
286
|
let socket;
|
|
232
287
|
try {
|
|
233
288
|
socket = this.createConnection(this.socketPath);
|
|
@@ -271,6 +326,7 @@ export class OnlyneAgent {
|
|
|
271
326
|
/** Tear the current socket down and schedule the next attempt. */
|
|
272
327
|
dropSocket() {
|
|
273
328
|
const socket = this.socket;
|
|
329
|
+
const wasConnected = this.connected;
|
|
274
330
|
this.socket = null;
|
|
275
331
|
this.connected = false;
|
|
276
332
|
this.handshaking = false;
|
|
@@ -278,7 +334,13 @@ export class OnlyneAgent {
|
|
|
278
334
|
this.rejectAll("connection lost");
|
|
279
335
|
this.stopHeartbeat();
|
|
280
336
|
this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
|
|
281
|
-
|
|
337
|
+
if (!this.closed && (!this.resident || wasConnected)) {
|
|
338
|
+
this.notice("state", "reconnecting");
|
|
339
|
+
} else if (this.closed) {
|
|
340
|
+
this.notice("state", "detached");
|
|
341
|
+
} else {
|
|
342
|
+
this.redraw();
|
|
343
|
+
}
|
|
282
344
|
if (socket && !socket.destroyed) socket.destroy();
|
|
283
345
|
this.scheduleReconnect();
|
|
284
346
|
}
|
|
@@ -321,6 +383,11 @@ export class OnlyneAgent {
|
|
|
321
383
|
}
|
|
322
384
|
this.welcome = welcome;
|
|
323
385
|
this.connected = true;
|
|
386
|
+
const hadConnectFailure = this.lastError !== null;
|
|
387
|
+
this.lastError = null;
|
|
388
|
+
if (this.resident && hadConnectFailure) {
|
|
389
|
+
this.log(`connected role=${welcome.role} session=${this.sessionId ?? "resident"} socket=${this.socketPath}`);
|
|
390
|
+
}
|
|
324
391
|
// The client names every delivery it already handed to this session, so a
|
|
325
392
|
// process that restarts under a live client does not re-inject work its
|
|
326
393
|
// predecessor read: the delivery guard is process memory, and this is how it
|
|
@@ -348,8 +415,13 @@ export class OnlyneAgent {
|
|
|
348
415
|
});
|
|
349
416
|
this.notice("state", `connected role=${welcome.role} gen=${welcome.generation} host=${welcome.hostCapabilities.join(",")}`);
|
|
350
417
|
const prose = welcome.prose.trim();
|
|
351
|
-
if (prose
|
|
418
|
+
if (prose) {
|
|
419
|
+
this.latestProse = prose;
|
|
352
420
|
this.deliveredProse.add(prose);
|
|
421
|
+
// Every welcome hands the prose over, not only the first one. A
|
|
422
|
+
// reconnect, a `--resume` and a surface re-created in this process all
|
|
423
|
+
// start empty, and the prose is a system-prompt section rather than a
|
|
424
|
+
// message, so writing it again duplicates nothing in the transcript.
|
|
353
425
|
this.surface.roleProse?.(prose);
|
|
354
426
|
}
|
|
355
427
|
|
|
@@ -467,7 +539,15 @@ export class OnlyneAgent {
|
|
|
467
539
|
}
|
|
468
540
|
|
|
469
541
|
noteFailure(error) {
|
|
542
|
+
const prevCode = this.lastError?.code ?? null;
|
|
470
543
|
this.lastError = error;
|
|
544
|
+
if (
|
|
545
|
+
this.resident &&
|
|
546
|
+
(error?.code === "ENOENT" || error?.code === "ECONNREFUSED") &&
|
|
547
|
+
prevCode === error.code
|
|
548
|
+
) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
471
551
|
this.log(`socket error: ${error?.message ?? error}`);
|
|
472
552
|
}
|
|
473
553
|
|
|
@@ -479,14 +559,20 @@ export class OnlyneAgent {
|
|
|
479
559
|
* @param {"in" | "dup" | "out" | "warn" | "state"} kind
|
|
480
560
|
* @param {string} text
|
|
481
561
|
*/
|
|
562
|
+
redraw() {
|
|
563
|
+
this.surface.status?.(this.activity.lines()[0]);
|
|
564
|
+
if (this.surface.available?.widget) {
|
|
565
|
+
this.surface.widget?.(this.activity.lines(), this.activity);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
482
569
|
notice(kind, text) {
|
|
483
570
|
this.activity.note(kind, text);
|
|
571
|
+
this.redraw();
|
|
484
572
|
if (this.surface.available?.widget) {
|
|
485
|
-
this.surface.widget?.(this.activity.lines());
|
|
486
573
|
return;
|
|
487
574
|
}
|
|
488
|
-
// No panel:
|
|
489
|
-
this.surface.status?.(this.activity.lines()[0]);
|
|
575
|
+
// No panel: stderr carries the event.
|
|
490
576
|
this.log(`${kind}: ${text}`);
|
|
491
577
|
}
|
|
492
578
|
|
|
@@ -730,11 +816,14 @@ export class OnlyneAgent {
|
|
|
730
816
|
* that is what every such frame meant.
|
|
731
817
|
*/
|
|
732
818
|
keepsSession() {
|
|
733
|
-
return this.scope === "task" || this.scope === "role";
|
|
819
|
+
return this.resident || this.scope === "task" || this.scope === "role";
|
|
734
820
|
}
|
|
735
821
|
|
|
736
822
|
async onAssign(args) {
|
|
737
823
|
if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
|
|
824
|
+
if (this.resident && typeof args.session_id === "string" && args.session_id) {
|
|
825
|
+
this.sessionId = args.session_id;
|
|
826
|
+
}
|
|
738
827
|
const envelope = args.envelope ?? {};
|
|
739
828
|
const taskId = args.task_id ?? envelope.causality?.task ?? null;
|
|
740
829
|
if (!taskId) {
|
|
@@ -762,12 +851,42 @@ export class OnlyneAgent {
|
|
|
762
851
|
const attachmentPaths = Array.isArray(args.attachments) ? args.attachments : [];
|
|
763
852
|
const prose = typeof args.prose === "string" ? args.prose.trim() : "";
|
|
764
853
|
const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
|
|
765
|
-
if (
|
|
854
|
+
if (prose.length > 0) {
|
|
855
|
+
this.latestProse = prose;
|
|
766
856
|
this.deliveredProse.add(prose);
|
|
767
|
-
this.surface.roleProse?.(prose);
|
|
857
|
+
if (proseIsNew) this.surface.roleProse?.(prose);
|
|
768
858
|
}
|
|
769
859
|
|
|
860
|
+
const kind = envelope.kind ?? "task";
|
|
770
861
|
const held = this.tasks.get(taskId);
|
|
862
|
+
const fromLabel = describePrincipal(envelope.from);
|
|
863
|
+
const wakeMeta = {
|
|
864
|
+
role: this.role,
|
|
865
|
+
taskId,
|
|
866
|
+
kind,
|
|
867
|
+
from: fromLabel,
|
|
868
|
+
envelopeId: envelope.id ?? null,
|
|
869
|
+
bodyText: envelope.body?.text ?? text,
|
|
870
|
+
};
|
|
871
|
+
// A note is a message, not work: it is injected and acknowledged, and it
|
|
872
|
+
// opens no task record, so nothing beats for it, no turn end owes it an
|
|
873
|
+
// outcome and `onlyne_complete` is never required of it. A note naming a
|
|
874
|
+
// task this session is still working on lands mid-task, below.
|
|
875
|
+
if (kind === "note" && (!held || held.completed)) {
|
|
876
|
+
if (this.tasks.size > 0) this.agentState = "running";
|
|
877
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
878
|
+
this.surface.customEntry?.("onlyne-assign", {
|
|
879
|
+
taskId,
|
|
880
|
+
envelopeId: envelope.id ?? null,
|
|
881
|
+
kind: "note",
|
|
882
|
+
proseInjected: proseIsNew,
|
|
883
|
+
prose,
|
|
884
|
+
attachments: attachmentPaths,
|
|
885
|
+
});
|
|
886
|
+
this.notice("in", `note ${taskId.slice(0, 8)} from ${fromLabel}: ${headOf(envelope.body?.text)}`);
|
|
887
|
+
await this.ack(taskId, true, null);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
771
890
|
// A completed record is not a live one: a new envelope for a task that
|
|
772
891
|
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
773
892
|
if (held && !held.completed) {
|
|
@@ -800,17 +919,17 @@ export class OnlyneAgent {
|
|
|
800
919
|
});
|
|
801
920
|
}
|
|
802
921
|
this.agentState = "running";
|
|
803
|
-
this.surface.wakeUser?.(text, this.imageParts(envelope));
|
|
922
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
804
923
|
this.surface.customEntry?.("onlyne-assign", {
|
|
805
924
|
taskId,
|
|
806
925
|
envelopeId: envelope.id ?? null,
|
|
807
|
-
kind
|
|
926
|
+
kind,
|
|
808
927
|
proseInjected: proseIsNew,
|
|
809
928
|
prose,
|
|
810
929
|
attachments: attachmentPaths,
|
|
811
930
|
});
|
|
812
931
|
this.activity.set({ taskId, phase: "running" });
|
|
813
|
-
this.notice("in", `task ${taskId.slice(0, 8)} from ${
|
|
932
|
+
this.notice("in", `task ${taskId.slice(0, 8)} from ${fromLabel} (${kind}): ${headOf(envelope.body?.text)}`);
|
|
814
933
|
this.startHeartbeat();
|
|
815
934
|
await this.ack(taskId, true, null);
|
|
816
935
|
}
|
|
@@ -843,7 +962,13 @@ export class OnlyneAgent {
|
|
|
843
962
|
onNudge(frame, args) {
|
|
844
963
|
const text = typeof args.text === "string" ? args.text : "";
|
|
845
964
|
const taskId = typeof args.task_id === "string" && args.task_id ? args.task_id.slice(0, 8) : "?";
|
|
846
|
-
const handedOver = text.length > 0 && this.surface.wakeUser?.(text, []
|
|
965
|
+
const handedOver = text.length > 0 && this.surface.wakeUser?.(text, [], {
|
|
966
|
+
role: this.role,
|
|
967
|
+
taskId: args.task_id ?? null,
|
|
968
|
+
kind: "nudge",
|
|
969
|
+
from: "host",
|
|
970
|
+
bodyText: text,
|
|
971
|
+
}) === true;
|
|
847
972
|
if (handedOver) this.notice("in", `nudge for task ${taskId}`);
|
|
848
973
|
else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
|
|
849
974
|
if (frame?.id === undefined || frame?.id === null) return;
|
package/src/agent.test.mjs
CHANGED
|
@@ -490,7 +490,7 @@ test("a fresh agent opens with hello, registers and reports ready", async () =>
|
|
|
490
490
|
plugin: "pi-onlyne",
|
|
491
491
|
version: PACKAGE_VERSION,
|
|
492
492
|
kind: "agent",
|
|
493
|
-
capabilities: ["register", "report", "inject", "recycle"],
|
|
493
|
+
capabilities: ["register", "report", "inject", "recycle", "resume"],
|
|
494
494
|
mount: { role: "planner", session: SESSION_ID, task_id: TASK_ID, pid: process.pid },
|
|
495
495
|
});
|
|
496
496
|
|
|
@@ -540,6 +540,27 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
|
|
|
540
540
|
assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
541
541
|
});
|
|
542
542
|
|
|
543
|
+
// A note is communication, not work: the session is woken and the delivery is
|
|
544
|
+
// acknowledged, and no task record opens, so nothing beats for it and no turn
|
|
545
|
+
// end can demand a completion.
|
|
546
|
+
test("a note is injected, acked, and opens no task", async () => {
|
|
547
|
+
const { agent, host, surface } = await startAgent();
|
|
548
|
+
agent.start();
|
|
549
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
550
|
+
|
|
551
|
+
const args = structuredClone(assignArgs());
|
|
552
|
+
args.envelope.kind = "note";
|
|
553
|
+
args.text = "From planner:\n\nwhich way is the flag?";
|
|
554
|
+
host.notify("assign", args);
|
|
555
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
556
|
+
|
|
557
|
+
assert.equal(surface.calls.wakeUser[0].text, args.text, "the frame's text is injected unchanged");
|
|
558
|
+
assert.equal(surface.calls.entries[0].data.kind, "note");
|
|
559
|
+
const acks = await waitFor(() => (host.of("assign_ack").length >= 1 ? host.of("assign_ack") : null));
|
|
560
|
+
assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
|
|
561
|
+
assert.deepEqual(agent.status().tasks, []);
|
|
562
|
+
});
|
|
563
|
+
|
|
543
564
|
// A follow-up arrives as its own envelope under the task id the session is
|
|
544
565
|
// already running. Keying the injection guard on the task swallowed it: the
|
|
545
566
|
// panel said `already injected`, nothing reached the model, and the row the
|
|
@@ -1578,3 +1599,64 @@ test("a host without the widget keeps the footer line and the stderr line", asyn
|
|
|
1578
1599
|
assert.ok(logs.some((line) => line.startsWith("in: task 11111111")), logs.join(" | "));
|
|
1579
1600
|
});
|
|
1580
1601
|
|
|
1602
|
+
// The prose is a system-prompt section, not a message: rewriting it in a second
|
|
1603
|
+
// run duplicates nothing, so a reconnect hands it over again rather than
|
|
1604
|
+
// leaving the surface of a resumed session without the role spec.
|
|
1605
|
+
test("a reconnect hands the role prose to the surface again", async () => {
|
|
1606
|
+
const { agent, host, surface } = await startAgent();
|
|
1607
|
+
agent.start();
|
|
1608
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1609
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
|
|
1610
|
+
|
|
1611
|
+
const connections = host.connections;
|
|
1612
|
+
agent.dropSocket();
|
|
1613
|
+
await waitFor(() => (host.connections > connections ? true : null));
|
|
1614
|
+
await waitFor(() => (surface.calls.prose.length === 2 ? true : null));
|
|
1615
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task", "Read the incoming task"]);
|
|
1616
|
+
assert.equal(agent.latestProse, "Read the incoming task");
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
// A surface is replaced while the process keeps its session: the new one holds
|
|
1620
|
+
// no prose of its own, and what the last welcome carried is what fills it.
|
|
1621
|
+
test("a replaced surface is handed the prose the agent remembers", async () => {
|
|
1622
|
+
const { agent, host } = await startAgent();
|
|
1623
|
+
agent.start();
|
|
1624
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1625
|
+
|
|
1626
|
+
const replacement = fakeSurface();
|
|
1627
|
+
agent.surface = replacement;
|
|
1628
|
+
assert.equal(agent.syncRoleProse(), true);
|
|
1629
|
+
assert.deepEqual(replacement.calls.prose, ["Read the incoming task"]);
|
|
1630
|
+
|
|
1631
|
+
// A surface a session switch never filled is still refilled before its first
|
|
1632
|
+
// run, so an agent that remembers nothing yet answers without a throw.
|
|
1633
|
+
const empty = new OnlyneAgent({
|
|
1634
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1635
|
+
cwd: tmpdir(),
|
|
1636
|
+
role: "planner",
|
|
1637
|
+
sessionId: SESSION_ID,
|
|
1638
|
+
taskId: TASK_ID,
|
|
1639
|
+
surface: fakeSurface(),
|
|
1640
|
+
});
|
|
1641
|
+
assert.equal(empty.syncRoleProse(), false);
|
|
1642
|
+
assert.equal(empty.latestProse, "");
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// The mount `index.ts` builds after a `/new` or a resumed session starts over
|
|
1646
|
+
// with a new surface, and the prose it was started with is what the new surface
|
|
1647
|
+
// carries from its first run.
|
|
1648
|
+
test("a mount built over a live session carries the prose into its surface", () => {
|
|
1649
|
+
const surface = fakeSurface();
|
|
1650
|
+
const agent = new OnlyneAgent({
|
|
1651
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1652
|
+
cwd: tmpdir(),
|
|
1653
|
+
role: "planner",
|
|
1654
|
+
sessionId: SESSION_ID,
|
|
1655
|
+
taskId: TASK_ID,
|
|
1656
|
+
surface,
|
|
1657
|
+
prose: "Read the incoming task",
|
|
1658
|
+
});
|
|
1659
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
|
|
1660
|
+
assert.equal(agent.latestProse, "Read the incoming task");
|
|
1661
|
+
});
|
|
1662
|
+
|
package/src/config.mjs
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// deliberate off switch.
|
|
10
10
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
13
13
|
|
|
14
14
|
/** Where the switch file lives, relative to the pi working directory. */
|
|
15
15
|
export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
|
|
16
16
|
|
|
17
|
+
/** Where the workspace's own client configuration lives. */
|
|
18
|
+
export const WORKSPACE_CONFIG_RELATIVE_PATH = join(".onlyne", "config.toml");
|
|
19
|
+
|
|
17
20
|
/** Defaults: on, and connecting as soon as a session starts. */
|
|
18
21
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
19
22
|
enabled: true,
|
|
@@ -25,7 +28,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
25
28
|
*
|
|
26
29
|
* @param {string} cwd
|
|
27
30
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
28
|
-
* @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
|
|
31
|
+
* @returns {{ enabled: boolean, autoStart: boolean, role: string | null, sessionId: string | null, workspace: string | null, cluster: string | null, resident: boolean, path: string, warning: string | null, present: boolean }}
|
|
29
32
|
*/
|
|
30
33
|
export function loadConfig(cwd, options = {}) {
|
|
31
34
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -34,7 +37,17 @@ export function loadConfig(cwd, options = {}) {
|
|
|
34
37
|
try {
|
|
35
38
|
raw = readFile(path);
|
|
36
39
|
} catch {
|
|
37
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
...DEFAULT_CONFIG,
|
|
42
|
+
role: null,
|
|
43
|
+
sessionId: null,
|
|
44
|
+
workspace: null,
|
|
45
|
+
cluster: null,
|
|
46
|
+
resident: false,
|
|
47
|
+
path,
|
|
48
|
+
warning: null,
|
|
49
|
+
present: false,
|
|
50
|
+
};
|
|
38
51
|
}
|
|
39
52
|
let parsed;
|
|
40
53
|
try {
|
|
@@ -42,18 +55,39 @@ export function loadConfig(cwd, options = {}) {
|
|
|
42
55
|
} catch (error) {
|
|
43
56
|
return {
|
|
44
57
|
...DEFAULT_CONFIG,
|
|
58
|
+
role: null,
|
|
59
|
+
sessionId: null,
|
|
60
|
+
workspace: null,
|
|
61
|
+
cluster: null,
|
|
62
|
+
resident: false,
|
|
45
63
|
path,
|
|
46
64
|
warning: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`,
|
|
47
65
|
present: true,
|
|
48
66
|
};
|
|
49
67
|
}
|
|
50
68
|
if (!parsed || typeof parsed !== "object") {
|
|
51
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
...DEFAULT_CONFIG,
|
|
71
|
+
role: null,
|
|
72
|
+
sessionId: null,
|
|
73
|
+
workspace: null,
|
|
74
|
+
cluster: null,
|
|
75
|
+
resident: false,
|
|
76
|
+
path,
|
|
77
|
+
warning: `${path} must hold a JSON object; using defaults`,
|
|
78
|
+
present: true,
|
|
79
|
+
};
|
|
52
80
|
}
|
|
53
81
|
const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
|
|
82
|
+
const rawSessionId = parsed.sessionId ?? parsed.session_id ?? parsed.session_alias;
|
|
54
83
|
return {
|
|
55
84
|
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
56
85
|
autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
|
|
86
|
+
role: typeof parsed.role === "string" && parsed.role.trim() !== "" ? parsed.role.trim() : null,
|
|
87
|
+
sessionId: typeof rawSessionId === "string" && rawSessionId.trim() !== "" ? rawSessionId.trim() : null,
|
|
88
|
+
workspace: typeof parsed.workspace === "string" && parsed.workspace.trim() !== "" ? parsed.workspace.trim() : null,
|
|
89
|
+
cluster: typeof parsed.cluster === "string" && parsed.cluster.trim() !== "" ? parsed.cluster.trim() : null,
|
|
90
|
+
resident: typeof parsed.resident === "boolean" ? parsed.resident : false,
|
|
57
91
|
path,
|
|
58
92
|
warning: null,
|
|
59
93
|
present: true,
|
|
@@ -75,3 +109,144 @@ export function sessionIdentity(env) {
|
|
|
75
109
|
if (!role || !sessionId || !taskId) return null;
|
|
76
110
|
return { role, sessionId, taskId };
|
|
77
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse the subset of `<workspace>/.onlyne/config.toml` relevant to a
|
|
115
|
+
* pre-started resident agent (`role`, `placement`, `[client.session]`).
|
|
116
|
+
*
|
|
117
|
+
* @param {string} raw
|
|
118
|
+
* @returns {{ role: string | null, placement: string | null, scope: string | null, sessionId: string | null }}
|
|
119
|
+
*/
|
|
120
|
+
export function parseWorkspaceConfigToml(raw) {
|
|
121
|
+
let section = "";
|
|
122
|
+
let role = null;
|
|
123
|
+
let placement = null;
|
|
124
|
+
let scope = null;
|
|
125
|
+
let sessionId = null;
|
|
126
|
+
for (const rawLine of String(raw).split(/\r?\n/)) {
|
|
127
|
+
const line = rawLine.replace(/#.*$/, "").trim();
|
|
128
|
+
if (!line) continue;
|
|
129
|
+
const secMatch = line.match(/^\[([^\]]+)\]$/);
|
|
130
|
+
if (secMatch) {
|
|
131
|
+
section = secMatch[1].trim();
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const kvMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"/);
|
|
135
|
+
if (!kvMatch) continue;
|
|
136
|
+
const [, key, val] = kvMatch;
|
|
137
|
+
const trimmed = val.trim();
|
|
138
|
+
if (!trimmed) continue;
|
|
139
|
+
if (section === "" && key === "role") role = trimmed;
|
|
140
|
+
else if (section === "" && key === "placement") placement = trimmed.toLowerCase();
|
|
141
|
+
else if (section === "client.session" && key === "scope") scope = trimmed.toLowerCase();
|
|
142
|
+
else if (section === "client.session" && (key === "session_id" || key === "session_alias")) {
|
|
143
|
+
sessionId = sessionId ?? trimmed;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { role, placement, scope, sessionId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Discover a resident / pre-started session identity when the agent is launched
|
|
151
|
+
* before `onlyne-client` (no `ONLYNE_TASK_ID` in `env`).
|
|
152
|
+
*
|
|
153
|
+
* Activates when any of the following holds:
|
|
154
|
+
* 1. `ONLYNE_ROLE` is set in `env` (with optional `ONLYNE_SESSION_ID` / `ONLYNE_WORKSPACE`),
|
|
155
|
+
* 2. `.pi/onlyne.json` sets `"role"`, `"sessionId"`, or `"resident": true`, or
|
|
156
|
+
* 3. `<workspace>/.onlyne/config.toml` declares `placement = "external"`,
|
|
157
|
+
* `[client.session] scope = "role"`, or `[client.session] session_id` / `session_alias`.
|
|
158
|
+
*
|
|
159
|
+
* @param {Record<string, string | undefined>} env
|
|
160
|
+
* @param {string} cwd
|
|
161
|
+
* @param {{ readFile?: (path: string) => string }} [options]
|
|
162
|
+
* @returns {{ role: string, sessionId: string | null, taskId: string | null, cluster: string | null, resident: true, workspaceRoot: string } | null}
|
|
163
|
+
*/
|
|
164
|
+
export function residentIdentity(env, cwd, options = {}) {
|
|
165
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
166
|
+
const config = loadConfig(cwd, options);
|
|
167
|
+
const envRole = typeof env.ONLYNE_ROLE === "string" && env.ONLYNE_ROLE.trim() !== "" ? env.ONLYNE_ROLE.trim() : null;
|
|
168
|
+
const envSessionId =
|
|
169
|
+
typeof (env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS) === "string" &&
|
|
170
|
+
(env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS).trim() !== ""
|
|
171
|
+
? (env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS).trim()
|
|
172
|
+
: null;
|
|
173
|
+
const envTaskId =
|
|
174
|
+
typeof env.ONLYNE_TASK_ID === "string" && env.ONLYNE_TASK_ID.trim() !== "" ? env.ONLYNE_TASK_ID.trim() : null;
|
|
175
|
+
const envWorkspace =
|
|
176
|
+
typeof env.ONLYNE_WORKSPACE === "string" && env.ONLYNE_WORKSPACE.trim() !== ""
|
|
177
|
+
? env.ONLYNE_WORKSPACE.trim()
|
|
178
|
+
: null;
|
|
179
|
+
const envResident =
|
|
180
|
+
typeof env.ONLYNE_RESIDENT === "string" &&
|
|
181
|
+
["1", "true", "yes", "on"].includes(env.ONLYNE_RESIDENT.trim().toLowerCase());
|
|
182
|
+
|
|
183
|
+
const hintRole = envRole ?? config.role;
|
|
184
|
+
const explicitWorkspace = envWorkspace ?? config.workspace;
|
|
185
|
+
let workspaceRoot = explicitWorkspace
|
|
186
|
+
? isAbsolute(explicitWorkspace)
|
|
187
|
+
? explicitWorkspace
|
|
188
|
+
: resolve(cwd, explicitWorkspace)
|
|
189
|
+
: cwd;
|
|
190
|
+
|
|
191
|
+
let toml = { role: null, placement: null, scope: null, sessionId: null };
|
|
192
|
+
const candidateRoots = [];
|
|
193
|
+
if (explicitWorkspace) {
|
|
194
|
+
candidateRoots.push(workspaceRoot);
|
|
195
|
+
} else {
|
|
196
|
+
let cur = resolve(cwd);
|
|
197
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
198
|
+
candidateRoots.push(cur);
|
|
199
|
+
if (hintRole) candidateRoots.push(join(cur, hintRole));
|
|
200
|
+
const parent = dirname(cur);
|
|
201
|
+
if (parent === cur) break;
|
|
202
|
+
cur = parent;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const candidate of candidateRoots) {
|
|
206
|
+
try {
|
|
207
|
+
const raw = readFile(join(candidate, WORKSPACE_CONFIG_RELATIVE_PATH));
|
|
208
|
+
const parsed = parseWorkspaceConfigToml(raw);
|
|
209
|
+
if (parsed.role && (!hintRole || parsed.role === hintRole)) {
|
|
210
|
+
toml = parsed;
|
|
211
|
+
workspaceRoot = candidate;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* try next candidate */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!explicitWorkspace && !toml.role && hintRole && workspaceRoot === cwd) {
|
|
219
|
+
// If started in a cluster root (`<root>/.onlyne/spec.toml` exists), the
|
|
220
|
+
// role workspace will live at `<root>/<role>`.
|
|
221
|
+
try {
|
|
222
|
+
readFile(join(cwd, ".onlyne", "spec.toml"));
|
|
223
|
+
workspaceRoot = join(cwd, hintRole);
|
|
224
|
+
} catch {
|
|
225
|
+
/* cwd itself is the workspace root */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const role = envRole ?? config.role ?? toml.role;
|
|
230
|
+
if (!role) return null;
|
|
231
|
+
|
|
232
|
+
const optedIn = Boolean(
|
|
233
|
+
envRole ||
|
|
234
|
+
envResident ||
|
|
235
|
+
config.resident ||
|
|
236
|
+
config.role ||
|
|
237
|
+
config.sessionId ||
|
|
238
|
+
toml.placement === "external" ||
|
|
239
|
+
toml.scope === "role" ||
|
|
240
|
+
toml.sessionId,
|
|
241
|
+
);
|
|
242
|
+
if (!optedIn) return null;
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
role,
|
|
246
|
+
sessionId: envSessionId ?? config.sessionId ?? toml.sessionId ?? null,
|
|
247
|
+
taskId: envTaskId,
|
|
248
|
+
cluster: config.cluster,
|
|
249
|
+
resident: true,
|
|
250
|
+
workspaceRoot,
|
|
251
|
+
};
|
|
252
|
+
}
|
package/src/config.test.mjs
CHANGED
|
@@ -10,7 +10,14 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { afterEach, test } from "node:test";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
CONFIG_RELATIVE_PATH,
|
|
15
|
+
DEFAULT_CONFIG,
|
|
16
|
+
WORKSPACE_CONFIG_RELATIVE_PATH,
|
|
17
|
+
loadConfig,
|
|
18
|
+
residentIdentity,
|
|
19
|
+
sessionIdentity,
|
|
20
|
+
} from "./config.mjs";
|
|
14
21
|
|
|
15
22
|
const cleanups = [];
|
|
16
23
|
afterEach(() => {
|
|
@@ -78,3 +85,54 @@ test("the session identity gate needs all three variables", () => {
|
|
|
78
85
|
assert.equal(sessionIdentity({}), null);
|
|
79
86
|
assert.equal(sessionIdentity({ ONLYNE_ROLE: "", ONLYNE_SESSION_ID: "s", ONLYNE_TASK_ID: "t" }), null);
|
|
80
87
|
});
|
|
88
|
+
|
|
89
|
+
test("residentIdentity resolves pre-started sessions from env, .pi/onlyne.json, or .onlyne/config.toml", () => {
|
|
90
|
+
const dir = workspace();
|
|
91
|
+
// Plain workspace with no role or resident opt-in stays inert.
|
|
92
|
+
assert.equal(residentIdentity({}, dir), null);
|
|
93
|
+
|
|
94
|
+
// ONLYNE_ROLE alone activates resident mode for pre-started supervisor/agent.
|
|
95
|
+
assert.deepEqual(residentIdentity({ ONLYNE_ROLE: "supervisor" }, dir), {
|
|
96
|
+
role: "supervisor",
|
|
97
|
+
sessionId: null,
|
|
98
|
+
taskId: null,
|
|
99
|
+
cluster: null,
|
|
100
|
+
resident: true,
|
|
101
|
+
workspaceRoot: dir,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// .onlyne/config.toml with placement = "external" and session_id activates resident mode.
|
|
105
|
+
mkdirSync(join(dir, ".onlyne"), { recursive: true });
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(dir, WORKSPACE_CONFIG_RELATIVE_PATH),
|
|
108
|
+
'role = "planner"\nplacement = "external"\n\n[client.session]\nscope = "role"\nsession_id = "planner-main"\n',
|
|
109
|
+
);
|
|
110
|
+
assert.deepEqual(residentIdentity({}, dir), {
|
|
111
|
+
role: "planner",
|
|
112
|
+
sessionId: "planner-main",
|
|
113
|
+
taskId: null,
|
|
114
|
+
cluster: null,
|
|
115
|
+
resident: true,
|
|
116
|
+
workspaceRoot: dir,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the switch file's cluster is trimmed, optional, and reaches residentIdentity", () => {
|
|
121
|
+
const dir = workspace(JSON.stringify({ role: "lazuli", cluster: " workshop " }));
|
|
122
|
+
assert.equal(loadConfig(dir).cluster, "workshop");
|
|
123
|
+
|
|
124
|
+
// Absent, blank and non-string all mean no declaration, never a guess.
|
|
125
|
+
for (const body of [{ role: "lazuli" }, { role: "lazuli", cluster: "" }, { role: "lazuli", cluster: 42 }]) {
|
|
126
|
+
assert.equal(loadConfig(workspace(JSON.stringify(body))).cluster, null);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The identity carries the declaration so the resolver can pass it through.
|
|
130
|
+
assert.deepEqual(residentIdentity({}, dir), {
|
|
131
|
+
role: "lazuli",
|
|
132
|
+
sessionId: null,
|
|
133
|
+
taskId: null,
|
|
134
|
+
cluster: "workshop",
|
|
135
|
+
resident: true,
|
|
136
|
+
workspaceRoot: dir,
|
|
137
|
+
});
|
|
138
|
+
});
|