pi-onlyne 2.1.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 +77 -35
- package/README.zh.md +48 -19
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +157 -84
- package/src/agent.test.mjs +97 -45
- package/src/config.mjs +173 -3
- package/src/config.test.mjs +37 -1
- package/src/index.ts +163 -18
- package/src/pi-surface.mjs +69 -8
- package/src/pi-surface.test.mjs +78 -4
- package/src/session-scope.mjs +90 -0
- package/src/session-scope.test.mjs +61 -0
- package/src/socket.mjs +45 -0
- package/src/socket.test.mjs +27 -0
package/src/agent.test.mjs
CHANGED
|
@@ -117,6 +117,9 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
|
|
|
117
117
|
* reports before answering the rest, the way a client whose settle failed would.
|
|
118
118
|
* `request` sends a host frame carrying an id, which is how the plugin is asked
|
|
119
119
|
* for an answer (`nudge`); `answers` reads what came back.
|
|
120
|
+
* `alreadySettled` answers every `complete` report with the client's
|
|
121
|
+
* already-settled shape in `data`, the way a client that had settled the task
|
|
122
|
+
* before the report arrived does.
|
|
120
123
|
*/
|
|
121
124
|
class FakeHost {
|
|
122
125
|
constructor({
|
|
@@ -125,6 +128,7 @@ class FakeHost {
|
|
|
125
128
|
failSend = false,
|
|
126
129
|
handoffError = null,
|
|
127
130
|
failCompletions = 0,
|
|
131
|
+
alreadySettled = null,
|
|
128
132
|
} = {}) {
|
|
129
133
|
this.server = createServer((socket) => this.onConnection(socket));
|
|
130
134
|
this.frames = [];
|
|
@@ -135,6 +139,7 @@ class FakeHost {
|
|
|
135
139
|
this.failSend = failSend;
|
|
136
140
|
this.handoffError = handoffError;
|
|
137
141
|
this.failCompletions = failCompletions;
|
|
142
|
+
this.alreadySettled = alreadySettled;
|
|
138
143
|
this.held = [];
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -189,6 +194,9 @@ class FakeHost {
|
|
|
189
194
|
}));
|
|
190
195
|
return;
|
|
191
196
|
}
|
|
197
|
+
if (this.alreadySettled && frame.op === "report" && frame.args?.kind === "complete") {
|
|
198
|
+
body = { ok: true, data: { already_settled: this.alreadySettled } };
|
|
199
|
+
}
|
|
192
200
|
if (this.failSend && frame.op === "send") {
|
|
193
201
|
socket.write(encodeFrame({
|
|
194
202
|
reply_to: frame.id,
|
|
@@ -293,10 +301,11 @@ async function startAgent({
|
|
|
293
301
|
failSend = false,
|
|
294
302
|
handoffError = null,
|
|
295
303
|
failCompletions = 0,
|
|
304
|
+
alreadySettled = null,
|
|
296
305
|
} = {}) {
|
|
297
306
|
const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
|
|
298
307
|
const socketPath = join(dir, "s");
|
|
299
|
-
const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions });
|
|
308
|
+
const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions, alreadySettled });
|
|
300
309
|
await host.listen(socketPath);
|
|
301
310
|
const logs = [];
|
|
302
311
|
const agent = new OnlyneAgent({
|
|
@@ -308,9 +317,6 @@ async function startAgent({
|
|
|
308
317
|
surface,
|
|
309
318
|
log: (line) => logs.push(line),
|
|
310
319
|
ladder: [20, 40, 80],
|
|
311
|
-
// The fallback is the agent_settled-less path; a test that wants it asks for
|
|
312
|
-
// a short window, and every other test drives the exit explicitly.
|
|
313
|
-
settleFallbackMs: options.settleFallbackMs ?? 30_000,
|
|
314
320
|
heartbeatMs: options.heartbeatMs ?? 60_000,
|
|
315
321
|
...(capabilities ? { capabilities } : {}),
|
|
316
322
|
...(options.host !== undefined ? { host: options.host } : {}),
|
|
@@ -484,7 +490,7 @@ test("a fresh agent opens with hello, registers and reports ready", async () =>
|
|
|
484
490
|
plugin: "pi-onlyne",
|
|
485
491
|
version: PACKAGE_VERSION,
|
|
486
492
|
kind: "agent",
|
|
487
|
-
capabilities: ["register", "report", "inject", "recycle"],
|
|
493
|
+
capabilities: ["register", "report", "inject", "recycle", "resume"],
|
|
488
494
|
mount: { role: "planner", session: SESSION_ID, task_id: TASK_ID, pid: process.pid },
|
|
489
495
|
});
|
|
490
496
|
|
|
@@ -534,6 +540,27 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
|
|
|
534
540
|
assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
535
541
|
});
|
|
536
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
|
+
|
|
537
564
|
// A follow-up arrives as its own envelope under the task id the session is
|
|
538
565
|
// already running. Keying the injection guard on the task swallowed it: the
|
|
539
566
|
// panel said `already injected`, nothing reached the model, and the row the
|
|
@@ -688,30 +715,37 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
|
|
|
688
715
|
assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
|
|
689
716
|
});
|
|
690
717
|
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
718
|
+
// A turn that ends with an error is not a verdict. pi retries a retryable
|
|
719
|
+
// provider error and stays inside the same run, and the client's nudge can
|
|
720
|
+
// restart a session a moment after it settles, so a report sent from here would
|
|
721
|
+
// race work that is still going. The client's own turn-end rule (§3c) owns the
|
|
722
|
+
// ending, and its sweeps own a session whose agent died; the plugin states its
|
|
723
|
+
// phase and nothing else.
|
|
724
|
+
test("a turn error sends no completion, even after a settle and a wait", async () => {
|
|
725
|
+
const { agent, host, surface } = await startAgent();
|
|
698
726
|
agent.start();
|
|
699
727
|
await waitFor(() => host.of("report").length >= 1);
|
|
700
728
|
host.notify("assign", assignArgs());
|
|
701
729
|
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
730
|
+
|
|
731
|
+
agent.onTurnStart();
|
|
702
732
|
agent.onTurnError("provider exploded");
|
|
703
|
-
await
|
|
704
|
-
|
|
733
|
+
await waitFor(() => heartbeats(host).some((observed) => observed.agent === "running"));
|
|
734
|
+
agent.onSettled();
|
|
735
|
+
// Past the window the deleted fallback used to fire in (40 ms in the harness
|
|
736
|
+
// that pinned it), so a plugin that still armed one would have reported here.
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
705
738
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
assert.deepEqual(
|
|
739
|
+
assert.deepEqual(completions(host), [], "a failed turn reports no outcome nobody claimed");
|
|
740
|
+
assert.deepEqual(agent.status().tasks, [TASK_ID], "the task is still open");
|
|
741
|
+
assert.deepEqual(surface.calls.exits, [], "and the session stays mounted");
|
|
709
742
|
});
|
|
710
743
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
744
|
+
// What the error does supply is the head fallback: a completion whose summary
|
|
745
|
+
// carries nothing still has a display line, and it is the sentence the failed
|
|
746
|
+
// turn left behind.
|
|
747
|
+
test("an explicit completion after a turn error uses the error as its head", async () => {
|
|
748
|
+
const { agent, host, surface } = await startAgent();
|
|
715
749
|
agent.start();
|
|
716
750
|
await waitFor(() => host.of("report").length >= 1);
|
|
717
751
|
host.notify("assign", assignArgs());
|
|
@@ -719,14 +753,11 @@ test("a live background task holds the failed-turn report until its task is term
|
|
|
719
753
|
|
|
720
754
|
agent.onTurnStart();
|
|
721
755
|
agent.onTurnError("provider exploded");
|
|
722
|
-
|
|
723
|
-
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
724
|
-
assert.deepEqual(completions(host), [], "work off the agent loop keeps the report off");
|
|
756
|
+
const result = await agent.completeFromTool({ outcome: "done" });
|
|
725
757
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
|
|
758
|
+
assert.equal(result.head, "provider exploded", "the error text is the head fallback");
|
|
759
|
+
const [complete] = completions(host);
|
|
760
|
+
assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "done", head: "provider exploded" });
|
|
730
761
|
});
|
|
731
762
|
|
|
732
763
|
// The turn-end rule has one owner, the client (`docs/v2-CONTRACT.md` §3c): a
|
|
@@ -735,15 +766,15 @@ test("a live background task holds the failed-turn report until its task is term
|
|
|
735
766
|
// plugin's answer to a clean turn end is its beat and nothing else — no
|
|
736
767
|
// injection, no count, and no outcome nobody claimed.
|
|
737
768
|
test("a turn that ends without a completion settles nothing and says nothing", async () => {
|
|
738
|
-
const { agent, host, surface } = await startAgent(
|
|
769
|
+
const { agent, host, surface } = await startAgent();
|
|
739
770
|
agent.start();
|
|
740
771
|
await waitFor(() => host.of("report").length >= 1);
|
|
741
772
|
host.notify("assign", assignArgs());
|
|
742
773
|
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
743
774
|
agent.noteAssistantText("OK");
|
|
744
775
|
|
|
745
|
-
// Two clean turns and a settle after each: the
|
|
746
|
-
//
|
|
776
|
+
// Two clean turns and a settle after each: the settled signal lands, and it
|
|
777
|
+
// has nothing to decide.
|
|
747
778
|
agent.onTurnStart();
|
|
748
779
|
agent.onTurnEnd();
|
|
749
780
|
agent.onSettled();
|
|
@@ -772,18 +803,6 @@ test("an assigned task that never ran is not completed", async () => {
|
|
|
772
803
|
assert.deepEqual(agent.status().tasks, [TASK_ID]);
|
|
773
804
|
});
|
|
774
805
|
|
|
775
|
-
test("a failed turn settles the task as failed with the error as head", async () => {
|
|
776
|
-
const { agent, host, surface } = await startAgent();
|
|
777
|
-
agent.start();
|
|
778
|
-
await waitFor(() => host.of("report").length >= 1);
|
|
779
|
-
host.notify("assign", assignArgs());
|
|
780
|
-
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
781
|
-
agent.onTurnStart();
|
|
782
|
-
agent.onTurnError("provider exploded");
|
|
783
|
-
const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
|
|
784
|
-
assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
|
|
785
|
-
});
|
|
786
|
-
|
|
787
806
|
test("an explicit tool outcome wins and a second completion is refused", async () => {
|
|
788
807
|
const { agent, host, surface } = await startAgent();
|
|
789
808
|
agent.start();
|
|
@@ -1277,7 +1296,6 @@ test("a host that never answers the hello is dropped and retried", async () => {
|
|
|
1277
1296
|
log: (line) => logs.push(line),
|
|
1278
1297
|
ladder: [20],
|
|
1279
1298
|
helloTimeoutMs: 40,
|
|
1280
|
-
settleFallbackMs: 30,
|
|
1281
1299
|
});
|
|
1282
1300
|
cleanups.push(async () => {
|
|
1283
1301
|
agent.stop("test");
|
|
@@ -1314,6 +1332,40 @@ test("a completion sends the summary as the head and adds nothing else", async (
|
|
|
1314
1332
|
assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR);
|
|
1315
1333
|
});
|
|
1316
1334
|
|
|
1335
|
+
// A client that had already settled this task answers the report with its own
|
|
1336
|
+
// record instead of `data: null`. The completion still leaves the task settled —
|
|
1337
|
+
// the client's row is the authority — but the caller hands the record on, so the
|
|
1338
|
+
// tool result can say the call changed nothing rather than claim the outcome.
|
|
1339
|
+
test("a report the client answers as already settled comes back as the client's record", async () => {
|
|
1340
|
+
const settled = {
|
|
1341
|
+
state: "failed",
|
|
1342
|
+
settled_at: "2026-10-11T05:15:47Z",
|
|
1343
|
+
head: "provider exploded",
|
|
1344
|
+
};
|
|
1345
|
+
const { agent, host } = await startAgent({ alreadySettled: settled });
|
|
1346
|
+
agent.start();
|
|
1347
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1348
|
+
|
|
1349
|
+
const result = await agent.completeFromTool({ outcome: "done", summary: "finished it" });
|
|
1350
|
+
assert.deepEqual(result, {
|
|
1351
|
+
taskId: TASK_ID,
|
|
1352
|
+
outcome: "done",
|
|
1353
|
+
head: "finished it",
|
|
1354
|
+
alreadySettled: settled,
|
|
1355
|
+
});
|
|
1356
|
+
assert.equal(completions(host).length, 1, "the report still went out");
|
|
1357
|
+
assert.deepEqual(agent.status().tasks, [], "the client's row is the authority");
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test("a normal accepted completion carries no already-settled record", async () => {
|
|
1361
|
+
const { agent, host } = await startAgent();
|
|
1362
|
+
agent.start();
|
|
1363
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1364
|
+
|
|
1365
|
+
const result = await agent.completeFromTool({ outcome: "done", summary: "finished it" });
|
|
1366
|
+
assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "finished it" });
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1317
1369
|
test("details and files ride the completion unchanged", async () => {
|
|
1318
1370
|
const { agent, host } = await startAgent();
|
|
1319
1371
|
agent.start();
|
|
@@ -1374,7 +1426,7 @@ test("a nudge hands the client's sentence over as it stands and answers ok", asy
|
|
|
1374
1426
|
// A nudge is not a delivery and not a report: the task stays open, its record
|
|
1375
1427
|
// is untouched, and no frame of the plugin's own follows.
|
|
1376
1428
|
assert.equal(agent.tasks.get(TASK_ID), record);
|
|
1377
|
-
assert.equal(record.
|
|
1429
|
+
assert.equal(record.head, "", "the nudge leaves the head fallback alone");
|
|
1378
1430
|
assert.deepEqual(host.of("report").slice(reportsBefore), []);
|
|
1379
1431
|
});
|
|
1380
1432
|
|
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
|
+
});
|