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/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
- /** Capabilities this plugin implements on the wire. */
61
- export const CAPABILITIES = ["register", "report", "inject", "recycle"];
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
- this.notice("state", this.closed ? "detached" : "reconnecting");
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 && !this.deliveredProse.has(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: the footer carries the steady header line, stderr the event.
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 (proseIsNew) {
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: envelope.kind ?? "task",
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 ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
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, []) === true;
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;
@@ -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 { ...DEFAULT_CONFIG, path, warning: null, present: false };
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 { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
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
+ }
@@ -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,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
+ });