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.
@@ -0,0 +1,461 @@
1
+ // One process serves one resident, and a subagent is never it.
2
+ //
3
+ // Two readings decide the resident branch of `session_start`, and these tests
4
+ // drive both through the real factory: the host's own declaration that the
5
+ // session is a subagent (`ctx.agent.kind`, omp), and — on a host that declares
6
+ // nothing (`@earendil-works/pi-coding-agent` 1.3.0) — the process-wide claim
7
+ // (`docs/v2.11-LIFECYCLE-IS-DECLARED-NOT-INFERRED.md` §4.2).
8
+ //
9
+ // The live defect the claim pins (`caerulea-main` mislabelled `exited`,
10
+ // 2026-10-19): a resident pi is started by hand, not spawned by the client, so
11
+ // a `task` subagent's sidechain inside that same process inherits an
12
+ // environment with no task id in it, resolves the same cwd config and reaches
13
+ // the resident branch through a second call of this factory with fresh closure
14
+ // state. `boundSessionId` was `null` there, so a guard written as
15
+ // `boundSessionId !== null && ...` let the child open a connection that
16
+ // impersonated the parent's resident alias; the child's own `detach` then
17
+ // retired the session the parent was still serving.
18
+ //
19
+ // The socket is real and the fake client behind it is what proves a mount: a
20
+ // session that mounts registers tools and opens one connection, a session that
21
+ // is refused does neither. The claim is only observable through behaviour, so
22
+ // every step that reads it starts the next session in the same process and
23
+ // asserts what happened to that one.
24
+ import assert from "node:assert/strict";
25
+ import { mkdtempSync, rmSync } from "node:fs";
26
+ import { createServer } from "node:net";
27
+ import { registerHooks } from "node:module";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { afterEach, test } from "node:test";
31
+
32
+ import { createFrameDecoder, encodeFrame } from "./frame.mjs";
33
+
34
+ /**
35
+ * The two modules `index.ts` imports for its own sake, as a running extension
36
+ * sees them: `defineTool` is the identity (the typebox schema is what pi reads,
37
+ * and no tool is invoked here) and `Type` only has to build something. The pi
38
+ * runtime itself is the fake below; a test must not need the installed SDK.
39
+ */
40
+ const PI_MODULES = {
41
+ "@earendil-works/pi-coding-agent": "export const defineTool = (tool) => tool;",
42
+ typebox: [
43
+ "const shape = () => ({});",
44
+ "export const Type = { Object: shape, String: shape, Optional: shape, Array: shape };",
45
+ ].join("\n"),
46
+ };
47
+
48
+ registerHooks({
49
+ resolve(specifier, context, nextResolve) {
50
+ const source = PI_MODULES[specifier];
51
+ if (source) return { url: `data:text/javascript,${encodeURIComponent(source)}`, shortCircuit: true };
52
+ return nextResolve(specifier, context);
53
+ },
54
+ });
55
+
56
+ const { default: onlyne } = await import("./index.ts");
57
+
58
+ /** The role this process serves, as the resident config names it. */
59
+ const ROLE = "resident-role";
60
+ const SESSION_A = "b1f0c0de-6a1e-4b0a-9d3f-2c7a5e4190ab";
61
+ const SESSION_B = "c2a1d1ef-7b2f-4c1b-8e40-3d8b6f52a1bc";
62
+ const SESSION_C = "d3b2e2f0-8c30-4d2c-9f51-4e9c7a63b2cd";
63
+ const SESSION_D = "e4c3f301-9d41-4e3d-a062-5fad8b74c3de";
64
+ const TASK_ID = "1f2e3d4c-5b6a-4978-8899-aabbccddeeff";
65
+
66
+ /** Every tool the module registers, in registration order. */
67
+ const TOOLS = ["onlyne_send", "onlyne_complete", "onlyne_handoff"];
68
+
69
+ /** The agent identity a host declares for a `task` child, as omp reports one. */
70
+ const CHILD_AGENT = Object.freeze({ kind: "sub", id: "0-Explore", name: "explore", depth: 1, parentId: "Main" });
71
+
72
+ /**
73
+ * The `ONLYNE_*` names that decide which branch the plugin takes. A test may
74
+ * itself run under an onlyne client, whose injected session id and task id would
75
+ * make every mount here a spawned one, so all of them are replaced.
76
+ */
77
+ const IDENTITY_ENV = ["ONLYNE_ROLE", "ONLYNE_SESSION_ID", "ONLYNE_TASK_ID", "ONLYNE_WORKSPACE", "ONLYNE_RESIDENT"];
78
+
79
+ /** Work the afterEach hook undoes, whichever way a test ends. */
80
+ const cleanups = [];
81
+ afterEach(async () => {
82
+ while (cleanups.length > 0) await cleanups.pop()();
83
+ });
84
+
85
+ /** Poll until `predicate` holds, so a test never races the event loop. */
86
+ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
87
+ const deadline = Date.now() + timeoutMs;
88
+ for (;;) {
89
+ const value = predicate();
90
+ if (value) return value;
91
+ if (Date.now() > deadline) throw new Error("timed out waiting for the expected state");
92
+ await new Promise((resolve) => setTimeout(resolve, stepMs));
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Give a connection the chance to appear that a mount would have given it: the
98
+ * connect and the host's accept are each one turn of the loop away, the window
99
+ * the agent tests read the same absence in.
100
+ */
101
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 120));
102
+
103
+ /** One pi extension host: the handlers the factory registers, and its surface. */
104
+ function fakePi() {
105
+ const handlers = new Map();
106
+ const tools = [];
107
+ const commands = [];
108
+ return {
109
+ handlers,
110
+ tools,
111
+ commands,
112
+ on(event, handler) {
113
+ handlers.set(event, handler);
114
+ },
115
+ registerTool(tool) {
116
+ tools.push(tool);
117
+ },
118
+ registerCommand(name, command) {
119
+ commands.push({ name, command });
120
+ },
121
+ sendUserMessage() {},
122
+ sendMessage() {},
123
+ appendEntry() {},
124
+ };
125
+ }
126
+
127
+ /**
128
+ * The context pi hands a handler: the workspace, this session's id, its pane,
129
+ * and — where the host declares one — the agent the session runs.
130
+ */
131
+ function sessionContext(cwd, sessionId, agent = null) {
132
+ return {
133
+ cwd,
134
+ // pi names the session it is starting; `null` is an older pi that cannot.
135
+ sessionManager: sessionId === null ? {} : { getSessionId: () => sessionId },
136
+ isIdle: () => true,
137
+ hasPendingMessages: () => false,
138
+ ui: { setWidget() {}, setStatus() {}, notify() {} },
139
+ ...(agent ? { agent } : {}),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * The client side of the socket: it counts connections and answers every request
145
+ * with the welcome a real host sends, so a mounted session reaches `connected`
146
+ * and holds its single connection open for the rest of the test.
147
+ */
148
+ class FakeHost {
149
+ constructor() {
150
+ this.server = createServer((socket) => this.onConnection(socket));
151
+ this.connections = 0;
152
+ this.sockets = new Set();
153
+ this.frames = [];
154
+ }
155
+
156
+ listen(socketPath) {
157
+ return new Promise((resolve) => this.server.listen(socketPath, resolve));
158
+ }
159
+
160
+ onConnection(socket) {
161
+ this.connections += 1;
162
+ this.sockets.add(socket);
163
+ socket.on("error", () => {});
164
+ socket.on("close", () => this.sockets.delete(socket));
165
+ const decoder = createFrameDecoder({
166
+ onFrame: (frame) => this.onFrame(socket, frame),
167
+ onError: () => socket.destroy(),
168
+ });
169
+ socket.on("data", (chunk) => decoder.push(chunk));
170
+ }
171
+
172
+ onFrame(socket, frame) {
173
+ this.frames.push(frame);
174
+ if (frame.id === undefined) return;
175
+ const data =
176
+ frame.op === "hello"
177
+ ? {
178
+ op: "welcome",
179
+ args: {
180
+ protocol: 1,
181
+ role: ROLE,
182
+ session_id: frame.args?.mount?.session ?? null,
183
+ generation: 1,
184
+ },
185
+ }
186
+ : null;
187
+ socket.write(encodeFrame({ reply_to: frame.id, ok: true, data }));
188
+ }
189
+
190
+ /** How many sessions this process opened a connection for. */
191
+ get hellos() {
192
+ return this.frames.filter((frame) => frame.op === "hello").length;
193
+ }
194
+
195
+ close() {
196
+ for (const socket of this.sockets) socket.destroy();
197
+ return new Promise((resolve) => this.server.close(resolve));
198
+ }
199
+ }
200
+
201
+ /**
202
+ * A workspace, the socket its resident dials, and the fake client behind it.
203
+ * All three go away with the test.
204
+ */
205
+ function residentWorkspace() {
206
+ const host = new FakeHost();
207
+ const cwd = mkdtempSync(join(tmpdir(), "pi-onlyne-resident-"));
208
+ const socketPath = join(cwd, "s");
209
+ cleanups.push(async () => {
210
+ await host.close();
211
+ rmSync(cwd, { recursive: true, force: true });
212
+ });
213
+ return { host, cwd, socketPath };
214
+ }
215
+
216
+ /** Put one process environment in place, and the harness's own back afterwards. */
217
+ function withEnv(values) {
218
+ const saved = new Map(IDENTITY_ENV.map((key) => [key, process.env[key]]));
219
+ for (const key of IDENTITY_ENV) delete process.env[key];
220
+ for (const [key, value] of Object.entries(values)) process.env[key] = value;
221
+ cleanups.push(() => {
222
+ for (const [key, value] of saved) {
223
+ if (value === undefined) delete process.env[key];
224
+ else process.env[key] = value;
225
+ }
226
+ });
227
+ }
228
+
229
+ /**
230
+ * A resident process: a role and the injected socket path, with no task id —
231
+ * which is exactly what a `task` child of that process inherits.
232
+ */
233
+ function residentEnv(socketPath) {
234
+ withEnv({ ONLYNE_ROLE: ROLE, ONLYNE_SOCKET: socketPath });
235
+ }
236
+
237
+ /** A client-spawned process: the environment the client injects. */
238
+ function spawnedEnv(socketPath) {
239
+ withEnv({
240
+ ONLYNE_ROLE: ROLE,
241
+ ONLYNE_SESSION_ID: SESSION_A,
242
+ ONLYNE_TASK_ID: TASK_ID,
243
+ ONLYNE_SOCKET: socketPath,
244
+ });
245
+ }
246
+
247
+ /**
248
+ * The plugin's own log goes to `process.stderr`; a test that asserts what a
249
+ * refusal said reads it here, and the stream is put back afterwards.
250
+ */
251
+ function captureStderr() {
252
+ const lines = [];
253
+ const write = process.stderr.write;
254
+ process.stderr.write = (chunk) => {
255
+ lines.push(String(chunk));
256
+ return true;
257
+ };
258
+ cleanups.push(() => {
259
+ process.stderr.write = write;
260
+ });
261
+ return (needle) => lines.find((line) => line.includes(needle));
262
+ }
263
+
264
+ /** Shut one session down, the way pi does when its session ends. */
265
+ async function shutdownSession(pi, reason = "quit") {
266
+ await pi.handlers.get("session_shutdown")({ reason });
267
+ }
268
+
269
+ /**
270
+ * One extension bind for one session: a fresh factory call — exactly what pi
271
+ * does for every session it runs in this process — then one `session_start`.
272
+ */
273
+ async function startSession(cwd, { sessionId, reason = "startup", agent = null } = {}) {
274
+ const pi = fakePi();
275
+ onlyne(pi);
276
+ cleanups.push(() => shutdownSession(pi));
277
+ await pi.handlers.get("session_start")({ reason }, sessionContext(cwd, sessionId, agent));
278
+ return pi;
279
+ }
280
+
281
+ /** The names of the tools a factory call registered, in order. */
282
+ const toolNames = (pi) => pi.tools.map((tool) => tool.name);
283
+
284
+ test("a session the host declares a subagent never mounts the client's session", async () => {
285
+ const { host, cwd, socketPath } = residentWorkspace();
286
+ await host.listen(socketPath);
287
+ spawnedEnv(socketPath);
288
+ const stderrLine = captureStderr();
289
+
290
+ // A `task` child inherits the parent's whole environment, this session id
291
+ // included, so the id cannot tell it from the session the client spawned. The
292
+ // host's own declaration can, and it decides before either identity path.
293
+ const child = await startSession(cwd, { sessionId: SESSION_A, agent: CHILD_AGENT });
294
+ await settle();
295
+ assert.deepEqual(child.tools, [], "the child registers no tool");
296
+ assert.deepEqual(child.commands, [], "and no command");
297
+ assert.equal(host.connections, 0, "and opens no connection");
298
+ const refusal = stderrLine("is subagent");
299
+ assert.ok(refusal, "the refusal is logged");
300
+ assert.ok(refusal.includes(SESSION_A), `the refusal names the session: ${refusal}`);
301
+ assert.ok(refusal.includes("0-Explore"), `and the subagent: ${refusal}`);
302
+ assert.ok(refusal.includes("Main"), `and its parent: ${refusal}`);
303
+
304
+ // The same session id, this time declared a top-level session, is the one the
305
+ // client named — so what refused the child was the declaration, not the id.
306
+ const named = await startSession(cwd, { sessionId: SESSION_A });
307
+ await waitFor(() => host.connections === 1);
308
+ assert.deepEqual(toolNames(named), TOOLS, "the named session mounts");
309
+ await shutdownSession(named);
310
+ });
311
+
312
+ test("a host-declared subagent cannot dock onto the resident alias", async () => {
313
+ const { host, cwd, socketPath } = residentWorkspace();
314
+ await host.listen(socketPath);
315
+ residentEnv(socketPath);
316
+ const stderrLine = captureStderr();
317
+
318
+ const resident = await startSession(cwd, { sessionId: SESSION_A });
319
+ await waitFor(() => host.connections === 1);
320
+
321
+ // A child of an older host that reports no session id at all: the resident
322
+ // branch has nothing left to compare, so only the declaration refuses it.
323
+ const child = await startSession(cwd, { sessionId: null, agent: { ...CHILD_AGENT, id: "0-Writer" } });
324
+ await settle();
325
+ assert.deepEqual(child.tools, [], "the child registers no tool");
326
+ assert.equal(host.connections, 1, "and no connection of its own");
327
+ const refusal = stderrLine("is subagent");
328
+ assert.ok(refusal?.includes("0-Writer"), `the refusal names the child: ${refusal}`);
329
+
330
+ // Its shutdown is not the resident's: the claim the resident holds survives
331
+ // it, so the next session in this process is still refused.
332
+ await shutdownSession(child);
333
+ const afterChild = await startSession(cwd, { sessionId: SESSION_C });
334
+ await settle();
335
+ assert.deepEqual(afterChild.tools, [], "the child's shutdown released nothing");
336
+ assert.equal(host.connections, 1);
337
+ await shutdownSession(resident);
338
+ });
339
+
340
+ test("a second factory in the same process cannot mount the resident session", async () => {
341
+ const { host, cwd, socketPath } = residentWorkspace();
342
+ await host.listen(socketPath);
343
+ residentEnv(socketPath);
344
+ const stderrLine = captureStderr();
345
+
346
+ const resident = await startSession(cwd, { sessionId: SESSION_A });
347
+ await waitFor(() => host.connections === 1);
348
+ assert.deepEqual(toolNames(resident), TOOLS, "the resident mounts, with its tools");
349
+
350
+ // A host that declares no agent identity (pi) leaves the claim to refuse the
351
+ // child: a second call of the factory with fresh closure state and another
352
+ // session id, which is what a `task` subagent runs there.
353
+ const sidechain = await startSession(cwd, { sessionId: SESSION_B });
354
+ await settle();
355
+ assert.deepEqual(sidechain.tools, [], "the child registers no tool");
356
+ assert.deepEqual(sidechain.commands, [], "and no command");
357
+ assert.equal(host.connections, 1, "and opens no connection");
358
+ assert.equal(host.hellos, 1, "the one hello is the resident's");
359
+ const refusal = stderrLine(SESSION_B);
360
+ assert.ok(refusal, "the refusal is logged");
361
+ assert.ok(refusal.includes(SESSION_A), `the refusal names the resident too: ${refusal}`);
362
+ assert.ok(refusal.includes("staying inert"), refusal);
363
+
364
+ // The child's shutdown is not the resident's: the claim it never held is still
365
+ // held, so the next session in this process is still refused.
366
+ await shutdownSession(sidechain);
367
+ const afterChild = await startSession(cwd, { sessionId: SESSION_C });
368
+ await settle();
369
+ assert.deepEqual(afterChild.tools, [], "the child's shutdown released nothing");
370
+ assert.equal(host.connections, 1);
371
+
372
+ // The resident's own shutdown does release it: the next session mounts.
373
+ await shutdownSession(resident);
374
+ const next = await startSession(cwd, { sessionId: SESSION_D });
375
+ await waitFor(() => host.connections === 2);
376
+ assert.deepEqual(toolNames(next), TOOLS, "the claim is free again after the resident left");
377
+ await shutdownSession(next);
378
+ });
379
+
380
+ test("a session replacement inside the resident process moves the claim", async () => {
381
+ const { host, cwd, socketPath } = residentWorkspace();
382
+ await host.listen(socketPath);
383
+ residentEnv(socketPath);
384
+ const stderrLine = captureStderr();
385
+
386
+ const resident = await startSession(cwd, { sessionId: SESSION_A });
387
+ await waitFor(() => host.connections === 1);
388
+
389
+ // `/new`: pi replaces the live session inside this process and reports it as
390
+ // `session_start` with another reason; the resident follows it and keeps the
391
+ // one connection it holds.
392
+ await resident.handlers.get("session_start")({ reason: "new" }, sessionContext(cwd, SESSION_B));
393
+ await settle();
394
+ assert.equal(host.connections, 1, "a replacement opens no second connection");
395
+
396
+ // The claim travelled with the live session: the session the process left is
397
+ // now the foreign one, and a factory that starts it stays inert.
398
+ const replaced = await startSession(cwd, { sessionId: SESSION_A });
399
+ await settle();
400
+ assert.deepEqual(replaced.tools, [], "the replaced session may not claim again");
401
+ assert.equal(host.connections, 1);
402
+ const refusal = stderrLine("is not the resident session");
403
+ assert.ok(refusal, "the refusal is logged");
404
+ assert.ok(refusal.includes(SESSION_A), `the refusal names the replaced session: ${refusal}`);
405
+ assert.ok(refusal.includes(SESSION_B), `and the live one: ${refusal}`);
406
+
407
+ // And it is released by the mount holding it, so the process can be resident
408
+ // for the session that comes next.
409
+ await shutdownSession(resident);
410
+ const next = await startSession(cwd, { sessionId: SESSION_C });
411
+ await waitFor(() => host.connections === 2);
412
+ await shutdownSession(next);
413
+ });
414
+
415
+ test("a host-declared replacement mounts even through a fresh factory", async () => {
416
+ const { host, cwd, socketPath } = residentWorkspace();
417
+ await host.listen(socketPath);
418
+ residentEnv(socketPath);
419
+ const stderrLine = captureStderr();
420
+
421
+ const resident = await startSession(cwd, { sessionId: SESSION_A });
422
+ await waitFor(() => host.connections === 1);
423
+
424
+ // A host that re-binds the extension for `/resume` rather than following the
425
+ // switch in place: the new session is this process's resident now, so the
426
+ // claim moves instead of the session being refused.
427
+ const resumed = await startSession(cwd, { sessionId: SESSION_B, reason: "resume" });
428
+ await waitFor(() => host.connections === 2);
429
+ assert.deepEqual(toolNames(resumed), TOOLS, "the replacing session mounts");
430
+ assert.equal(stderrLine("is not the resident session"), undefined, "and is not refused");
431
+
432
+ // The claim is the replacing session's: the one it replaced may not claim it
433
+ // back, and the replacing session's own shutdown releases it.
434
+ const replaced = await startSession(cwd, { sessionId: SESSION_A });
435
+ await settle();
436
+ assert.deepEqual(replaced.tools, [], "the replaced session may not claim again");
437
+ await shutdownSession(resumed);
438
+ const next = await startSession(cwd, { sessionId: SESSION_C });
439
+ await waitFor(() => host.connections === 3);
440
+ await shutdownSession(next);
441
+ });
442
+
443
+ test("an older pi that names no session id mounts, and claims nothing", async () => {
444
+ const { host, cwd, socketPath } = residentWorkspace();
445
+ await host.listen(socketPath);
446
+ residentEnv(socketPath);
447
+ captureStderr();
448
+
449
+ // Nothing to compare, so the mount keeps the behaviour it had: the env-bound
450
+ // session is the only candidate. It also leaves the claim empty, which is this
451
+ // fix's one concession — a session that cannot be named cannot be held to.
452
+ const unnamed = await startSession(cwd, { sessionId: null });
453
+ await waitFor(() => host.connections === 1);
454
+ assert.deepEqual(toolNames(unnamed), TOOLS, "an unnamed session still mounts");
455
+
456
+ const named = await startSession(cwd, { sessionId: SESSION_A });
457
+ await waitFor(() => host.connections === 2);
458
+ assert.deepEqual(toolNames(named), TOOLS, "and a session that is named is not blocked by one that was not");
459
+ await shutdownSession(named);
460
+ await shutdownSession(unnamed);
461
+ });
@@ -21,6 +21,16 @@
21
21
  // report a session id at all is the one case where both answer yes — refusing
22
22
  // every call on an older pi would break the ordinary session to prevent nothing,
23
23
  // because the env-bound mount is still the only one that connected.
24
+ //
25
+ // A session id is not the only thing that can decide the door, and where the
26
+ // host declares more, the declaration wins: `declaredSubagent` reads the agent a
27
+ // session runs (omp re-binds every extension factory into each subagent session
28
+ // — the `task` tool, an `eval` child, a `/tan` clone — so a child arrives at a
29
+ // handler carrying the parent's environment and a session id of its own), and
30
+ // `isSessionReplacement` reads the host's own reason for a `session_start` that
31
+ // is really `/new`, `/resume` or `/fork` replacing the live session. A child is
32
+ // never the session a role's tools belong to, whatever id it carries; a
33
+ // replacement is that session moving, not a second one arriving.
24
34
 
25
35
  /**
26
36
  * The id of the session a pi extension context belongs to.
@@ -55,6 +65,49 @@ export function mountsSession(identitySessionId, startingSessionId) {
55
65
  return startingSessionId === null || startingSessionId === identitySessionId;
56
66
  }
57
67
 
68
+ /**
69
+ * The host's own declaration that the session starting is a spawned subagent,
70
+ * as `{ id, parentId }` for the line a refusal is logged with, or `null`.
71
+ *
72
+ * omp puts the agent a session runs on every extension context
73
+ * (`{ kind: "main" | "sub", id, name, depth, parentId? }`, `ExtensionAgentIdentity`)
74
+ * and says at that declaration that factories are re-bound "to every subagent
75
+ * session (task tool, eval `agent()`, `/tan` clones)". `kind` is the field to
76
+ * read and `depth` is not: a `/tan` clone is a subagent at depth 0. The read is
77
+ * structural because the installed typings of the host this plugin is published
78
+ * against (`@earendil-works/pi-coding-agent`, 1.3.0) do not declare `agent` at
79
+ * all — that host answers `null` here, and its sessions are told apart by the
80
+ * session id and the resident claim instead.
81
+ *
82
+ * @param {{ agent?: { kind?: unknown, id?: unknown, parentId?: unknown } } | null | undefined} ctx
83
+ * @returns {{ id: string | null, parentId: string | null } | null}
84
+ */
85
+ export function declaredSubagent(ctx) {
86
+ const agent = ctx?.agent;
87
+ if (!agent || typeof agent !== "object" || agent.kind !== "sub") return null;
88
+ const text = (value) => (typeof value === "string" && value.length > 0 ? value : null);
89
+ return { id: text(agent.id), parentId: text(agent.parentId) };
90
+ }
91
+
92
+ /**
93
+ * Whether this `session_start` is the host declaring a session replacement —
94
+ * `/new`, `/resume`, `/fork` — rather than a session arriving beside the live
95
+ * one.
96
+ *
97
+ * The reason strings are the host's: `@earendil-works/pi-coding-agent` reports
98
+ * these transitions as `session_start.reason`, and omp reports them as
99
+ * `session_switch.reason`. `"startup"` and an absent reason are the other
100
+ * reading, which is what a factory re-bound for a subagent or a second session
101
+ * in the same process looks like.
102
+ *
103
+ * @param {{ reason?: unknown } | null | undefined} event
104
+ * @returns {boolean}
105
+ */
106
+ export function isSessionReplacement(event) {
107
+ const reason = event?.reason;
108
+ return reason === "new" || reason === "resume" || reason === "fork";
109
+ }
110
+
58
111
  /**
59
112
  * Whether a call comes from the session that mounted this plugin.
60
113
  *
@@ -6,7 +6,9 @@
6
6
  import assert from "node:assert/strict";
7
7
  import test from "node:test";
8
8
  import {
9
+ declaredSubagent,
9
10
  foreignSessionRefusal,
11
+ isSessionReplacement,
10
12
  mountsSession,
11
13
  ownsSession,
12
14
  sessionIdOf,
@@ -59,3 +61,35 @@ test("the refusal names both sessions and why the call is refused", () => {
59
61
  assert.ok(sentence.includes(PARENT), sentence);
60
62
  assert.ok(sentence.includes("subagent"), sentence);
61
63
  });
64
+
65
+ test("a host that declares the session a subagent is read before any session id", () => {
66
+ assert.deepEqual(
67
+ declaredSubagent({ agent: { kind: "sub", id: "0-Explore", name: "explore", depth: 1, parentId: "Main" } }),
68
+ { id: "0-Explore", parentId: "Main" },
69
+ );
70
+ assert.deepEqual(
71
+ declaredSubagent({ agent: { kind: "sub", id: "tan-1", name: "sub", depth: 0 } }),
72
+ { id: "tan-1", parentId: null },
73
+ "a /tan clone is a subagent at depth 0, which is why depth is not the field read",
74
+ );
75
+ assert.deepEqual(
76
+ declaredSubagent({ agent: { kind: "sub" } }),
77
+ { id: null, parentId: null },
78
+ "a declaration with no ids still refuses; the caller names what it can",
79
+ );
80
+ assert.equal(declaredSubagent({ agent: { kind: "main", id: "Main", name: "main", depth: 0 } }), null);
81
+ assert.equal(declaredSubagent({ agent: "sub" }), null, "a non-object declaration declares nothing");
82
+ assert.equal(declaredSubagent({ agent: null }), null);
83
+ assert.equal(declaredSubagent({}), null, "a host that declares no agent identity is answered with none");
84
+ assert.equal(declaredSubagent(undefined), null);
85
+ });
86
+
87
+ test("only the transitions a host calls a replacement read as one", () => {
88
+ assert.equal(isSessionReplacement({ reason: "new" }), true);
89
+ assert.equal(isSessionReplacement({ reason: "resume" }), true);
90
+ assert.equal(isSessionReplacement({ reason: "fork" }), true);
91
+ assert.equal(isSessionReplacement({ reason: "startup" }), false, "a second session beside the live one");
92
+ assert.equal(isSessionReplacement({ reason: "reload" }), false, "the same session, reopened");
93
+ assert.equal(isSessionReplacement({}), false, "a host that names no reason names no replacement");
94
+ assert.equal(isSessionReplacement(undefined), false);
95
+ });