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/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
  /**
@@ -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
- this.notice("state", this.closed ? "detached" : "reconnecting");
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: the footer carries the steady header line, stderr the event.
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: envelope.kind ?? "task",
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 ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
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, []) === true;
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;
@@ -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 { ...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
+ });