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.
@@ -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
- // The plugin still owes one report at a turn end: the failure it witnessed
692
- // itself. That report waits for the idle it is about, exactly as the phase-based
693
- // signal always did.
694
- test("a busy pi holds the failed-turn report until it is waiting for input", async () => {
695
- let waiting = false;
696
- const surface = fakeSurface({ waiting: () => waiting });
697
- const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
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 new Promise((resolve) => setTimeout(resolve, 80));
704
- assert.deepEqual(completions(host), [], "a busy pi is not reported on yet");
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
- waiting = true;
707
- const complete = await waitFor(() => completions(host)[0] ?? null);
708
- assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
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
- test("a live background task holds the failed-turn report until its task is terminal", async () => {
712
- let backgroundWork = true;
713
- const surface = fakeSurface({ backgroundWork: () => backgroundWork });
714
- const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
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
- await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
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
- backgroundWork = false;
727
- agent.onSettled();
728
- const complete = await waitFor(() => completions(host)[0] ?? null);
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({ options: { settleFallbackMs: 40 } });
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 fallback window and the
746
- // settled signal both land, and neither has anything to decide.
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.errored, false);
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 { ...DEFAULT_CONFIG, path, warning: null, present: false };
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 { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
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
+ }
@@ -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 { CONFIG_RELATIVE_PATH, DEFAULT_CONFIG, loadConfig, sessionIdentity } from "./config.mjs";
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
+ });