pi-onlyne 2.1.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
@@ -7,28 +7,49 @@
7
7
  // `crates/onlyne-client/src/dispatch.rs`); with any of the three missing this is
8
8
  // a plain pi session and the extension stays silent rather than failing.
9
9
  //
10
- // session_start -> read env + .pi/onlyne.json, connect, register tools
10
+ // session_start -> bind the env session id, read env + .pi/onlyne.json,
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
11
17
  // before_agent_start -> the role prose becomes one section of the system
12
18
  // prompt the run is about to send (the instruction layer)
13
19
  // turn_start -> heartbeat{running}
14
20
  // turn_end -> one turn of a run ended; the phase is re-derived from pi
15
- // and the fallback window for a witnessed failure opens
16
- // message_end -> keep the last assistant text; a failed turn is `failed`
17
- // agent_settled -> heartbeat{idle} when the session waits for input, and the
18
- // report a failed turn owes is sent from here
21
+ // message_end -> keep the last assistant text as the head fallback
22
+ // agent_settled -> heartbeat{idle} when the session waits for input
19
23
  // session_shutdown -> detach{reason}
20
24
  //
25
+ // The plugin keeps exactly one completion path: the model's explicit
26
+ // `onlyne_complete` (and `recycle{outcome}`). A turn that ends without one
27
+ // decides nothing here — the client's own turn-end rule owns it
28
+ // (`docs/v2-CONTRACT.md` §3c: one nudge, then the delivery settles `blocked`)
29
+ // and its sweeps own a session whose agent died. A turn error only supplies the
30
+ // head fallback, because pi retries a retryable provider error and stays inside
31
+ // the same run, and the client's nudge can restart a session a moment after it
32
+ // settles — a report sent on a turn error would race work that is still going.
33
+ //
21
34
  // Host frames are dispatched in `agent.mjs`, not here: `assign` and `nudge` are
22
35
  // injected as user messages, `probe` is answered with a heartbeat, and `recycle`
23
36
  // settles the task and stops the plugin.
37
+ //
38
+ // Only the session whose id equals `ONLYNE_SESSION_ID` mounts: a pi process runs
39
+ // a subagent sidechain and `/new` sessions too, all inheriting this extension's
40
+ // environment, and a sidechain that called `onlyne_complete` would settle the
41
+ // parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
42
+ // what a call from a foreign session is told.
24
43
 
25
44
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
26
45
  import { Type } from "typebox";
27
46
 
28
47
  import { OnlyneAgent } from "./agent.mjs";
29
- import { loadConfig, sessionIdentity } from "./config.mjs";
30
- 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";
31
51
  import { createSurface } from "./pi-surface.mjs";
52
+ import { foreignSessionRefusal, mountsSession, ownsSession, sessionIdOf } from "./session-scope.mjs";
32
53
 
33
54
  /**
34
55
  * Node's process globals, declared here so the package needs no `@types/node`
@@ -67,11 +88,11 @@ interface PiSurface {
67
88
  registerTool: boolean;
68
89
  registerCommand: boolean;
69
90
  };
70
- wakeUser(text: string, parts?: ImagePartInput[]): boolean;
91
+ wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
71
92
  roleProse(text: string): boolean;
72
93
  applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
73
94
  customEntry(customType: string, data: unknown): boolean;
74
- widget(lines: string[] | undefined): void;
95
+ widget(lines: string[] | undefined, activity?: unknown): void;
75
96
  status(text: string): void;
76
97
  welcome(welcome: WelcomeLike): void;
77
98
  isIdle(): boolean;
@@ -131,16 +152,43 @@ export default function onlyne(pi: ExtensionAPI) {
131
152
  let agent: OnlyneAgent | null = null;
132
153
  let surface: PiSurface | null = null;
133
154
  let registered = false;
155
+ /**
156
+ * The session id that mounted this plugin. A pi process can host more than
157
+ * one session — a `task` subagent runs a sidechain in the same process and
158
+ * inherits `ONLYNE_*`, and `/new` starts another — and every one of them
159
+ * loads this extension. The env names the one session that is the onlyne
160
+ * session, so the tools belong to it alone; an unbound session must not be
161
+ * able to settle this task by calling `onlyne_complete` from its sidechain.
162
+ */
163
+ let boundSessionId: string | null = null;
134
164
 
135
165
  const log = (line: string, data?: unknown) => {
136
166
  const detail = data === undefined ? "" : ` ${JSON.stringify(data)}`;
137
167
  process?.stderr?.write(`[pi-onlyne] ${line}${detail}\n`);
138
168
  };
139
169
 
170
+ /**
171
+ * Guard one tool call: the caller must be the session that mounted this
172
+ * plugin (`session-scope.mjs` carries the rule and the reason).
173
+ */
174
+ const refuseForeignSession = (caller: string | null): never => {
175
+ throw new Error(foreignSessionRefusal(boundSessionId, caller));
176
+ };
177
+
140
178
  /** Tools and the `/onlyne` command exist only inside an onlyne session. */
141
179
  const registerSurface = () => {
142
180
  if (registered || !surface) return;
143
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
+ }
144
192
  try {
145
193
  pi.registerTool(defineTool({
146
194
  name: "onlyne_send",
@@ -157,7 +205,9 @@ export default function onlyne(pi: ExtensionAPI) {
157
205
  kind: Type.Optional(Type.String({ description: '"note" (default) or "task"' })),
158
206
  image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
159
207
  }),
160
- async execute(_toolCallId, params) {
208
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
209
+ const caller = sessionIdOf(ctx?.sessionManager);
210
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
161
211
  if (!agent) throw new Error("onlyne: session is not connected");
162
212
  const result = await agent.sendFromTool({
163
213
  to: params.to,
@@ -189,7 +239,9 @@ export default function onlyne(pi: ExtensionAPI) {
189
239
  details: Type.Optional(Type.String({ description: "the full result, delivered as it stands" })),
190
240
  files: Type.Optional(Type.Array(Type.String(), { description: "absolute paths of the files the result names" })),
191
241
  }),
192
- async execute(_toolCallId, params) {
242
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
243
+ const caller = sessionIdOf(ctx?.sessionManager);
244
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
193
245
  if (!agent) throw new Error("onlyne: session is not connected");
194
246
  // The exit is not a tool-result flag: pi 0.85.1 has no tool-result
195
247
  // `terminate` handling. `agent.complete` asks the surface to shut the
@@ -202,6 +254,17 @@ export default function onlyne(pi: ExtensionAPI) {
202
254
  details: params.details,
203
255
  files: params.files,
204
256
  });
257
+ // The client that had already settled this task answers with its own
258
+ // record, and the sentence says so rather than claiming a completion
259
+ // this call did not cause.
260
+ if (result.alreadySettled) {
261
+ const settled = result.alreadySettled;
262
+ const head = settled.head ? `: ${settled.head}` : "";
263
+ return textResult(
264
+ `task already settled as ${settled.state} at ${settled.settled_at}${head} — this call changed nothing`,
265
+ { outcome: result.outcome, alreadySettled: settled }
266
+ );
267
+ }
205
268
  // The outcome and nothing else: the ledger's head stays a display
206
269
  // field (docs/v2-CONTRACT.md §3c), and the task's identity is not a
207
270
  // fact the model is meant to hold.
@@ -226,7 +289,9 @@ export default function onlyne(pi: ExtensionAPI) {
226
289
  text: Type.String({ description: "handoff text for the receiving role" }),
227
290
  image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
228
291
  }),
229
- async execute(_toolCallId, params) {
292
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
293
+ const caller = sessionIdOf(ctx?.sessionManager);
294
+ if (!ownsSession(boundSessionId, caller)) refuseForeignSession(caller);
230
295
  if (!agent) throw new Error("onlyne: session is not connected");
231
296
  const result = await agent.handoffFromTool({
232
297
  to: params.to,
@@ -246,6 +311,17 @@ export default function onlyne(pi: ExtensionAPI) {
246
311
  pi.registerCommand("onlyne", {
247
312
  description: "Onlyne session status: connection, task, reports",
248
313
  handler: async (argLine, commandContext) => {
314
+ // The command drives this plugin's connection, so it belongs to the
315
+ // bound session for the same reason the tools do: a sidechain that
316
+ // issued `/onlyne disconnect` would detach the real session.
317
+ const caller = sessionIdOf(commandContext?.sessionManager);
318
+ if (!ownsSession(boundSessionId, caller)) {
319
+ commandContext.ui.notify(
320
+ foreignSessionRefusal(boundSessionId, caller),
321
+ "info"
322
+ );
323
+ return;
324
+ }
249
325
  if (!agent) {
250
326
  commandContext.ui.notify("onlyne: no session (ONLYNE_* env is absent)", "info");
251
327
  return;
@@ -269,10 +345,59 @@ export default function onlyne(pi: ExtensionAPI) {
269
345
  }
270
346
  };
271
347
 
272
- pi.on("session_start", async (_event, ctx) => {
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;
273
356
  context = ctx;
274
- const identity = sessionIdentity(env);
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;
275
371
  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
+ const starting = sessionIdOf(ctx.sessionManager);
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`);
396
+ return;
397
+ }
398
+ const wireSessionId = identity.sessionId ?? starting;
399
+ context = ctx;
400
+ boundSessionId = spawned ? spawned.sessionId : (starting ?? wireSessionId);
276
401
  const config = loadConfig(ctx.cwd);
277
402
  if (config.warning) log(config.warning);
278
403
  if (!config.enabled) {
@@ -284,23 +409,30 @@ export default function onlyne(pi: ExtensionAPI) {
284
409
  // neither has no socket to dial, and saying so is the whole answer: this
285
410
  // stays a plain pi session instead of retrying a path nothing serves.
286
411
  let socketPath: string | null = null;
412
+ const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
287
413
  try {
288
- socketPath = resolveSocketPath(env, ctx.cwd);
414
+ socketPath = resident
415
+ ? resolveResidentSocketPath(env, targetWorkspace, identity.role)
416
+ : resolveSocketPath(env, ctx.cwd);
289
417
  } catch (error) {
290
418
  log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
291
419
  }
292
420
  if (socketPath === null) return;
293
- surface = createSurface({ pi, log, context: () => context, sessionId: identity.sessionId });
421
+ surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
294
422
  agent = new OnlyneAgent({
295
423
  socketPath,
424
+ resolveSocket: resident
425
+ ? () => resolveResidentSocketPath(env, targetWorkspace, identity.role)
426
+ : undefined,
296
427
  cwd: ctx.cwd,
297
428
  role: identity.role,
298
- sessionId: identity.sessionId,
429
+ sessionId: wireSessionId,
299
430
  taskId: identity.taskId,
431
+ resident: Boolean(resident),
300
432
  surface,
301
433
  log,
302
434
  });
303
- log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
435
+ log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
304
436
  registerSurface();
305
437
  if (!surface.available.wakeUser) {
306
438
  // Without an injection surface the payload travels through config_get,
@@ -311,6 +443,18 @@ export default function onlyne(pi: ExtensionAPI) {
311
443
  if (config.autoStart) agent.start();
312
444
  });
313
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
+
314
458
  // The role prose is instruction-layer text: `before_agent_start` hands the
315
459
  // handler the prompt options the run is about to render, and a section written
316
460
  // there is part of the system prompt rather than one more message the model has
@@ -351,5 +495,6 @@ export default function onlyne(pi: ExtensionAPI) {
351
495
  surface = null;
352
496
  registered = false;
353
497
  context = null;
498
+ boundSessionId = null;
354
499
  });
355
500
  }
@@ -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
 
@@ -0,0 +1,90 @@
1
+ // One question, asked twice: is the session in front of us the one the client
2
+ // spawned this process for?
3
+ //
4
+ // A pi process runs more than one session. The client spawns pi with
5
+ // `--session-id <the delivery's session>` and injects `ONLYNE_*` into the
6
+ // process environment, and pi then runs other sessions inside that same process
7
+ // with the same environment: a `task` subagent's sidechain
8
+ // (`@tintinweb/pi-subagents` re-binds every extension for the child,
9
+ // `session.bindExtensions`), and `/new`. Each of those loads this extension and
10
+ // sees the parent's `ONLYNE_SESSION_ID`, so a rule that read the environment
11
+ // alone would mount a second connection for the parent's session and let a
12
+ // subagent's `onlyne_complete` settle the parent's task — which is exactly what
13
+ // happened live (librarian task cfa73386, 2026-08-11: a writer subagent settled
14
+ // its parent at 05:15:47 while the parent kept writing).
15
+ //
16
+ // So the rule is the session's own id, read off the pid pi gave the handler, and
17
+ // the environment is only the expectation it is measured against. The two
18
+ // readings are separate on purpose: `mountsSession` is the door (does this
19
+ // session get a connection and the tools at all), `ownsSession` is the guard
20
+ // behind it (may this caller's tool call move the task), and a pi that cannot
21
+ // report a session id at all is the one case where both answer yes — refusing
22
+ // every call on an older pi would break the ordinary session to prevent nothing,
23
+ // because the env-bound mount is still the only one that connected.
24
+
25
+ /**
26
+ * The id of the session a pi extension context belongs to.
27
+ *
28
+ * `null` means this pi cannot tell — no `sessionManager`, or one without
29
+ * `getSessionId` — which is the honest reading of an unknown, and both rules
30
+ * below treat it as "allowed" for the reason in the header.
31
+ *
32
+ * @param {{ getSessionId?: () => unknown } | null | undefined} sessionManager
33
+ * @returns {string | null}
34
+ */
35
+ export function sessionIdOf(sessionManager) {
36
+ try {
37
+ const id = sessionManager?.getSessionId?.();
38
+ return typeof id === "string" && id.length > 0 ? id : null;
39
+ } catch {
40
+ // A context that throws is a context this plugin cannot read; the caller
41
+ // gets the same answer as one that is absent.
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Whether the session pi is starting is the one the client spawned this process
48
+ * for, and therefore whether it may mount.
49
+ *
50
+ * @param {string} identitySessionId `ONLYNE_SESSION_ID`, the client's own word
51
+ * @param {string | null} startingSessionId the session's own id, or `null`
52
+ * @returns {boolean}
53
+ */
54
+ export function mountsSession(identitySessionId, startingSessionId) {
55
+ return startingSessionId === null || startingSessionId === identitySessionId;
56
+ }
57
+
58
+ /**
59
+ * Whether a call comes from the session that mounted this plugin.
60
+ *
61
+ * The guard behind the tools and the `/onlyne` command: a sidechain that reached
62
+ * a tool through its parent's registration must not settle the parent's task,
63
+ * and the sentence it earns says so rather than reporting an outcome nobody
64
+ * asked this session for.
65
+ *
66
+ * @param {string | null} boundSessionId the session that mounted, or `null`
67
+ * before one has
68
+ * @param {string | null} callingSessionId the session the call came from
69
+ * @returns {boolean}
70
+ */
71
+ export function ownsSession(boundSessionId, callingSessionId) {
72
+ if (callingSessionId === null) return true;
73
+ return boundSessionId !== null && callingSessionId === boundSessionId;
74
+ }
75
+
76
+ /**
77
+ * The one sentence a call from a foreign session earns, for a tool error and for
78
+ * a command notice alike.
79
+ *
80
+ * @param {string | null} boundSessionId
81
+ * @param {string | null} callingSessionId
82
+ * @returns {string}
83
+ */
84
+ export function foreignSessionRefusal(boundSessionId, callingSessionId) {
85
+ return (
86
+ `onlyne: session ${callingSessionId} did not mount these tools; they belong to onlyne ` +
87
+ `session ${boundSessionId}, and a subagent or another session in the same pi process ` +
88
+ `must not settle this task`
89
+ );
90
+ }