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/index.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  // a plain pi session and the extension stays silent rather than failing.
9
9
  //
10
10
  // session_start -> bind the env session id, read env + .pi/onlyne.json,
11
- // connect, register tools
11
+ // connect, register tools; a resident plugin that is
12
+ // already connected only follows the switch
13
+ // session_switch -> /new, /resume and /fork on a host that names them;
14
+ // session_branch the same re-point a resident plugin does when current
15
+ // session_tree pi reports the switch through session_start, and
16
+ // after tree navigation
12
17
  // before_agent_start -> the role prose becomes one section of the system
13
18
  // prompt the run is about to send (the instruction layer)
14
19
  // turn_start -> heartbeat{running}
@@ -40,8 +45,9 @@ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-
40
45
  import { Type } from "typebox";
41
46
 
42
47
  import { OnlyneAgent } from "./agent.mjs";
43
- import { loadConfig, sessionIdentity } from "./config.mjs";
44
- import { resolveSocketPath } from "./socket.mjs";
48
+ import { ASSIGN_MESSAGE_TYPE, createAssignMessageComponent } from "./activity.mjs";
49
+ import { loadConfig, residentIdentity, sessionIdentity } from "./config.mjs";
50
+ import { resolveResidentSocketPath, resolveSocketPath } from "./socket.mjs";
45
51
  import { createSurface } from "./pi-surface.mjs";
46
52
  import { foreignSessionRefusal, mountsSession, ownsSession, sessionIdOf } from "./session-scope.mjs";
47
53
 
@@ -82,11 +88,11 @@ interface PiSurface {
82
88
  registerTool: boolean;
83
89
  registerCommand: boolean;
84
90
  };
85
- wakeUser(text: string, parts?: ImagePartInput[]): boolean;
91
+ wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
86
92
  roleProse(text: string): boolean;
87
93
  applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
88
94
  customEntry(customType: string, data: unknown): boolean;
89
- widget(lines: string[] | undefined): void;
95
+ widget(lines: string[] | undefined, activity?: unknown): void;
90
96
  status(text: string): void;
91
97
  welcome(welcome: WelcomeLike): void;
92
98
  isIdle(): boolean;
@@ -173,6 +179,16 @@ export default function onlyne(pi: ExtensionAPI) {
173
179
  const registerSurface = () => {
174
180
  if (registered || !surface) return;
175
181
  registered = true;
182
+ if ("registerMessageRenderer" in pi && typeof pi.registerMessageRenderer === "function") {
183
+ try {
184
+ pi.registerMessageRenderer(ASSIGN_MESSAGE_TYPE, (message, options, theme) => {
185
+ if (!message?.details || typeof message.details !== "object") return undefined;
186
+ return createAssignMessageComponent(message.details, message.content, theme, !options?.expanded);
187
+ });
188
+ } catch (error) {
189
+ log(`registerMessageRenderer(${ASSIGN_MESSAGE_TYPE}) refused: ${error instanceof Error ? error.message : String(error)}`);
190
+ }
191
+ }
176
192
  try {
177
193
  pi.registerTool(defineTool({
178
194
  name: "onlyne_send",
@@ -329,8 +345,29 @@ export default function onlyne(pi: ExtensionAPI) {
329
345
  }
330
346
  };
331
347
 
332
- pi.on("session_start", async (_event, ctx) => {
333
- const identity = sessionIdentity(env);
348
+ /**
349
+ * Re-point the plugin at the session pi made current. The socket belongs to
350
+ * the role, not to the session file that mounted it: /new, /resume, /fork and
351
+ * a tree jump replace the live session in this process, and the tools must
352
+ * follow it instead of refusing every call as foreign.
353
+ */
354
+ const syncSessionContext = (_event: unknown, ctx: ExtensionContext) => {
355
+ if (!agent) return;
356
+ context = ctx;
357
+ const currentId = sessionIdOf(ctx?.sessionManager);
358
+ if (currentId) {
359
+ boundSessionId = currentId;
360
+ }
361
+ if (surface?.available.widget) {
362
+ surface.widget(agent.activity.lines(), agent.activity);
363
+ }
364
+ surface?.status(agent.activity.lines()[0]);
365
+ };
366
+
367
+ pi.on("session_start", async (event, ctx) => {
368
+ const spawned = sessionIdentity(env);
369
+ const resident = spawned ? null : residentIdentity(env, ctx.cwd);
370
+ const identity = spawned ?? resident;
334
371
  if (!identity) return; // not an onlyne session: stay out of the way
335
372
  // Only the session the client names may mount. A pi process runs more than
336
373
  // one session — a `task` subagent's sidechain, `/new` — and each of them
@@ -340,12 +377,27 @@ export default function onlyne(pi: ExtensionAPI) {
340
377
  // expose one; the env-bound mount is then the only candidate and stays the
341
378
  // behaviour, with no line to explain.
342
379
  const starting = sessionIdOf(ctx.sessionManager);
343
- if (!mountsSession(identity.sessionId, starting)) {
344
- log(`session ${starting} is not the onlyne session ${identity.sessionId}; staying inert`);
380
+ // A resident plugin already holds its connection, so another session_start
381
+ // in this process is a re-point, not a mount: /new, /resume and /fork
382
+ // replace the live session, and pi can re-bind this same session's
383
+ // extensions. The sidechain a `task` subagent runs re-binds every extension
384
+ // with reason "startup" and a different id, and stays inert.
385
+ if (!spawned && agent !== null && (event.reason !== "startup" || starting === boundSessionId)) {
386
+ syncSessionContext(event, ctx);
387
+ return;
388
+ }
389
+ if (spawned) {
390
+ if (!mountsSession(spawned.sessionId, starting)) {
391
+ log(`session ${starting} is not the onlyne session ${spawned.sessionId}; staying inert`);
392
+ return;
393
+ }
394
+ } else if (boundSessionId !== null && starting !== null && starting !== boundSessionId) {
395
+ log(`session ${starting} is not the resident onlyne session ${boundSessionId}; staying inert`);
345
396
  return;
346
397
  }
398
+ const wireSessionId = identity.sessionId ?? starting;
347
399
  context = ctx;
348
- boundSessionId = identity.sessionId;
400
+ boundSessionId = spawned ? spawned.sessionId : (starting ?? wireSessionId);
349
401
  const config = loadConfig(ctx.cwd);
350
402
  if (config.warning) log(config.warning);
351
403
  if (!config.enabled) {
@@ -357,23 +409,30 @@ export default function onlyne(pi: ExtensionAPI) {
357
409
  // neither has no socket to dial, and saying so is the whole answer: this
358
410
  // stays a plain pi session instead of retrying a path nothing serves.
359
411
  let socketPath: string | null = null;
412
+ const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
360
413
  try {
361
- socketPath = resolveSocketPath(env, ctx.cwd);
414
+ socketPath = resident
415
+ ? resolveResidentSocketPath(env, targetWorkspace, identity.role)
416
+ : resolveSocketPath(env, ctx.cwd);
362
417
  } catch (error) {
363
418
  log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
364
419
  }
365
420
  if (socketPath === null) return;
366
- surface = createSurface({ pi, log, context: () => context, sessionId: identity.sessionId });
421
+ surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
367
422
  agent = new OnlyneAgent({
368
423
  socketPath,
424
+ resolveSocket: resident
425
+ ? () => resolveResidentSocketPath(env, targetWorkspace, identity.role)
426
+ : undefined,
369
427
  cwd: ctx.cwd,
370
428
  role: identity.role,
371
- sessionId: identity.sessionId,
429
+ sessionId: wireSessionId,
372
430
  taskId: identity.taskId,
431
+ resident: Boolean(resident),
373
432
  surface,
374
433
  log,
375
434
  });
376
- log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
435
+ log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
377
436
  registerSurface();
378
437
  if (!surface.available.wakeUser) {
379
438
  // Without an injection surface the payload travels through config_get,
@@ -384,6 +443,18 @@ export default function onlyne(pi: ExtensionAPI) {
384
443
  if (config.autoStart) agent.start();
385
444
  });
386
445
 
446
+ // `session_switch` and `session_branch` name the same transitions in the omp
447
+ // lineage of this API; current pi reports them as `session_start` with a
448
+ // reason and keeps `session_tree` for navigation, so one helper serves all.
449
+ // The installed typings no longer declare the first two, but the host stores
450
+ // handlers by name, which this narrow cast is the whole boundary of.
451
+ const legacySessionEvents = pi as unknown as {
452
+ on(event: "session_switch" | "session_branch", handler: (event: unknown, ctx: ExtensionContext) => void): void;
453
+ };
454
+ legacySessionEvents.on("session_switch", syncSessionContext);
455
+ legacySessionEvents.on("session_branch", syncSessionContext);
456
+ pi.on("session_tree", async (event, ctx) => syncSessionContext(event, ctx));
457
+
387
458
  // The role prose is instruction-layer text: `before_agent_start` hands the
388
459
  // handler the prompt options the run is about to render, and a section written
389
460
  // there is part of the system prompt rather than one more message the model has
@@ -18,7 +18,7 @@
18
18
  // subagentRegistry ~/.pi/subagents/missions (background-subagents.mjs)
19
19
  // registerTool / registerCommand are probed by index.ts itself.
20
20
 
21
- import { WIDGET_KEY } from "./activity.mjs";
21
+ import { ASSIGN_MESSAGE_TYPE, WIDGET_KEY } from "./activity.mjs";
22
22
  import { createSubagentProbe } from "./background-subagents.mjs";
23
23
  import { createBackgroundProbe } from "./background-work.mjs";
24
24
 
@@ -34,6 +34,7 @@ export const PROSE_SECTION = "onlyne-role-prose";
34
34
  * @param {{ pi: any, log: (line: string) => void, context: () => any, sessionId?: string | null }} options
35
35
  */
36
36
  export function createSurface({ pi, log, context, sessionId = null }) {
37
+ let currentSessionId = typeof sessionId === "function" ? sessionId : () => sessionId;
37
38
  const has = (value) => typeof value === "function";
38
39
  const ctx = () => {
39
40
  try {
@@ -71,7 +72,7 @@ export function createSurface({ pi, log, context, sessionId = null }) {
71
72
  // leave a subagent running with no tool event to bracket it. Scoped to this
72
73
  // session's own missions when the plugin knows its id.
73
74
  const subagents = createSubagentProbe({
74
- getSessionId: () => sessionId,
75
+ getSessionId: () => currentSessionId(),
75
76
  log,
76
77
  });
77
78
 
@@ -95,7 +96,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
95
96
  const available = {
96
97
  wakeUser: has(pi.sendUserMessage),
97
98
  customEntry: has(pi.appendEntry),
98
- widget: has(ctx()?.ui?.setWidget),
99
+ get widget() {
100
+ return has(ctx()?.ui?.setWidget);
101
+ },
99
102
  status: true,
100
103
  exit: true,
101
104
  isIdle: true,
@@ -103,9 +106,19 @@ export function createSurface({ pi, log, context, sessionId = null }) {
103
106
  registerCommand: has(pi.registerCommand),
104
107
  };
105
108
 
106
- const widget = (lines) => {
109
+ const widget = (lines, activity = null) => {
107
110
  try {
108
- ctx()?.ui?.setWidget?.(WIDGET_KEY, lines);
111
+ const ui = ctx()?.ui;
112
+ if (!has(ui?.setWidget)) return;
113
+ if (lines === undefined) {
114
+ ui.setWidget(WIDGET_KEY, undefined);
115
+ return;
116
+ }
117
+ if (activity && typeof activity.component === "function") {
118
+ ui.setWidget(WIDGET_KEY, (tui, theme) => activity.component(theme, tui));
119
+ return;
120
+ }
121
+ ui.setWidget(WIDGET_KEY, lines);
109
122
  } catch {
110
123
  /* widget is decoration; never let it break the protocol */
111
124
  }
@@ -120,7 +133,7 @@ export function createSurface({ pi, log, context, sessionId = null }) {
120
133
  };
121
134
 
122
135
  /**
123
- * One pi user message; images ride along as pi image content parts.
136
+ * One delivery, one message; images ride along as pi image content parts.
124
137
  *
125
138
  * pi reads an image part as the flat `ImageContent` of its message types —
126
139
  * `data` plus `mimeType` — and normalizes every part before the message is
@@ -128,8 +141,21 @@ export function createSurface({ pi, log, context, sessionId = null }) {
128
141
  * pi reports that failure in its own pane and hands nothing back to this
129
142
  * plugin, so an unusable part is dropped here and the assignment still
130
143
  * travels: the delivery text already names the path the client wrote.
144
+ *
145
+ * With `meta` and `pi.sendMessage`, the `onlyne_assign` card IS the delivery:
146
+ * a custom message reaches the model, and it is what the transcript draws, so
147
+ * a delivery does not also arrive as a second user message. `triggerTurn` is
148
+ * what an idle session owes it (a `display: true` custom message renders
149
+ * without starting a turn of its own); while a turn is running the card queues
150
+ * behind it as a follow-up instead of interrupting tool execution.
151
+ *
152
+ * A pi without `sendMessage` takes the plain `sendUserMessage` below — idle
153
+ * without `deliverAs` so the delivery starts the turn it owes, `followUp` when
154
+ * busy (`deliverAs: "followUp"` only enqueues and requires a prior
155
+ * assistant/toolResult tail to auto-continue). That path is the pre-card
156
+ * shape, kept so an older pi still receives a delivery.
131
157
  */
132
- const wakeUser = (text, parts = []) => {
158
+ const wakeUser = (text, parts = [], meta = null) => {
133
159
  if (!available.wakeUser) {
134
160
  log("pi has no sendUserMessage; the assignment reached the session log only");
135
161
  return false;
@@ -150,8 +176,43 @@ export function createSurface({ pi, log, context, sessionId = null }) {
150
176
  mimeType: part.mime,
151
177
  })),
152
178
  ];
179
+ const current = ctx();
180
+ let idle = false;
153
181
  try {
154
- pi.sendUserMessage(content, { deliverAs: "followUp" });
182
+ if (has(current?.isIdle)) idle = current.isIdle() === true;
183
+ } catch {
184
+ idle = false;
185
+ }
186
+ if (meta && typeof meta === "object" && has(pi.sendMessage)) {
187
+ try {
188
+ pi.sendMessage(
189
+ {
190
+ customType: ASSIGN_MESSAGE_TYPE,
191
+ content,
192
+ display: true,
193
+ details: {
194
+ role: meta.role ?? null,
195
+ taskId: meta.taskId ?? null,
196
+ kind: meta.kind ?? "task",
197
+ from: meta.from ?? null,
198
+ envelopeId: meta.envelopeId ?? null,
199
+ bodyText: meta.bodyText ?? text,
200
+ },
201
+ },
202
+ idle ? { triggerTurn: true } : { deliverAs: "followUp" },
203
+ );
204
+ return true;
205
+ } catch (error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ log(`sendMessage refused: ${message}; the delivery takes the user-message route`);
208
+ }
209
+ }
210
+ try {
211
+ if (idle) {
212
+ pi.sendUserMessage(content);
213
+ } else {
214
+ pi.sendUserMessage(content, { deliverAs: "followUp" });
215
+ }
155
216
  return true;
156
217
  } catch (error) {
157
218
  try {
@@ -16,6 +16,7 @@
16
16
  import assert from "node:assert/strict";
17
17
  import { test } from "node:test";
18
18
 
19
+ import { ASSIGN_MESSAGE_TYPE } from "./activity.mjs";
19
20
  import { PROSE_SECTION, createSurface } from "./pi-surface.mjs";
20
21
 
21
22
  /**
@@ -40,7 +41,7 @@ const PNG_CONTENT = [
40
41
  * know an option does.
41
42
  * @param {{ refuse?: (content: unknown, options: unknown) => boolean }} [options]
42
43
  */
43
- function fakeSurface({ refuse = null } = {}) {
44
+ function fakeSurface({ refuse = null, ctx = null } = {}) {
44
45
  const calls = [];
45
46
  const messages = [];
46
47
  const lines = [];
@@ -50,14 +51,12 @@ function fakeSurface({ refuse = null } = {}) {
50
51
  calls.push({ content, options });
51
52
  if (refuse?.(content, options)) throw new Error("this pi does not take that option");
52
53
  },
53
- // Recorded, never expected: no part of this plugin is a conversation
54
- // message any more, and the prose is the one that could creep back.
55
54
  sendMessage(message, options) {
56
55
  messages.push({ message, options });
57
56
  },
58
57
  },
59
58
  log: (line) => lines.push(line),
60
- context: () => null,
59
+ context: () => ctx,
61
60
  });
62
61
  return { calls, messages, lines, surface };
63
62
  }
@@ -128,6 +127,81 @@ test("delivery asks for followUp and a refusal of that option retries with the s
128
127
  assert.deepEqual(refusing.lines, [], "a delivery that landed on the retry is not a failure");
129
128
  });
130
129
 
130
+ test("a card delivery is one message: it starts the idle turn and queues while busy", () => {
131
+ // One delivery, one message. omp's rule for a custom message decides the
132
+ // option: with no `deliverAs` a `display: true` message renders without
133
+ // starting a turn, `triggerTurn` starts one when the session is idle, and
134
+ // `deliverAs: "followUp"` only queues behind a running turn.
135
+ const turns = [];
136
+ const queued = [];
137
+ const userMessages = [];
138
+ const cards = [];
139
+ let idle = true;
140
+ const surface = createSurface({
141
+ pi: {
142
+ sendMessage(message, options) {
143
+ cards.push({ message, options });
144
+ if (options?.triggerTurn === true && idle) {
145
+ turns.push(message.content);
146
+ return;
147
+ }
148
+ if (options?.deliverAs === "followUp") queued.push(message.content);
149
+ },
150
+ sendUserMessage(content, options) {
151
+ userMessages.push({ content, options });
152
+ },
153
+ },
154
+ log: () => {},
155
+ context: () => ({ isIdle: () => idle }),
156
+ });
157
+
158
+ const meta = {
159
+ role: "caerulea",
160
+ taskId: "97971b93-0000-4000-8000-000000000000",
161
+ kind: "note",
162
+ from: "role:_supervisor",
163
+ envelopeId: "env-1",
164
+ bodyText: "ping from supervisor",
165
+ };
166
+ const text = "From _supervisor:\n\nping from supervisor";
167
+ assert.equal(surface.wakeUser(text, [], meta), true);
168
+ assert.equal(cards.length, 1);
169
+ assert.equal(cards[0].message.customType, ASSIGN_MESSAGE_TYPE);
170
+ assert.equal(cards[0].message.display, true);
171
+ assert.equal(cards[0].message.details.kind, "note");
172
+ assert.equal(cards[0].message.details.from, "role:_supervisor");
173
+ assert.deepEqual(cards[0].options, { triggerTurn: true });
174
+ assert.deepEqual(turns, [text], "an idle session starts its turn from the card");
175
+ assert.deepEqual(userMessages, [], "the delivery is one message, never a card plus a prompt");
176
+
177
+ // While busy (`isIdle() === false`), the next card queues as followUp instead
178
+ // of interrupting the running turn.
179
+ idle = false;
180
+ const second = "From _supervisor:\n\nsecond note while busy";
181
+ assert.equal(surface.wakeUser(second, [], meta), true);
182
+ assert.equal(cards.length, 2);
183
+ assert.deepEqual(cards[1].options, { deliverAs: "followUp" });
184
+ assert.deepEqual(queued, [second]);
185
+ assert.deepEqual(turns, [text], "a busy session queues rather than starting a second turn");
186
+ assert.deepEqual(userMessages, []);
187
+ });
188
+
189
+ test("a pi without sendMessage takes the delivery as a plain user message", () => {
190
+ const calls = [];
191
+ const surface = createSurface({
192
+ pi: {
193
+ sendUserMessage(content, options) {
194
+ calls.push({ content, options });
195
+ },
196
+ },
197
+ log: () => {},
198
+ context: () => ({ isIdle: () => true }),
199
+ });
200
+
201
+ assert.equal(surface.wakeUser(TASK_TEXT, [], { kind: "note" }), true);
202
+ assert.deepEqual(calls, [{ content: TASK_TEXT, options: undefined }]);
203
+ });
204
+
131
205
  /** One role prose, in the shape the client renders and `agent.mjs` hands over. */
132
206
  const PROSE_TEXT = "Read the incoming task.\n\nAnswer in the house style.\n";
133
207
 
package/src/socket.mjs CHANGED
@@ -210,12 +210,57 @@ export function listClientRegistrations(env, options = {}) {
210
210
  path: join(dir, name),
211
211
  root: canonicalRoot(parsed.root, options),
212
212
  kind: parsed.kind,
213
+ role: typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null,
213
214
  runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
214
215
  });
215
216
  }
216
217
  return found;
217
218
  }
218
219
 
220
+ /**
221
+ * Resolve the socket path for a resident or pre-started pi session.
222
+ *
223
+ * Unlike [`resolveSocketPath`], when `onlyne-client` has not been started yet
224
+ * (so `<runtime_dir>/<digest>.json` does not exist), this falls back to the
225
+ * deterministic `<runtime_dir>/<workspaceDigest(workspaceRoot)>.sock` path so
226
+ * the agent can enter its background reconnect loop and handshake automatically
227
+ * the moment `onlyne-client` binds the socket. When a client registration for
228
+ * `role` appears in `<runtime_dir>`, it prefers that registration's socket.
229
+ *
230
+ * @param {Record<string, string | undefined>} env
231
+ * @param {string} workspaceRoot
232
+ * @param {string} role
233
+ * @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string }} [options]
234
+ * @returns {string}
235
+ */
236
+ export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}) {
237
+ const injected = typeof env[SOCKET_ENV] === "string" ? env[SOCKET_ENV].trim() : "";
238
+ if (injected) return injected;
239
+
240
+ const dir = runtimeDir(env, options);
241
+ const here = canonicalRoot(workspaceRoot, options);
242
+ const candidates = listClientRegistrations(env, options);
243
+
244
+ const coveringRole = candidates.filter((entry) => covers(entry.root, here) && (!role || !entry.role || entry.role === role));
245
+ if (coveringRole.length > 0) {
246
+ const nearest = coveringRole.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
247
+ return join(dir, `${workspaceDigest(nearest.root, options)}${SOCKET_SUFFIX}`);
248
+ }
249
+
250
+ if (role) {
251
+ const byRole = candidates.filter((entry) => entry.role === role);
252
+ if (byRole.length === 1) {
253
+ return join(dir, `${workspaceDigest(byRole[0].root, options)}${SOCKET_SUFFIX}`);
254
+ }
255
+ }
256
+
257
+ try {
258
+ return resolveSocketPath(env, workspaceRoot, options);
259
+ } catch {
260
+ return socketPath(workspaceRoot, env, options);
261
+ }
262
+ }
263
+
219
264
  /**
220
265
  * The socket path to dial for the pi working directory `cwd`.
221
266
  *
@@ -17,6 +17,7 @@ import {
17
17
  canonicalRoot,
18
18
  listClientRegistrations,
19
19
  registrationPath,
20
+ resolveResidentSocketPath,
20
21
  resolveSocketPath,
21
22
  runtimeDir,
22
23
  socketPath,
@@ -235,3 +236,29 @@ test("a registration read through the injected filesystem names the same socket"
235
236
  // The env is what names the socket a client injected, and it is still first.
236
237
  assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir, [SOCKET_ENV]: "/tmp/served" }, workspace, options), "/tmp/served");
237
238
  });
239
+
240
+ test("resolveResidentSocketPath falls back to deterministic socket before client starts, then adopts registered role socket", () => {
241
+ const clusterRoot = scratch("pi-onlyne-cluster-");
242
+ const roleWs = join(clusterRoot, "supervisor");
243
+ mkdirSync(roleWs, { recursive: true });
244
+ const emptyDir = runtime([]);
245
+
246
+ // Before onlyne-client starts, no registration exists; returns deterministic socket path.
247
+ assert.equal(
248
+ resolveResidentSocketPath(envFor(emptyDir), roleWs, "supervisor"),
249
+ socketPath(roleWs, envFor(emptyDir)),
250
+ );
251
+
252
+ // Once onlyne-client starts and registers the role in the runtime directory, even if
253
+ // omp was started in the parent clusterRoot, it resolves the registered role's socket.
254
+ const liveDir = runtime([
255
+ {
256
+ name: registrationName(roleWs),
257
+ body: { kind: "client", role: "supervisor", root: canonicalRoot(roleWs), pid: 9001, version: "2.3.0", runtime: "pi" },
258
+ },
259
+ ]);
260
+ assert.equal(
261
+ resolveResidentSocketPath(envFor(liveDir), clusterRoot, "supervisor"),
262
+ socketPath(roleWs, envFor(liveDir)),
263
+ );
264
+ });