pi-onlyne 2.2.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/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}
@@ -35,15 +40,33 @@
35
40
  // environment, and a sidechain that called `onlyne_complete` would settle the
36
41
  // parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
37
42
  // what a call from a foreign session is told.
38
-
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
+ //
39
54
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
40
55
  import { Type } from "typebox";
41
56
 
42
57
  import { OnlyneAgent } from "./agent.mjs";
43
- import { loadConfig, sessionIdentity } from "./config.mjs";
44
- import { resolveSocketPath } from "./socket.mjs";
58
+ import { ASSIGN_MESSAGE_TYPE, createAssignMessageComponent } from "./activity.mjs";
59
+ import { loadConfig, residentIdentity, sessionIdentity } from "./config.mjs";
60
+ import { resolveResidentSocketPath, resolveSocketPath } from "./socket.mjs";
45
61
  import { createSurface } from "./pi-surface.mjs";
46
- 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";
47
70
 
48
71
  /**
49
72
  * Node's process globals, declared here so the package needs no `@types/node`
@@ -82,11 +105,11 @@ interface PiSurface {
82
105
  registerTool: boolean;
83
106
  registerCommand: boolean;
84
107
  };
85
- wakeUser(text: string, parts?: ImagePartInput[]): boolean;
108
+ wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
86
109
  roleProse(text: string): boolean;
87
- applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
110
+ applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }, fallback?: string): boolean;
88
111
  customEntry(customType: string, data: unknown): boolean;
89
- widget(lines: string[] | undefined): void;
112
+ widget(lines: string[] | undefined, activity?: unknown): void;
90
113
  status(text: string): void;
91
114
  welcome(welcome: WelcomeLike): void;
92
115
  isIdle(): boolean;
@@ -140,6 +163,18 @@ const textResult = (text: string, details?: unknown) => ({
140
163
  details,
141
164
  });
142
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
+
143
178
  export default function onlyne(pi: ExtensionAPI) {
144
179
  const env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env;
145
180
  let context: ExtensionContext | null = null;
@@ -173,6 +208,16 @@ export default function onlyne(pi: ExtensionAPI) {
173
208
  const registerSurface = () => {
174
209
  if (registered || !surface) return;
175
210
  registered = true;
211
+ if ("registerMessageRenderer" in pi && typeof pi.registerMessageRenderer === "function") {
212
+ try {
213
+ pi.registerMessageRenderer(ASSIGN_MESSAGE_TYPE, (message, options, theme) => {
214
+ if (!message?.details || typeof message.details !== "object") return undefined;
215
+ return createAssignMessageComponent(message.details, message.content, theme, !options?.expanded);
216
+ });
217
+ } catch (error) {
218
+ log(`registerMessageRenderer(${ASSIGN_MESSAGE_TYPE}) refused: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ }
176
221
  try {
177
222
  pi.registerTool(defineTool({
178
223
  name: "onlyne_send",
@@ -329,9 +374,68 @@ export default function onlyne(pi: ExtensionAPI) {
329
374
  }
330
375
  };
331
376
 
332
- pi.on("session_start", async (_event, ctx) => {
333
- const identity = sessionIdentity(env);
377
+ /**
378
+ * Re-point the plugin at the session pi made current. The socket belongs to
379
+ * the role, not to the session file that mounted it: /new, /resume, /fork and
380
+ * a tree jump replace the live session in this process, and the tools must
381
+ * follow it instead of refusing every call as foreign.
382
+ */
383
+ const syncSessionContext = (_event: unknown, ctx: ExtensionContext) => {
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;
389
+ context = ctx;
390
+ const currentId = sessionIdOf(ctx?.sessionManager);
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;
399
+ boundSessionId = currentId;
400
+ }
401
+ if (surface?.available.widget) {
402
+ surface.widget(agent.activity.lines(), agent.activity);
403
+ }
404
+ surface?.status(agent.activity.lines()[0]);
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
+ }
410
+
411
+ pi.on("session_start", async (event, ctx) => {
412
+ const spawned = sessionIdentity(env);
413
+ const resident = spawned ? null : residentIdentity(env, ctx.cwd);
414
+ const identity = spawned ?? resident;
334
415
  if (!identity) return; // not an onlyne session: stay out of the way
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
+ }
430
+ // A resident plugin already holds its connection, so another session_start
431
+ // in this process is a re-point, not a mount: /new, /resume and /fork
432
+ // replace the live session, and pi can re-bind this same session's
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.
435
+ if (!spawned && agent !== null && (event.reason !== "startup" || starting === boundSessionId)) {
436
+ syncSessionContext(event, ctx);
437
+ return;
438
+ }
335
439
  // Only the session the client names may mount. A pi process runs more than
336
440
  // one session — a `task` subagent's sidechain, `/new` — and each of them
337
441
  // loads this extension with the same inherited environment, so without this
@@ -339,13 +443,34 @@ export default function onlyne(pi: ExtensionAPI) {
339
443
  // librarian task cfa73386). An unknown id means an older pi that does not
340
444
  // expose one; the env-bound mount is then the only candidate and stays the
341
445
  // behaviour, with no line to explain.
342
- const starting = sessionIdOf(ctx.sessionManager);
343
- if (!mountsSession(identity.sessionId, starting)) {
344
- log(`session ${starting} is not the onlyne session ${identity.sessionId}; staying inert`);
345
- return;
446
+ if (spawned) {
447
+ if (!mountsSession(spawned.sessionId, starting)) {
448
+ log(`session ${starting} is not the onlyne session ${spawned.sessionId}; staying inert`);
449
+ return;
450
+ }
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;
346
470
  }
471
+ const wireSessionId = identity.sessionId ?? starting;
347
472
  context = ctx;
348
- boundSessionId = identity.sessionId;
473
+ boundSessionId = spawned ? spawned.sessionId : (starting ?? wireSessionId);
349
474
  const config = loadConfig(ctx.cwd);
350
475
  if (config.warning) log(config.warning);
351
476
  if (!config.enabled) {
@@ -357,23 +482,36 @@ export default function onlyne(pi: ExtensionAPI) {
357
482
  // neither has no socket to dial, and saying so is the whole answer: this
358
483
  // stays a plain pi session instead of retrying a path nothing serves.
359
484
  let socketPath: string | null = null;
485
+ const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
486
+ const declaredCluster = resident ? resident.cluster : null;
360
487
  try {
361
- socketPath = resolveSocketPath(env, ctx.cwd);
488
+ socketPath = resident
489
+ ? resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
490
+ : resolveSocketPath(env, ctx.cwd);
362
491
  } catch (error) {
363
492
  log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
364
493
  }
365
494
  if (socketPath === null) return;
366
- surface = createSurface({ pi, log, context: () => context, sessionId: identity.sessionId });
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 ?? "";
499
+ surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
367
500
  agent = new OnlyneAgent({
368
501
  socketPath,
502
+ resolveSocket: resident
503
+ ? () => resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
504
+ : undefined,
369
505
  cwd: ctx.cwd,
370
506
  role: identity.role,
371
- sessionId: identity.sessionId,
507
+ sessionId: wireSessionId,
372
508
  taskId: identity.taskId,
509
+ resident: Boolean(resident),
373
510
  surface,
374
511
  log,
512
+ prose: carriedProse,
375
513
  });
376
- log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
514
+ log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
377
515
  registerSurface();
378
516
  if (!surface.available.wakeUser) {
379
517
  // Without an injection surface the payload travels through config_get,
@@ -384,13 +522,28 @@ export default function onlyne(pi: ExtensionAPI) {
384
522
  if (config.autoStart) agent.start();
385
523
  });
386
524
 
525
+ // `session_switch` and `session_branch` name the same transitions in the omp
526
+ // lineage of this API; current pi reports them as `session_start` with a
527
+ // reason and keeps `session_tree` for navigation, so one helper serves all.
528
+ // The installed typings no longer declare the first two, but the host stores
529
+ // handlers by name, which this narrow cast is the whole boundary of.
530
+ const legacySessionEvents = pi as unknown as {
531
+ on(event: "session_switch" | "session_branch", handler: (event: unknown, ctx: ExtensionContext) => void): void;
532
+ };
533
+ legacySessionEvents.on("session_switch", syncSessionContext);
534
+ legacySessionEvents.on("session_branch", syncSessionContext);
535
+ pi.on("session_tree", async (event, ctx) => syncSessionContext(event, ctx));
536
+
387
537
  // The role prose is instruction-layer text: `before_agent_start` hands the
388
538
  // handler the prompt options the run is about to render, and a section written
389
539
  // there is part of the system prompt rather than one more message the model has
390
540
  // to read as an utterance. Outside an onlyne session there is no surface and
391
541
  // nothing to add.
392
542
  pi.on("before_agent_start", async (event) => {
393
- 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 ?? "");
394
547
  });
395
548
 
396
549
  pi.on("turn_start", async () => {
@@ -420,6 +573,10 @@ export default function onlyne(pi: ExtensionAPI) {
420
573
  agent?.stop(`pi:${event.reason ?? "quit"}`);
421
574
  surface?.closeBackground?.();
422
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;
423
580
  agent = null;
424
581
  surface = null;
425
582
  registered = false;
@@ -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;
181
+ try {
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
+ }
153
210
  try {
154
- pi.sendUserMessage(content, { deliverAs: "followUp" });
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 {
@@ -180,7 +241,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
180
241
  let warnedNoSections = false;
181
242
 
182
243
  /**
183
- * 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.
184
247
  * @param {string} text
185
248
  * @returns {boolean} whether there is prose to carry
186
249
  */
@@ -198,10 +261,17 @@ export function createSurface({ pi, log, context, sessionId = null }) {
198
261
  * pi's own examples does this). pi rebuilds those options for every run, so the
199
262
  * section is written again rather than once — the write is idempotent, and the
200
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.
201
269
  * @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
270
+ * @param {string} [fallback]
202
271
  * @returns {boolean} whether a section was written
203
272
  */
204
- const applyRoleProse = (event) => {
273
+ const applyRoleProse = (event, fallback = "") => {
274
+ if (roleProseText.length === 0 && typeof fallback === "string") roleProseText = fallback;
205
275
  if (roleProseText.length === 0) return false;
206
276
  const sections = event?.systemPromptOptions?.sections;
207
277
  if (!sections) {
@@ -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
 
@@ -186,6 +260,30 @@ test("a pi whose prompt options carry no sections reports it once and carries no
186
260
  assert.match(lines[0], /no instruction layer/);
187
261
  });
188
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
+
189
287
  /**
190
288
  * A surface over a fake pi that answers the two questions the phase is read
191
289
  * from, plus the cases where pi answers neither.