pi-onlyne 2.2.0 → 2.3.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 +16 -7
- package/README.zh.md +12 -5
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +102 -11
- package/src/agent.test.mjs +22 -1
- package/src/config.mjs +173 -3
- package/src/config.test.mjs +37 -1
- package/src/index.ts +85 -14
- package/src/pi-surface.mjs +69 -8
- package/src/pi-surface.test.mjs +78 -4
- package/src/socket.mjs +45 -0
- package/src/socket.test.mjs +27 -0
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
|
/**
|
|
@@ -82,10 +97,12 @@ export class OnlyneAgent {
|
|
|
82
97
|
*/
|
|
83
98
|
constructor(options) {
|
|
84
99
|
this.socketPath = options.socketPath;
|
|
100
|
+
this.resolveSocket = typeof options.resolveSocket === "function" ? options.resolveSocket : null;
|
|
85
101
|
this.cwd = options.cwd;
|
|
86
102
|
this.role = options.role;
|
|
87
103
|
this.sessionId = options.sessionId;
|
|
88
104
|
this.envTaskId = options.taskId;
|
|
105
|
+
this.resident = Boolean(options.resident);
|
|
89
106
|
this.surface = options.surface;
|
|
90
107
|
/** The widget panel's model (`activity.mjs`); seeded with the local role. */
|
|
91
108
|
this.activity = options.activity ?? createActivity();
|
|
@@ -228,6 +245,16 @@ export class OnlyneAgent {
|
|
|
228
245
|
|
|
229
246
|
connect() {
|
|
230
247
|
if (this.closed || this.socket) return;
|
|
248
|
+
if (this.resolveSocket) {
|
|
249
|
+
try {
|
|
250
|
+
const refreshed = this.resolveSocket();
|
|
251
|
+
if (typeof refreshed === "string" && refreshed.length > 0) {
|
|
252
|
+
this.socketPath = refreshed;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
/* keep previous socketPath */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
231
258
|
let socket;
|
|
232
259
|
try {
|
|
233
260
|
socket = this.createConnection(this.socketPath);
|
|
@@ -271,6 +298,7 @@ export class OnlyneAgent {
|
|
|
271
298
|
/** Tear the current socket down and schedule the next attempt. */
|
|
272
299
|
dropSocket() {
|
|
273
300
|
const socket = this.socket;
|
|
301
|
+
const wasConnected = this.connected;
|
|
274
302
|
this.socket = null;
|
|
275
303
|
this.connected = false;
|
|
276
304
|
this.handshaking = false;
|
|
@@ -278,7 +306,13 @@ export class OnlyneAgent {
|
|
|
278
306
|
this.rejectAll("connection lost");
|
|
279
307
|
this.stopHeartbeat();
|
|
280
308
|
this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
|
|
281
|
-
|
|
309
|
+
if (!this.closed && (!this.resident || wasConnected)) {
|
|
310
|
+
this.notice("state", "reconnecting");
|
|
311
|
+
} else if (this.closed) {
|
|
312
|
+
this.notice("state", "detached");
|
|
313
|
+
} else {
|
|
314
|
+
this.redraw();
|
|
315
|
+
}
|
|
282
316
|
if (socket && !socket.destroyed) socket.destroy();
|
|
283
317
|
this.scheduleReconnect();
|
|
284
318
|
}
|
|
@@ -321,6 +355,11 @@ export class OnlyneAgent {
|
|
|
321
355
|
}
|
|
322
356
|
this.welcome = welcome;
|
|
323
357
|
this.connected = true;
|
|
358
|
+
const hadConnectFailure = this.lastError !== null;
|
|
359
|
+
this.lastError = null;
|
|
360
|
+
if (this.resident && hadConnectFailure) {
|
|
361
|
+
this.log(`connected role=${welcome.role} session=${this.sessionId ?? "resident"} socket=${this.socketPath}`);
|
|
362
|
+
}
|
|
324
363
|
// The client names every delivery it already handed to this session, so a
|
|
325
364
|
// process that restarts under a live client does not re-inject work its
|
|
326
365
|
// predecessor read: the delivery guard is process memory, and this is how it
|
|
@@ -467,7 +506,15 @@ export class OnlyneAgent {
|
|
|
467
506
|
}
|
|
468
507
|
|
|
469
508
|
noteFailure(error) {
|
|
509
|
+
const prevCode = this.lastError?.code ?? null;
|
|
470
510
|
this.lastError = error;
|
|
511
|
+
if (
|
|
512
|
+
this.resident &&
|
|
513
|
+
(error?.code === "ENOENT" || error?.code === "ECONNREFUSED") &&
|
|
514
|
+
prevCode === error.code
|
|
515
|
+
) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
471
518
|
this.log(`socket error: ${error?.message ?? error}`);
|
|
472
519
|
}
|
|
473
520
|
|
|
@@ -479,14 +526,20 @@ export class OnlyneAgent {
|
|
|
479
526
|
* @param {"in" | "dup" | "out" | "warn" | "state"} kind
|
|
480
527
|
* @param {string} text
|
|
481
528
|
*/
|
|
529
|
+
redraw() {
|
|
530
|
+
this.surface.status?.(this.activity.lines()[0]);
|
|
531
|
+
if (this.surface.available?.widget) {
|
|
532
|
+
this.surface.widget?.(this.activity.lines(), this.activity);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
482
536
|
notice(kind, text) {
|
|
483
537
|
this.activity.note(kind, text);
|
|
538
|
+
this.redraw();
|
|
484
539
|
if (this.surface.available?.widget) {
|
|
485
|
-
this.surface.widget?.(this.activity.lines());
|
|
486
540
|
return;
|
|
487
541
|
}
|
|
488
|
-
// No panel:
|
|
489
|
-
this.surface.status?.(this.activity.lines()[0]);
|
|
542
|
+
// No panel: stderr carries the event.
|
|
490
543
|
this.log(`${kind}: ${text}`);
|
|
491
544
|
}
|
|
492
545
|
|
|
@@ -730,11 +783,14 @@ export class OnlyneAgent {
|
|
|
730
783
|
* that is what every such frame meant.
|
|
731
784
|
*/
|
|
732
785
|
keepsSession() {
|
|
733
|
-
return this.scope === "task" || this.scope === "role";
|
|
786
|
+
return this.resident || this.scope === "task" || this.scope === "role";
|
|
734
787
|
}
|
|
735
788
|
|
|
736
789
|
async onAssign(args) {
|
|
737
790
|
if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
|
|
791
|
+
if (this.resident && typeof args.session_id === "string" && args.session_id) {
|
|
792
|
+
this.sessionId = args.session_id;
|
|
793
|
+
}
|
|
738
794
|
const envelope = args.envelope ?? {};
|
|
739
795
|
const taskId = args.task_id ?? envelope.causality?.task ?? null;
|
|
740
796
|
if (!taskId) {
|
|
@@ -767,7 +823,36 @@ export class OnlyneAgent {
|
|
|
767
823
|
this.surface.roleProse?.(prose);
|
|
768
824
|
}
|
|
769
825
|
|
|
826
|
+
const kind = envelope.kind ?? "task";
|
|
770
827
|
const held = this.tasks.get(taskId);
|
|
828
|
+
const fromLabel = describePrincipal(envelope.from);
|
|
829
|
+
const wakeMeta = {
|
|
830
|
+
role: this.role,
|
|
831
|
+
taskId,
|
|
832
|
+
kind,
|
|
833
|
+
from: fromLabel,
|
|
834
|
+
envelopeId: envelope.id ?? null,
|
|
835
|
+
bodyText: envelope.body?.text ?? text,
|
|
836
|
+
};
|
|
837
|
+
// A note is a message, not work: it is injected and acknowledged, and it
|
|
838
|
+
// opens no task record, so nothing beats for it, no turn end owes it an
|
|
839
|
+
// outcome and `onlyne_complete` is never required of it. A note naming a
|
|
840
|
+
// task this session is still working on lands mid-task, below.
|
|
841
|
+
if (kind === "note" && (!held || held.completed)) {
|
|
842
|
+
if (this.tasks.size > 0) this.agentState = "running";
|
|
843
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
844
|
+
this.surface.customEntry?.("onlyne-assign", {
|
|
845
|
+
taskId,
|
|
846
|
+
envelopeId: envelope.id ?? null,
|
|
847
|
+
kind: "note",
|
|
848
|
+
proseInjected: proseIsNew,
|
|
849
|
+
prose,
|
|
850
|
+
attachments: attachmentPaths,
|
|
851
|
+
});
|
|
852
|
+
this.notice("in", `note ${taskId.slice(0, 8)} from ${fromLabel}: ${headOf(envelope.body?.text)}`);
|
|
853
|
+
await this.ack(taskId, true, null);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
771
856
|
// A completed record is not a live one: a new envelope for a task that
|
|
772
857
|
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
773
858
|
if (held && !held.completed) {
|
|
@@ -800,17 +885,17 @@ export class OnlyneAgent {
|
|
|
800
885
|
});
|
|
801
886
|
}
|
|
802
887
|
this.agentState = "running";
|
|
803
|
-
this.surface.wakeUser?.(text, this.imageParts(envelope));
|
|
888
|
+
this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
|
|
804
889
|
this.surface.customEntry?.("onlyne-assign", {
|
|
805
890
|
taskId,
|
|
806
891
|
envelopeId: envelope.id ?? null,
|
|
807
|
-
kind
|
|
892
|
+
kind,
|
|
808
893
|
proseInjected: proseIsNew,
|
|
809
894
|
prose,
|
|
810
895
|
attachments: attachmentPaths,
|
|
811
896
|
});
|
|
812
897
|
this.activity.set({ taskId, phase: "running" });
|
|
813
|
-
this.notice("in", `task ${taskId.slice(0, 8)} from ${
|
|
898
|
+
this.notice("in", `task ${taskId.slice(0, 8)} from ${fromLabel} (${kind}): ${headOf(envelope.body?.text)}`);
|
|
814
899
|
this.startHeartbeat();
|
|
815
900
|
await this.ack(taskId, true, null);
|
|
816
901
|
}
|
|
@@ -843,7 +928,13 @@ export class OnlyneAgent {
|
|
|
843
928
|
onNudge(frame, args) {
|
|
844
929
|
const text = typeof args.text === "string" ? args.text : "";
|
|
845
930
|
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, []
|
|
931
|
+
const handedOver = text.length > 0 && this.surface.wakeUser?.(text, [], {
|
|
932
|
+
role: this.role,
|
|
933
|
+
taskId: args.task_id ?? null,
|
|
934
|
+
kind: "nudge",
|
|
935
|
+
from: "host",
|
|
936
|
+
bodyText: text,
|
|
937
|
+
}) === true;
|
|
847
938
|
if (handedOver) this.notice("in", `nudge for task ${taskId}`);
|
|
848
939
|
else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
|
|
849
940
|
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
|
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,
|
|
@@ -34,7 +37,16 @@ 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
|
+
resident: false,
|
|
46
|
+
path,
|
|
47
|
+
warning: null,
|
|
48
|
+
present: false,
|
|
49
|
+
};
|
|
38
50
|
}
|
|
39
51
|
let parsed;
|
|
40
52
|
try {
|
|
@@ -42,18 +54,36 @@ export function loadConfig(cwd, options = {}) {
|
|
|
42
54
|
} catch (error) {
|
|
43
55
|
return {
|
|
44
56
|
...DEFAULT_CONFIG,
|
|
57
|
+
role: null,
|
|
58
|
+
sessionId: null,
|
|
59
|
+
workspace: null,
|
|
60
|
+
resident: false,
|
|
45
61
|
path,
|
|
46
62
|
warning: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`,
|
|
47
63
|
present: true,
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
66
|
if (!parsed || typeof parsed !== "object") {
|
|
51
|
-
return {
|
|
67
|
+
return {
|
|
68
|
+
...DEFAULT_CONFIG,
|
|
69
|
+
role: null,
|
|
70
|
+
sessionId: null,
|
|
71
|
+
workspace: null,
|
|
72
|
+
resident: false,
|
|
73
|
+
path,
|
|
74
|
+
warning: `${path} must hold a JSON object; using defaults`,
|
|
75
|
+
present: true,
|
|
76
|
+
};
|
|
52
77
|
}
|
|
53
78
|
const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
|
|
79
|
+
const rawSessionId = parsed.sessionId ?? parsed.session_id ?? parsed.session_alias;
|
|
54
80
|
return {
|
|
55
81
|
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
56
82
|
autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
|
|
83
|
+
role: typeof parsed.role === "string" && parsed.role.trim() !== "" ? parsed.role.trim() : null,
|
|
84
|
+
sessionId: typeof rawSessionId === "string" && rawSessionId.trim() !== "" ? rawSessionId.trim() : null,
|
|
85
|
+
workspace: typeof parsed.workspace === "string" && parsed.workspace.trim() !== "" ? parsed.workspace.trim() : null,
|
|
86
|
+
resident: typeof parsed.resident === "boolean" ? parsed.resident : false,
|
|
57
87
|
path,
|
|
58
88
|
warning: null,
|
|
59
89
|
present: true,
|
|
@@ -75,3 +105,143 @@ export function sessionIdentity(env) {
|
|
|
75
105
|
if (!role || !sessionId || !taskId) return null;
|
|
76
106
|
return { role, sessionId, taskId };
|
|
77
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse the subset of `<workspace>/.onlyne/config.toml` relevant to a
|
|
111
|
+
* pre-started resident agent (`role`, `placement`, `[client.session]`).
|
|
112
|
+
*
|
|
113
|
+
* @param {string} raw
|
|
114
|
+
* @returns {{ role: string | null, placement: string | null, scope: string | null, sessionId: string | null }}
|
|
115
|
+
*/
|
|
116
|
+
export function parseWorkspaceConfigToml(raw) {
|
|
117
|
+
let section = "";
|
|
118
|
+
let role = null;
|
|
119
|
+
let placement = null;
|
|
120
|
+
let scope = null;
|
|
121
|
+
let sessionId = null;
|
|
122
|
+
for (const rawLine of String(raw).split(/\r?\n/)) {
|
|
123
|
+
const line = rawLine.replace(/#.*$/, "").trim();
|
|
124
|
+
if (!line) continue;
|
|
125
|
+
const secMatch = line.match(/^\[([^\]]+)\]$/);
|
|
126
|
+
if (secMatch) {
|
|
127
|
+
section = secMatch[1].trim();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const kvMatch = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*"([^"]*)"/);
|
|
131
|
+
if (!kvMatch) continue;
|
|
132
|
+
const [, key, val] = kvMatch;
|
|
133
|
+
const trimmed = val.trim();
|
|
134
|
+
if (!trimmed) continue;
|
|
135
|
+
if (section === "" && key === "role") role = trimmed;
|
|
136
|
+
else if (section === "" && key === "placement") placement = trimmed.toLowerCase();
|
|
137
|
+
else if (section === "client.session" && key === "scope") scope = trimmed.toLowerCase();
|
|
138
|
+
else if (section === "client.session" && (key === "session_id" || key === "session_alias")) {
|
|
139
|
+
sessionId = sessionId ?? trimmed;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { role, placement, scope, sessionId };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Discover a resident / pre-started session identity when the agent is launched
|
|
147
|
+
* before `onlyne-client` (no `ONLYNE_TASK_ID` in `env`).
|
|
148
|
+
*
|
|
149
|
+
* Activates when any of the following holds:
|
|
150
|
+
* 1. `ONLYNE_ROLE` is set in `env` (with optional `ONLYNE_SESSION_ID` / `ONLYNE_WORKSPACE`),
|
|
151
|
+
* 2. `.pi/onlyne.json` sets `"role"`, `"sessionId"`, or `"resident": true`, or
|
|
152
|
+
* 3. `<workspace>/.onlyne/config.toml` declares `placement = "external"`,
|
|
153
|
+
* `[client.session] scope = "role"`, or `[client.session] session_id` / `session_alias`.
|
|
154
|
+
*
|
|
155
|
+
* @param {Record<string, string | undefined>} env
|
|
156
|
+
* @param {string} cwd
|
|
157
|
+
* @param {{ readFile?: (path: string) => string }} [options]
|
|
158
|
+
* @returns {{ role: string, sessionId: string | null, taskId: string | null, resident: true, workspaceRoot: string } | null}
|
|
159
|
+
*/
|
|
160
|
+
export function residentIdentity(env, cwd, options = {}) {
|
|
161
|
+
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
162
|
+
const config = loadConfig(cwd, options);
|
|
163
|
+
const envRole = typeof env.ONLYNE_ROLE === "string" && env.ONLYNE_ROLE.trim() !== "" ? env.ONLYNE_ROLE.trim() : null;
|
|
164
|
+
const envSessionId =
|
|
165
|
+
typeof (env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS) === "string" &&
|
|
166
|
+
(env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS).trim() !== ""
|
|
167
|
+
? (env.ONLYNE_SESSION_ID ?? env.ONLYNE_SESSION_ALIAS).trim()
|
|
168
|
+
: null;
|
|
169
|
+
const envTaskId =
|
|
170
|
+
typeof env.ONLYNE_TASK_ID === "string" && env.ONLYNE_TASK_ID.trim() !== "" ? env.ONLYNE_TASK_ID.trim() : null;
|
|
171
|
+
const envWorkspace =
|
|
172
|
+
typeof env.ONLYNE_WORKSPACE === "string" && env.ONLYNE_WORKSPACE.trim() !== ""
|
|
173
|
+
? env.ONLYNE_WORKSPACE.trim()
|
|
174
|
+
: null;
|
|
175
|
+
const envResident =
|
|
176
|
+
typeof env.ONLYNE_RESIDENT === "string" &&
|
|
177
|
+
["1", "true", "yes", "on"].includes(env.ONLYNE_RESIDENT.trim().toLowerCase());
|
|
178
|
+
|
|
179
|
+
const hintRole = envRole ?? config.role;
|
|
180
|
+
const explicitWorkspace = envWorkspace ?? config.workspace;
|
|
181
|
+
let workspaceRoot = explicitWorkspace
|
|
182
|
+
? isAbsolute(explicitWorkspace)
|
|
183
|
+
? explicitWorkspace
|
|
184
|
+
: resolve(cwd, explicitWorkspace)
|
|
185
|
+
: cwd;
|
|
186
|
+
|
|
187
|
+
let toml = { role: null, placement: null, scope: null, sessionId: null };
|
|
188
|
+
const candidateRoots = [];
|
|
189
|
+
if (explicitWorkspace) {
|
|
190
|
+
candidateRoots.push(workspaceRoot);
|
|
191
|
+
} else {
|
|
192
|
+
let cur = resolve(cwd);
|
|
193
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
194
|
+
candidateRoots.push(cur);
|
|
195
|
+
if (hintRole) candidateRoots.push(join(cur, hintRole));
|
|
196
|
+
const parent = dirname(cur);
|
|
197
|
+
if (parent === cur) break;
|
|
198
|
+
cur = parent;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const candidate of candidateRoots) {
|
|
202
|
+
try {
|
|
203
|
+
const raw = readFile(join(candidate, WORKSPACE_CONFIG_RELATIVE_PATH));
|
|
204
|
+
const parsed = parseWorkspaceConfigToml(raw);
|
|
205
|
+
if (parsed.role && (!hintRole || parsed.role === hintRole)) {
|
|
206
|
+
toml = parsed;
|
|
207
|
+
workspaceRoot = candidate;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* try next candidate */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!explicitWorkspace && !toml.role && hintRole && workspaceRoot === cwd) {
|
|
215
|
+
// If started in a cluster root (`<root>/.onlyne/spec.toml` exists), the
|
|
216
|
+
// role workspace will live at `<root>/<role>`.
|
|
217
|
+
try {
|
|
218
|
+
readFile(join(cwd, ".onlyne", "spec.toml"));
|
|
219
|
+
workspaceRoot = join(cwd, hintRole);
|
|
220
|
+
} catch {
|
|
221
|
+
/* cwd itself is the workspace root */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const role = envRole ?? config.role ?? toml.role;
|
|
226
|
+
if (!role) return null;
|
|
227
|
+
|
|
228
|
+
const optedIn = Boolean(
|
|
229
|
+
envRole ||
|
|
230
|
+
envResident ||
|
|
231
|
+
config.resident ||
|
|
232
|
+
config.role ||
|
|
233
|
+
config.sessionId ||
|
|
234
|
+
toml.placement === "external" ||
|
|
235
|
+
toml.scope === "role" ||
|
|
236
|
+
toml.sessionId,
|
|
237
|
+
);
|
|
238
|
+
if (!optedIn) return null;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
role,
|
|
242
|
+
sessionId: envSessionId ?? config.sessionId ?? toml.sessionId ?? null,
|
|
243
|
+
taskId: envTaskId,
|
|
244
|
+
resident: true,
|
|
245
|
+
workspaceRoot,
|
|
246
|
+
};
|
|
247
|
+
}
|
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,32 @@ 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
|
+
resident: true,
|
|
100
|
+
workspaceRoot: dir,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// .onlyne/config.toml with placement = "external" and session_id activates resident mode.
|
|
104
|
+
mkdirSync(join(dir, ".onlyne"), { recursive: true });
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(dir, WORKSPACE_CONFIG_RELATIVE_PATH),
|
|
107
|
+
'role = "planner"\nplacement = "external"\n\n[client.session]\nscope = "role"\nsession_id = "planner-main"\n',
|
|
108
|
+
);
|
|
109
|
+
assert.deepEqual(residentIdentity({}, dir), {
|
|
110
|
+
role: "planner",
|
|
111
|
+
sessionId: "planner-main",
|
|
112
|
+
taskId: null,
|
|
113
|
+
resident: true,
|
|
114
|
+
workspaceRoot: dir,
|
|
115
|
+
});
|
|
116
|
+
});
|