pi-onlyne 2.2.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 +16 -7
- package/README.zh.md +12 -5
- package/package.json +1 -1
- package/src/activity.mjs +294 -0
- package/src/activity.test.mjs +69 -1
- package/src/agent.mjs +102 -11
- package/src/agent.test.mjs +22 -1
- package/src/config.mjs +173 -3
- package/src/config.test.mjs +37 -1
- package/src/index.ts +85 -14
- package/src/pi-surface.mjs +69 -8
- package/src/pi-surface.test.mjs +78 -4
- package/src/socket.mjs +45 -0
- package/src/socket.test.mjs +27 -0
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}
|
|
@@ -40,8 +45,9 @@ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-
|
|
|
40
45
|
import { Type } from "typebox";
|
|
41
46
|
|
|
42
47
|
import { OnlyneAgent } from "./agent.mjs";
|
|
43
|
-
import {
|
|
44
|
-
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";
|
|
45
51
|
import { createSurface } from "./pi-surface.mjs";
|
|
46
52
|
import { foreignSessionRefusal, mountsSession, ownsSession, sessionIdOf } from "./session-scope.mjs";
|
|
47
53
|
|
|
@@ -82,11 +88,11 @@ interface PiSurface {
|
|
|
82
88
|
registerTool: boolean;
|
|
83
89
|
registerCommand: boolean;
|
|
84
90
|
};
|
|
85
|
-
wakeUser(text: string, parts?: ImagePartInput[]): boolean;
|
|
91
|
+
wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
|
|
86
92
|
roleProse(text: string): boolean;
|
|
87
93
|
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
|
|
88
94
|
customEntry(customType: string, data: unknown): boolean;
|
|
89
|
-
widget(lines: string[] | undefined): void;
|
|
95
|
+
widget(lines: string[] | undefined, activity?: unknown): void;
|
|
90
96
|
status(text: string): void;
|
|
91
97
|
welcome(welcome: WelcomeLike): void;
|
|
92
98
|
isIdle(): boolean;
|
|
@@ -173,6 +179,16 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
173
179
|
const registerSurface = () => {
|
|
174
180
|
if (registered || !surface) return;
|
|
175
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
|
+
}
|
|
176
192
|
try {
|
|
177
193
|
pi.registerTool(defineTool({
|
|
178
194
|
name: "onlyne_send",
|
|
@@ -329,8 +345,29 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
329
345
|
}
|
|
330
346
|
};
|
|
331
347
|
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
|
356
|
+
context = ctx;
|
|
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;
|
|
334
371
|
if (!identity) return; // not an onlyne session: stay out of the way
|
|
335
372
|
// Only the session the client names may mount. A pi process runs more than
|
|
336
373
|
// one session — a `task` subagent's sidechain, `/new` — and each of them
|
|
@@ -340,12 +377,27 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
340
377
|
// expose one; the env-bound mount is then the only candidate and stays the
|
|
341
378
|
// behaviour, with no line to explain.
|
|
342
379
|
const starting = sessionIdOf(ctx.sessionManager);
|
|
343
|
-
|
|
344
|
-
|
|
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`);
|
|
345
396
|
return;
|
|
346
397
|
}
|
|
398
|
+
const wireSessionId = identity.sessionId ?? starting;
|
|
347
399
|
context = ctx;
|
|
348
|
-
boundSessionId =
|
|
400
|
+
boundSessionId = spawned ? spawned.sessionId : (starting ?? wireSessionId);
|
|
349
401
|
const config = loadConfig(ctx.cwd);
|
|
350
402
|
if (config.warning) log(config.warning);
|
|
351
403
|
if (!config.enabled) {
|
|
@@ -357,23 +409,30 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
357
409
|
// neither has no socket to dial, and saying so is the whole answer: this
|
|
358
410
|
// stays a plain pi session instead of retrying a path nothing serves.
|
|
359
411
|
let socketPath: string | null = null;
|
|
412
|
+
const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
|
|
360
413
|
try {
|
|
361
|
-
socketPath =
|
|
414
|
+
socketPath = resident
|
|
415
|
+
? resolveResidentSocketPath(env, targetWorkspace, identity.role)
|
|
416
|
+
: resolveSocketPath(env, ctx.cwd);
|
|
362
417
|
} catch (error) {
|
|
363
418
|
log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
|
|
364
419
|
}
|
|
365
420
|
if (socketPath === null) return;
|
|
366
|
-
surface = createSurface({ pi, log, context: () => context, sessionId:
|
|
421
|
+
surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
|
|
367
422
|
agent = new OnlyneAgent({
|
|
368
423
|
socketPath,
|
|
424
|
+
resolveSocket: resident
|
|
425
|
+
? () => resolveResidentSocketPath(env, targetWorkspace, identity.role)
|
|
426
|
+
: undefined,
|
|
369
427
|
cwd: ctx.cwd,
|
|
370
428
|
role: identity.role,
|
|
371
|
-
sessionId:
|
|
429
|
+
sessionId: wireSessionId,
|
|
372
430
|
taskId: identity.taskId,
|
|
431
|
+
resident: Boolean(resident),
|
|
373
432
|
surface,
|
|
374
433
|
log,
|
|
375
434
|
});
|
|
376
|
-
log(`session ${
|
|
435
|
+
log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
|
|
377
436
|
registerSurface();
|
|
378
437
|
if (!surface.available.wakeUser) {
|
|
379
438
|
// Without an injection surface the payload travels through config_get,
|
|
@@ -384,6 +443,18 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
384
443
|
if (config.autoStart) agent.start();
|
|
385
444
|
});
|
|
386
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
|
+
|
|
387
458
|
// The role prose is instruction-layer text: `before_agent_start` hands the
|
|
388
459
|
// handler the prompt options the run is about to render, and a section written
|
|
389
460
|
// there is part of the system prompt rather than one more message the model has
|
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
|
|
package/src/socket.mjs
CHANGED
|
@@ -210,12 +210,57 @@ export function listClientRegistrations(env, options = {}) {
|
|
|
210
210
|
path: join(dir, name),
|
|
211
211
|
root: canonicalRoot(parsed.root, options),
|
|
212
212
|
kind: parsed.kind,
|
|
213
|
+
role: typeof parsed.role === "string" && parsed.role !== "" ? parsed.role : null,
|
|
213
214
|
runtime: typeof parsed.runtime === "string" && parsed.runtime !== "" ? parsed.runtime : null,
|
|
214
215
|
});
|
|
215
216
|
}
|
|
216
217
|
return found;
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the socket path for a resident or pre-started pi session.
|
|
222
|
+
*
|
|
223
|
+
* Unlike [`resolveSocketPath`], when `onlyne-client` has not been started yet
|
|
224
|
+
* (so `<runtime_dir>/<digest>.json` does not exist), this falls back to the
|
|
225
|
+
* deterministic `<runtime_dir>/<workspaceDigest(workspaceRoot)>.sock` path so
|
|
226
|
+
* the agent can enter its background reconnect loop and handshake automatically
|
|
227
|
+
* the moment `onlyne-client` binds the socket. When a client registration for
|
|
228
|
+
* `role` appears in `<runtime_dir>`, it prefers that registration's socket.
|
|
229
|
+
*
|
|
230
|
+
* @param {Record<string, string | undefined>} env
|
|
231
|
+
* @param {string} workspaceRoot
|
|
232
|
+
* @param {string} role
|
|
233
|
+
* @param {{ readFile?: (path: string) => string, readDir?: (path: string) => string[], realpath?: (path: string) => string, uid?: number | null, runtime?: string }} [options]
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
export function resolveResidentSocketPath(env, workspaceRoot, role, options = {}) {
|
|
237
|
+
const injected = typeof env[SOCKET_ENV] === "string" ? env[SOCKET_ENV].trim() : "";
|
|
238
|
+
if (injected) return injected;
|
|
239
|
+
|
|
240
|
+
const dir = runtimeDir(env, options);
|
|
241
|
+
const here = canonicalRoot(workspaceRoot, options);
|
|
242
|
+
const candidates = listClientRegistrations(env, options);
|
|
243
|
+
|
|
244
|
+
const coveringRole = candidates.filter((entry) => covers(entry.root, here) && (!role || !entry.role || entry.role === role));
|
|
245
|
+
if (coveringRole.length > 0) {
|
|
246
|
+
const nearest = coveringRole.reduce((best, entry) => (entry.root.length > best.root.length ? entry : best));
|
|
247
|
+
return join(dir, `${workspaceDigest(nearest.root, options)}${SOCKET_SUFFIX}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (role) {
|
|
251
|
+
const byRole = candidates.filter((entry) => entry.role === role);
|
|
252
|
+
if (byRole.length === 1) {
|
|
253
|
+
return join(dir, `${workspaceDigest(byRole[0].root, options)}${SOCKET_SUFFIX}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
return resolveSocketPath(env, workspaceRoot, options);
|
|
259
|
+
} catch {
|
|
260
|
+
return socketPath(workspaceRoot, env, options);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
/**
|
|
220
265
|
* The socket path to dial for the pi working directory `cwd`.
|
|
221
266
|
*
|
package/src/socket.test.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
canonicalRoot,
|
|
18
18
|
listClientRegistrations,
|
|
19
19
|
registrationPath,
|
|
20
|
+
resolveResidentSocketPath,
|
|
20
21
|
resolveSocketPath,
|
|
21
22
|
runtimeDir,
|
|
22
23
|
socketPath,
|
|
@@ -235,3 +236,29 @@ test("a registration read through the injected filesystem names the same socket"
|
|
|
235
236
|
// The env is what names the socket a client injected, and it is still first.
|
|
236
237
|
assert.equal(resolveSocketPath({ ONLYNE_RUNTIME_DIR: dir, [SOCKET_ENV]: "/tmp/served" }, workspace, options), "/tmp/served");
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
test("resolveResidentSocketPath falls back to deterministic socket before client starts, then adopts registered role socket", () => {
|
|
241
|
+
const clusterRoot = scratch("pi-onlyne-cluster-");
|
|
242
|
+
const roleWs = join(clusterRoot, "supervisor");
|
|
243
|
+
mkdirSync(roleWs, { recursive: true });
|
|
244
|
+
const emptyDir = runtime([]);
|
|
245
|
+
|
|
246
|
+
// Before onlyne-client starts, no registration exists; returns deterministic socket path.
|
|
247
|
+
assert.equal(
|
|
248
|
+
resolveResidentSocketPath(envFor(emptyDir), roleWs, "supervisor"),
|
|
249
|
+
socketPath(roleWs, envFor(emptyDir)),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Once onlyne-client starts and registers the role in the runtime directory, even if
|
|
253
|
+
// omp was started in the parent clusterRoot, it resolves the registered role's socket.
|
|
254
|
+
const liveDir = runtime([
|
|
255
|
+
{
|
|
256
|
+
name: registrationName(roleWs),
|
|
257
|
+
body: { kind: "client", role: "supervisor", root: canonicalRoot(roleWs), pid: 9001, version: "2.3.0", runtime: "pi" },
|
|
258
|
+
},
|
|
259
|
+
]);
|
|
260
|
+
assert.equal(
|
|
261
|
+
resolveResidentSocketPath(envFor(liveDir), clusterRoot, "supervisor"),
|
|
262
|
+
socketPath(roleWs, envFor(liveDir)),
|
|
263
|
+
);
|
|
264
|
+
});
|