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/README.md +77 -35
- package/README.zh.md +48 -19
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +157 -84
- package/src/agent.test.mjs +97 -45
- package/src/config.mjs +173 -3
- package/src/config.test.mjs +37 -1
- package/src/index.ts +163 -18
- package/src/pi-surface.mjs +69 -8
- package/src/pi-surface.test.mjs +78 -4
- package/src/session-scope.mjs +90 -0
- package/src/session-scope.test.mjs +61 -0
- package/src/socket.mjs +45 -0
- package/src/socket.test.mjs +27 -0
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,
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
429
|
+
sessionId: wireSessionId,
|
|
299
430
|
taskId: identity.taskId,
|
|
431
|
+
resident: Boolean(resident),
|
|
300
432
|
surface,
|
|
301
433
|
log,
|
|
302
434
|
});
|
|
303
|
-
log(`session ${
|
|
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
|
}
|
package/src/pi-surface.mjs
CHANGED
|
@@ -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: () =>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/pi-surface.test.mjs
CHANGED
|
@@ -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: () =>
|
|
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
|
+
}
|