pi-onlyne 2.3.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/README.md CHANGED
@@ -121,6 +121,9 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
121
121
  | --- | --- | --- |
122
122
  | `enabled` | `true` | `false` turns the extension off for this workspace |
123
123
  | `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
124
+ | `role` | — | the role this session serves when the client injected no `ONLYNE_ROLE`; also what makes the file a resident opt-in |
125
+ | `workspace` | — | the role workspace, when the session did not start inside it; a relative path resolves against the pi working directory |
126
+ | `cluster` | — | the cluster whose client this session dials — the serving server's `[server] name`; unset means the tree decides |
124
127
 
125
128
  A missing file means every default. A malformed file prints one warning on stderr and
126
129
  keeps the defaults: a typo must not silently disable a role. The client does not read
@@ -128,6 +131,13 @@ this file (§11 of the plan downgraded the old readiness gates to generate-time
128
131
  advice), so only this extension consumes it; the key shape stays the one the templates
129
132
  carry.
130
133
 
134
+ `cluster` is the answer for a session whose body runs in one cluster's tree while the
135
+ role workspace belongs to another: with the key set, the client registered for this role
136
+ in that cluster is dialed first, whatever tree the session runs in. A cluster holding two
137
+ clients of the role is refused by name rather than picked from. With the key absent — or
138
+ naming a cluster no client has registered — resolution falls back to the tree, exactly as
139
+ it did before the key existed.
140
+
131
141
  Nothing else is needed. The workspace's `session_command` in `spec.toml` already spawns
132
142
  `pi` per task (`["pi", "--session-id", "{session}"]`), and the client injects the
133
143
  environment this extension keys on.
package/README.zh.md CHANGED
@@ -112,6 +112,8 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
112
112
  | --- | --- | --- |
113
113
  | `enabled` | `true` | `false` 时该工作区禁用扩展 |
114
114
  | `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
115
+ | `workspace` | — | 角色工作区路径(当会话未在其中启动时生效;相对路径相对 pi 工作目录解析) |
116
+ | `cluster` | — | 本会话要连接的集群名(对应服务端的 `[server] name`;未设时由工作区目录树决定) |
115
117
 
116
118
  文件缺失即取各自默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
117
119
  role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
@@ -120,6 +122,11 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
120
122
  其余无需配置。工作区 `spec.toml` 的 `session_command` 已经按任务拉起 `pi`
121
123
  (`["pi", "--session-id", "{session}"]`),client 负责注入本扩展识别的环境变量。
122
124
 
125
+ `cluster` 专门解决“会话主体在一个集群的目录树下运行、但挂载的角色工作区属于另一个集群”的夹心代理场景:
126
+ 配置该键后,无论会话当前处于哪棵目录树下,都会优先连接该集群中为该角色注册的客户端。若同一集群内存在
127
+ 两个同名角色客户端,会直接显式报错拒绝,绝不随意选取。若未配置该键(或指定的集群未发现注册客户端),
128
+ 则优雅回退至原有的工作区目录树收敛规则。
129
+
123
130
  ## 2. 能力表
124
131
 
125
132
  `hello` 只声明真实实现的能力:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agent.mjs CHANGED
@@ -93,6 +93,7 @@ export class OnlyneAgent {
93
93
  * helloTimeoutMs?: number,
94
94
  * createConnection?: (path: string) => any,
95
95
  * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
96
+ * prose?: string,
96
97
  * }} options
97
98
  */
98
99
  constructor(options) {
@@ -171,6 +172,18 @@ export class OnlyneAgent {
171
172
  */
172
173
  this.injectedDeliveries = new Set();
173
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);
174
187
  /** Pushes that arrived before the handshake finished; see `onFrame`. */
175
188
  this.handshaking = false;
176
189
  this.deferredPushes = [];
@@ -241,6 +254,21 @@ export class OnlyneAgent {
241
254
  };
242
255
  }
243
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
+
244
272
  // ------------------------------------------------------------- transport
245
273
 
246
274
  connect() {
@@ -387,8 +415,13 @@ export class OnlyneAgent {
387
415
  });
388
416
  this.notice("state", `connected role=${welcome.role} gen=${welcome.generation} host=${welcome.hostCapabilities.join(",")}`);
389
417
  const prose = welcome.prose.trim();
390
- if (prose && !this.deliveredProse.has(prose)) {
418
+ if (prose) {
419
+ this.latestProse = prose;
391
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.
392
425
  this.surface.roleProse?.(prose);
393
426
  }
394
427
 
@@ -818,9 +851,10 @@ export class OnlyneAgent {
818
851
  const attachmentPaths = Array.isArray(args.attachments) ? args.attachments : [];
819
852
  const prose = typeof args.prose === "string" ? args.prose.trim() : "";
820
853
  const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
821
- if (proseIsNew) {
854
+ if (prose.length > 0) {
855
+ this.latestProse = prose;
822
856
  this.deliveredProse.add(prose);
823
- this.surface.roleProse?.(prose);
857
+ if (proseIsNew) this.surface.roleProse?.(prose);
824
858
  }
825
859
 
826
860
  const kind = envelope.kind ?? "task";
@@ -1599,3 +1599,64 @@ test("a host without the widget keeps the footer line and the stderr line", asyn
1599
1599
  assert.ok(logs.some((line) => line.startsWith("in: task 11111111")), logs.join(" | "));
1600
1600
  });
1601
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
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG = Object.freeze({
28
28
  *
29
29
  * @param {string} cwd
30
30
  * @param {{ readFile?: (path: string) => string }} [options]
31
- * @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 }}
32
32
  */
33
33
  export function loadConfig(cwd, options = {}) {
34
34
  const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
@@ -42,6 +42,7 @@ export function loadConfig(cwd, options = {}) {
42
42
  role: null,
43
43
  sessionId: null,
44
44
  workspace: null,
45
+ cluster: null,
45
46
  resident: false,
46
47
  path,
47
48
  warning: null,
@@ -57,6 +58,7 @@ export function loadConfig(cwd, options = {}) {
57
58
  role: null,
58
59
  sessionId: null,
59
60
  workspace: null,
61
+ cluster: null,
60
62
  resident: false,
61
63
  path,
62
64
  warning: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`,
@@ -69,6 +71,7 @@ export function loadConfig(cwd, options = {}) {
69
71
  role: null,
70
72
  sessionId: null,
71
73
  workspace: null,
74
+ cluster: null,
72
75
  resident: false,
73
76
  path,
74
77
  warning: `${path} must hold a JSON object; using defaults`,
@@ -83,6 +86,7 @@ export function loadConfig(cwd, options = {}) {
83
86
  role: typeof parsed.role === "string" && parsed.role.trim() !== "" ? parsed.role.trim() : null,
84
87
  sessionId: typeof rawSessionId === "string" && rawSessionId.trim() !== "" ? rawSessionId.trim() : null,
85
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,
86
90
  resident: typeof parsed.resident === "boolean" ? parsed.resident : false,
87
91
  path,
88
92
  warning: null,
@@ -155,7 +159,7 @@ export function parseWorkspaceConfigToml(raw) {
155
159
  * @param {Record<string, string | undefined>} env
156
160
  * @param {string} cwd
157
161
  * @param {{ readFile?: (path: string) => string }} [options]
158
- * @returns {{ role: string, sessionId: string | null, taskId: string | null, resident: true, workspaceRoot: string } | null}
162
+ * @returns {{ role: string, sessionId: string | null, taskId: string | null, cluster: string | null, resident: true, workspaceRoot: string } | null}
159
163
  */
160
164
  export function residentIdentity(env, cwd, options = {}) {
161
165
  const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
@@ -241,6 +245,7 @@ export function residentIdentity(env, cwd, options = {}) {
241
245
  role,
242
246
  sessionId: envSessionId ?? config.sessionId ?? toml.sessionId ?? null,
243
247
  taskId: envTaskId,
248
+ cluster: config.cluster,
244
249
  resident: true,
245
250
  workspaceRoot,
246
251
  };
@@ -96,6 +96,7 @@ test("residentIdentity resolves pre-started sessions from env, .pi/onlyne.json,
96
96
  role: "supervisor",
97
97
  sessionId: null,
98
98
  taskId: null,
99
+ cluster: null,
99
100
  resident: true,
100
101
  workspaceRoot: dir,
101
102
  });
@@ -110,6 +111,27 @@ test("residentIdentity resolves pre-started sessions from env, .pi/onlyne.json,
110
111
  role: "planner",
111
112
  sessionId: "planner-main",
112
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",
113
135
  resident: true,
114
136
  workspaceRoot: dir,
115
137
  });
package/src/index.ts CHANGED
@@ -40,7 +40,17 @@
40
40
  // environment, and a sidechain that called `onlyne_complete` would settle the
41
41
  // parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
42
42
  // what a call from a foreign session is told.
43
-
43
+ //
44
+ // One process also holds at most one resident claim. A resident pi is started
45
+ // by hand rather than spawned by the client, so a `task` subagent's sidechain
46
+ // inherits nothing that names a task and reaches the resident branch through a
47
+ // second call of this factory with fresh closure state; the claim is what stops
48
+ // it from opening a connection that impersonates the parent's resident alias
49
+ // (`docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md` §4.2). Where the host
50
+ // itself declares what the session is — omp marks every subagent context
51
+ // `agent.kind === "sub"` — that declaration decides first, on both identity
52
+ // paths, and the claim is the reading left for a host that declares nothing.
53
+ //
44
54
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
45
55
  import { Type } from "typebox";
46
56
 
@@ -49,7 +59,14 @@ import { ASSIGN_MESSAGE_TYPE, createAssignMessageComponent } from "./activity.mj
49
59
  import { loadConfig, residentIdentity, sessionIdentity } from "./config.mjs";
50
60
  import { resolveResidentSocketPath, resolveSocketPath } from "./socket.mjs";
51
61
  import { createSurface } from "./pi-surface.mjs";
52
- import { foreignSessionRefusal, mountsSession, ownsSession, sessionIdOf } from "./session-scope.mjs";
62
+ import {
63
+ declaredSubagent,
64
+ foreignSessionRefusal,
65
+ isSessionReplacement,
66
+ mountsSession,
67
+ ownsSession,
68
+ sessionIdOf,
69
+ } from "./session-scope.mjs";
53
70
 
54
71
  /**
55
72
  * Node's process globals, declared here so the package needs no `@types/node`
@@ -90,7 +107,7 @@ interface PiSurface {
90
107
  };
91
108
  wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
92
109
  roleProse(text: string): boolean;
93
- applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
110
+ applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }, fallback?: string): boolean;
94
111
  customEntry(customType: string, data: unknown): boolean;
95
112
  widget(lines: string[] | undefined, activity?: unknown): void;
96
113
  status(text: string): void;
@@ -146,6 +163,18 @@ const textResult = (text: string, details?: unknown) => ({
146
163
  details,
147
164
  });
148
165
 
166
+ /**
167
+ * The session this process has already taken as its resident, or `null` while
168
+ * no resident mount holds it.
169
+ *
170
+ * Module scope is the point: pi hosts a `task` subagent's sidechain in the same
171
+ * process by loading this extension again, so a guard that read the factory's
172
+ * own `boundSessionId` compared `null` against a name and let the child open a
173
+ * connection for the parent's resident alias
174
+ * (`docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md` §4.2).
175
+ */
176
+ let residentClaim: string | null = null;
177
+
149
178
  export default function onlyne(pi: ExtensionAPI) {
150
179
  const env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env;
151
180
  let context: ExtensionContext | null = null;
@@ -353,47 +382,91 @@ export default function onlyne(pi: ExtensionAPI) {
353
382
  */
354
383
  const syncSessionContext = (_event: unknown, ctx: ExtensionContext) => {
355
384
  if (!agent) return;
385
+ // The same declaration the mount gate reads, on the other door: a context
386
+ // the host declares a subagent must not re-point this plugin's connection
387
+ // onto a child, whatever session id it carries.
388
+ if (declaredSubagent(ctx)) return;
356
389
  context = ctx;
357
390
  const currentId = sessionIdOf(ctx?.sessionManager);
358
391
  if (currentId) {
392
+ // `/new`, `/resume` and a tree jump replace the live session inside this
393
+ // process rather than starting a second resident, so the claim moves with
394
+ // it — but only the mount that holds the claim may move it. A factory that
395
+ // never mounted returned above, and one whose mount is not the claim
396
+ // holder leaves the claim where it is. The comparison reads the session
397
+ // this mount held until now, so it goes before the rebind.
398
+ if (boundSessionId === residentClaim) residentClaim = currentId;
359
399
  boundSessionId = currentId;
360
400
  }
361
401
  if (surface?.available.widget) {
362
402
  surface.widget(agent.activity.lines(), agent.activity);
363
403
  }
364
404
  surface?.status(agent.activity.lines()[0]);
365
- };
405
+ // A surface that was replaced starts with no prose of its own, and the run
406
+ // after a switch still owes the model the role spec: the agent remembers
407
+ // what the last welcome carried and hands it over here.
408
+ agent.syncRoleProse();
409
+ }
366
410
 
367
411
  pi.on("session_start", async (event, ctx) => {
368
412
  const spawned = sessionIdentity(env);
369
413
  const resident = spawned ? null : residentIdentity(env, ctx.cwd);
370
414
  const identity = spawned ?? resident;
371
415
  if (!identity) return; // not an onlyne session: stay out of the way
372
- // Only the session the client names may mount. A pi process runs more than
373
- // one session — a `task` subagent's sidechain, `/new` — and each of them
374
- // loads this extension with the same inherited environment, so without this
375
- // check a subagent's `onlyne_complete` settles the parent's task (observed:
376
- // librarian task cfa73386). An unknown id means an older pi that does not
377
- // expose one; the env-bound mount is then the only candidate and stays the
378
- // behaviour, with no line to explain.
379
416
  const starting = sessionIdOf(ctx.sessionManager);
417
+ // The host's own declaration comes first, before either identity path: a
418
+ // session it declares a subagent — a `task` subagent, an `eval` child, a
419
+ // `/tan` clone, all re-bound through this factory in this process — is never
420
+ // the session a role's tools belong to, even when it inherits the client's
421
+ // environment or arrives with no session id at all
422
+ // (docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md §2 rule 2).
423
+ const subagent = declaredSubagent(ctx);
424
+ if (subagent) {
425
+ log(
426
+ `session ${starting} is subagent ${subagent.id ?? "?"} of ${subagent.parentId ?? "?"}; staying inert`
427
+ );
428
+ return;
429
+ }
380
430
  // A resident plugin already holds its connection, so another session_start
381
431
  // in this process is a re-point, not a mount: /new, /resume and /fork
382
432
  // 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.
433
+ // extensions. A subagent on a host that declares no agent identity re-binds
434
+ // with reason "startup" and a different id, and is refused below.
385
435
  if (!spawned && agent !== null && (event.reason !== "startup" || starting === boundSessionId)) {
386
436
  syncSessionContext(event, ctx);
387
437
  return;
388
438
  }
439
+ // Only the session the client names may mount. A pi process runs more than
440
+ // one session — a `task` subagent's sidechain, `/new` — and each of them
441
+ // loads this extension with the same inherited environment, so without this
442
+ // check a subagent's `onlyne_complete` settles the parent's task (observed:
443
+ // librarian task cfa73386). An unknown id means an older pi that does not
444
+ // expose one; the env-bound mount is then the only candidate and stays the
445
+ // behaviour, with no line to explain.
389
446
  if (spawned) {
390
447
  if (!mountsSession(spawned.sessionId, starting)) {
391
448
  log(`session ${starting} is not the onlyne session ${spawned.sessionId}; staying inert`);
392
449
  return;
393
450
  }
394
- } else if (boundSessionId !== null && starting !== null && starting !== boundSessionId) {
395
- log(`session ${starting} is not the resident onlyne session ${boundSessionId}; staying inert`);
396
- return;
451
+ } else {
452
+ // The claim is process-wide, so it holds across factory calls: a `task`
453
+ // subagent's sidechain loads this extension again with fresh closure state
454
+ // and would otherwise mount the alias this process already serves. A
455
+ // `session_start` the host calls a replacement is the other reading: the
456
+ // live session is moving inside this process, so the claim moves with it
457
+ // instead of refusing the session that is now the resident. An older pi
458
+ // that names no session id is the one case with nothing to compare — it
459
+ // mounts as it did before, and claims nothing.
460
+ if (
461
+ residentClaim !== null &&
462
+ starting !== null &&
463
+ starting !== residentClaim &&
464
+ !isSessionReplacement(event)
465
+ ) {
466
+ log(`session ${starting} is not the resident session ${residentClaim}; staying inert`);
467
+ return;
468
+ }
469
+ if (starting !== null) residentClaim = starting;
397
470
  }
398
471
  const wireSessionId = identity.sessionId ?? starting;
399
472
  context = ctx;
@@ -410,19 +483,24 @@ export default function onlyne(pi: ExtensionAPI) {
410
483
  // stays a plain pi session instead of retrying a path nothing serves.
411
484
  let socketPath: string | null = null;
412
485
  const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
486
+ const declaredCluster = resident ? resident.cluster : null;
413
487
  try {
414
488
  socketPath = resident
415
- ? resolveResidentSocketPath(env, targetWorkspace, identity.role)
489
+ ? resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
416
490
  : resolveSocketPath(env, ctx.cwd);
417
491
  } catch (error) {
418
492
  log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
419
493
  }
420
494
  if (socketPath === null) return;
495
+ // The prose a mount of this process already holds: the agent is replaced
496
+ // below, and the surface made now starts empty, so what the last welcome
497
+ // carried moves with it rather than being lost until the next handshake.
498
+ const carriedProse = agent?.latestProse ?? "";
421
499
  surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
422
500
  agent = new OnlyneAgent({
423
501
  socketPath,
424
502
  resolveSocket: resident
425
- ? () => resolveResidentSocketPath(env, targetWorkspace, identity.role)
503
+ ? () => resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
426
504
  : undefined,
427
505
  cwd: ctx.cwd,
428
506
  role: identity.role,
@@ -431,6 +509,7 @@ export default function onlyne(pi: ExtensionAPI) {
431
509
  resident: Boolean(resident),
432
510
  surface,
433
511
  log,
512
+ prose: carriedProse,
434
513
  });
435
514
  log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
436
515
  registerSurface();
@@ -461,7 +540,10 @@ export default function onlyne(pi: ExtensionAPI) {
461
540
  // to read as an utterance. Outside an onlyne session there is no surface and
462
541
  // nothing to add.
463
542
  pi.on("before_agent_start", async (event) => {
464
- surface?.applyRoleProse(event);
543
+ // A surface created after the handshake holds no prose; the agent's copy of
544
+ // the last welcome is what the run that is starting carries instead of
545
+ // running without the role spec until the next connection.
546
+ surface?.applyRoleProse(event, agent?.latestProse ?? "");
465
547
  });
466
548
 
467
549
  pi.on("turn_start", async () => {
@@ -491,6 +573,10 @@ export default function onlyne(pi: ExtensionAPI) {
491
573
  agent?.stop(`pi:${event.reason ?? "quit"}`);
492
574
  surface?.closeBackground?.();
493
575
  surface?.widget?.(undefined);
576
+ // Only the mount holding the claim may release it: a child factory that
577
+ // never mounted has no `boundSessionId` of its own, and its shutdown must
578
+ // leave the resident's claim where it is.
579
+ if (boundSessionId !== null && boundSessionId === residentClaim) residentClaim = null;
494
580
  agent = null;
495
581
  surface = null;
496
582
  registered = false;
@@ -241,7 +241,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
241
241
  let warnedNoSections = false;
242
242
 
243
243
  /**
244
- * Hand the role prose over once.
244
+ * Take the role prose this surface is to carry: handed over with the
245
+ * handshake, and again whenever a surface that holds none has to be filled
246
+ * for the run that is starting.
245
247
  * @param {string} text
246
248
  * @returns {boolean} whether there is prose to carry
247
249
  */
@@ -259,10 +261,17 @@ export function createSurface({ pi, log, context, sessionId = null }) {
259
261
  * pi's own examples does this). pi rebuilds those options for every run, so the
260
262
  * section is written again rather than once — the write is idempotent, and the
261
263
  * first run records it in the transcript's system message.
264
+ *
265
+ * `fallback` is the prose this surface was never handed: a surface made after
266
+ * the handshake (a session switch re-binds the plugin onto one) starts empty,
267
+ * and the run that is starting still owes the model the role spec, so the
268
+ * caller passes what the agent remembers of the last welcome.
262
269
  * @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
270
+ * @param {string} [fallback]
263
271
  * @returns {boolean} whether a section was written
264
272
  */
265
- const applyRoleProse = (event) => {
273
+ const applyRoleProse = (event, fallback = "") => {
274
+ if (roleProseText.length === 0 && typeof fallback === "string") roleProseText = fallback;
266
275
  if (roleProseText.length === 0) return false;
267
276
  const sections = event?.systemPromptOptions?.sections;
268
277
  if (!sections) {
@@ -260,6 +260,30 @@ test("a pi whose prompt options carry no sections reports it once and carries no
260
260
  assert.match(lines[0], /no instruction layer/);
261
261
  });
262
262
 
263
+ test("a surface made after the handshake adopts the prose the agent remembers", () => {
264
+ const { surface } = fakeSurface();
265
+
266
+ // No `roleProse` call ever reached this surface — it was created after the
267
+ // welcome, by a session switch or a resumed mount — and the run that is
268
+ // starting is still owed the role spec.
269
+ const first = promptEvent();
270
+ assert.equal(surface.applyRoleProse(first, PROSE_TEXT), true);
271
+ assert.deepEqual(first.systemPromptOptions.sections, { [PROSE_SECTION]: PROSE_TEXT });
272
+
273
+ // The adopted text is the surface's own from then on: the next run carries it
274
+ // without the caller passing it again.
275
+ const second = promptEvent();
276
+ assert.equal(surface.applyRoleProse(second), true);
277
+ assert.deepEqual(second.systemPromptOptions.sections, { [PROSE_SECTION]: PROSE_TEXT });
278
+
279
+ // A fallback fills an empty surface only; prose the surface already holds is
280
+ // newer than what the agent last saw.
281
+ surface.roleProse("Answer in the log style.");
282
+ const third = promptEvent();
283
+ surface.applyRoleProse(third, PROSE_TEXT);
284
+ assert.deepEqual(third.systemPromptOptions.sections, { [PROSE_SECTION]: "Answer in the log style." });
285
+ });
286
+
263
287
  /**
264
288
  * A surface over a fake pi that answers the two questions the phase is read
265
289
  * from, plus the cases where pi answers neither.