pi-onlyne 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/README.zh.md +7 -0
- package/package.json +1 -1
- package/src/agent.mjs +37 -3
- package/src/agent.test.mjs +61 -0
- package/src/config.mjs +7 -2
- package/src/config.test.mjs +22 -0
- package/src/index.ts +105 -19
- package/src/pi-surface.mjs +11 -2
- package/src/pi-surface.test.mjs +24 -0
- package/src/resident-claim.test.mjs +461 -0
- package/src/session-scope.mjs +53 -0
- package/src/session-scope.test.mjs +34 -0
- package/src/socket.mjs +62 -7
- package/src/socket.test.mjs +130 -3
package/README.md
CHANGED
|
@@ -121,6 +121,9 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
|
121
121
|
| --- | --- | --- |
|
|
122
122
|
| `enabled` | `true` | `false` turns the extension off for this workspace |
|
|
123
123
|
| `watch.autoStart` | `true` | `false` registers the tools but opens no socket until `/onlyne connect` |
|
|
124
|
+
| `role` | — | the role this session serves when the client injected no `ONLYNE_ROLE`; also what makes the file a resident opt-in |
|
|
125
|
+
| `workspace` | — | the role workspace, when the session did not start inside it; a relative path resolves against the pi working directory |
|
|
126
|
+
| `cluster` | — | the cluster whose client this session dials — the serving server's `[server] name`; unset means the tree decides |
|
|
124
127
|
|
|
125
128
|
A missing file means every default. A malformed file prints one warning on stderr and
|
|
126
129
|
keeps the defaults: a typo must not silently disable a role. The client does not read
|
|
@@ -128,6 +131,13 @@ this file (§11 of the plan downgraded the old readiness gates to generate-time
|
|
|
128
131
|
advice), so only this extension consumes it; the key shape stays the one the templates
|
|
129
132
|
carry.
|
|
130
133
|
|
|
134
|
+
`cluster` is the answer for a session whose body runs in one cluster's tree while the
|
|
135
|
+
role workspace belongs to another: with the key set, the client registered for this role
|
|
136
|
+
in that cluster is dialed first, whatever tree the session runs in. A cluster holding two
|
|
137
|
+
clients of the role is refused by name rather than picked from. With the key absent — or
|
|
138
|
+
naming a cluster no client has registered — resolution falls back to the tree, exactly as
|
|
139
|
+
it did before the key existed.
|
|
140
|
+
|
|
131
141
|
Nothing else is needed. The workspace's `session_command` in `spec.toml` already spawns
|
|
132
142
|
`pi` per task (`["pi", "--session-id", "{session}"]`), and the client injects the
|
|
133
143
|
environment this extension keys on.
|
package/README.zh.md
CHANGED
|
@@ -112,6 +112,8 @@ pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
|
112
112
|
| --- | --- | --- |
|
|
113
113
|
| `enabled` | `true` | `false` 时该工作区禁用扩展 |
|
|
114
114
|
| `watch.autoStart` | `true` | `false` 时注册工具但不建连接,需 `/onlyne connect` |
|
|
115
|
+
| `workspace` | — | 角色工作区路径(当会话未在其中启动时生效;相对路径相对 pi 工作目录解析) |
|
|
116
|
+
| `cluster` | — | 本会话要连接的集群名(对应服务端的 `[server] name`;未设时由工作区目录树决定) |
|
|
115
117
|
|
|
116
118
|
文件缺失即取各自默认值。文件格式错误时打印一行警告,并保留默认值:一个笔误不该静默关掉一个
|
|
117
119
|
role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为 generate 期模板提示),所以
|
|
@@ -120,6 +122,11 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
|
|
|
120
122
|
其余无需配置。工作区 `spec.toml` 的 `session_command` 已经按任务拉起 `pi`
|
|
121
123
|
(`["pi", "--session-id", "{session}"]`),client 负责注入本扩展识别的环境变量。
|
|
122
124
|
|
|
125
|
+
`cluster` 专门解决“会话主体在一个集群的目录树下运行、但挂载的角色工作区属于另一个集群”的夹心代理场景:
|
|
126
|
+
配置该键后,无论会话当前处于哪棵目录树下,都会优先连接该集群中为该角色注册的客户端。若同一集群内存在
|
|
127
|
+
两个同名角色客户端,会直接显式报错拒绝,绝不随意选取。若未配置该键(或指定的集群未发现注册客户端),
|
|
128
|
+
则优雅回退至原有的工作区目录树收敛规则。
|
|
129
|
+
|
|
123
130
|
## 2. 能力表
|
|
124
131
|
|
|
125
132
|
`hello` 只声明真实实现的能力:
|
package/package.json
CHANGED
package/src/agent.mjs
CHANGED
|
@@ -93,6 +93,7 @@ export class OnlyneAgent {
|
|
|
93
93
|
* helloTimeoutMs?: number,
|
|
94
94
|
* createConnection?: (path: string) => any,
|
|
95
95
|
* timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
|
|
96
|
+
* prose?: string,
|
|
96
97
|
* }} options
|
|
97
98
|
*/
|
|
98
99
|
constructor(options) {
|
|
@@ -171,6 +172,18 @@ export class OnlyneAgent {
|
|
|
171
172
|
*/
|
|
172
173
|
this.injectedDeliveries = new Set();
|
|
173
174
|
this.deliveredProse = new Set();
|
|
175
|
+
/**
|
|
176
|
+
* The newest role prose the client sent. `welcome` carries it and an
|
|
177
|
+
* `assign` names it again; it is kept here, not only in the surface,
|
|
178
|
+
* because a surface is replaced while this process keeps running — a
|
|
179
|
+
* `/new` or `/resume` re-binds the plugin onto a fresh one — and the prose
|
|
180
|
+
* is the role's instruction layer, not a message that could be replayed
|
|
181
|
+
* from the transcript.
|
|
182
|
+
*/
|
|
183
|
+
this.latestProse = typeof options.prose === "string" ? options.prose : "";
|
|
184
|
+
// A surface handed over at construction is owed the prose this agent was
|
|
185
|
+
// started with: it holds none of its own yet.
|
|
186
|
+
if (this.latestProse) this.surface?.roleProse?.(this.latestProse);
|
|
174
187
|
/** Pushes that arrived before the handshake finished; see `onFrame`. */
|
|
175
188
|
this.handshaking = false;
|
|
176
189
|
this.deferredPushes = [];
|
|
@@ -241,6 +254,21 @@ export class OnlyneAgent {
|
|
|
241
254
|
};
|
|
242
255
|
}
|
|
243
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Hand the newest role prose to the current surface again.
|
|
259
|
+
*
|
|
260
|
+
* The surface keeps the prose for the instruction layer, and a surface made
|
|
261
|
+
* after the handshake — or after this process moved onto another session —
|
|
262
|
+
* starts with none of it. The delivery guard must not be the reason it stays
|
|
263
|
+
* empty, so whoever binds a surface calls this.
|
|
264
|
+
* @returns {boolean} whether there was prose to hand over
|
|
265
|
+
*/
|
|
266
|
+
syncRoleProse() {
|
|
267
|
+
if (typeof this.latestProse !== "string" || this.latestProse.length === 0) return false;
|
|
268
|
+
this.surface?.roleProse?.(this.latestProse);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
244
272
|
// ------------------------------------------------------------- transport
|
|
245
273
|
|
|
246
274
|
connect() {
|
|
@@ -387,8 +415,13 @@ export class OnlyneAgent {
|
|
|
387
415
|
});
|
|
388
416
|
this.notice("state", `connected role=${welcome.role} gen=${welcome.generation} host=${welcome.hostCapabilities.join(",")}`);
|
|
389
417
|
const prose = welcome.prose.trim();
|
|
390
|
-
if (prose
|
|
418
|
+
if (prose) {
|
|
419
|
+
this.latestProse = prose;
|
|
391
420
|
this.deliveredProse.add(prose);
|
|
421
|
+
// Every welcome hands the prose over, not only the first one. A
|
|
422
|
+
// reconnect, a `--resume` and a surface re-created in this process all
|
|
423
|
+
// start empty, and the prose is a system-prompt section rather than a
|
|
424
|
+
// message, so writing it again duplicates nothing in the transcript.
|
|
392
425
|
this.surface.roleProse?.(prose);
|
|
393
426
|
}
|
|
394
427
|
|
|
@@ -818,9 +851,10 @@ export class OnlyneAgent {
|
|
|
818
851
|
const attachmentPaths = Array.isArray(args.attachments) ? args.attachments : [];
|
|
819
852
|
const prose = typeof args.prose === "string" ? args.prose.trim() : "";
|
|
820
853
|
const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
|
|
821
|
-
if (
|
|
854
|
+
if (prose.length > 0) {
|
|
855
|
+
this.latestProse = prose;
|
|
822
856
|
this.deliveredProse.add(prose);
|
|
823
|
-
this.surface.roleProse?.(prose);
|
|
857
|
+
if (proseIsNew) this.surface.roleProse?.(prose);
|
|
824
858
|
}
|
|
825
859
|
|
|
826
860
|
const kind = envelope.kind ?? "task";
|
package/src/agent.test.mjs
CHANGED
|
@@ -1599,3 +1599,64 @@ test("a host without the widget keeps the footer line and the stderr line", asyn
|
|
|
1599
1599
|
assert.ok(logs.some((line) => line.startsWith("in: task 11111111")), logs.join(" | "));
|
|
1600
1600
|
});
|
|
1601
1601
|
|
|
1602
|
+
// The prose is a system-prompt section, not a message: rewriting it in a second
|
|
1603
|
+
// run duplicates nothing, so a reconnect hands it over again rather than
|
|
1604
|
+
// leaving the surface of a resumed session without the role spec.
|
|
1605
|
+
test("a reconnect hands the role prose to the surface again", async () => {
|
|
1606
|
+
const { agent, host, surface } = await startAgent();
|
|
1607
|
+
agent.start();
|
|
1608
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1609
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
|
|
1610
|
+
|
|
1611
|
+
const connections = host.connections;
|
|
1612
|
+
agent.dropSocket();
|
|
1613
|
+
await waitFor(() => (host.connections > connections ? true : null));
|
|
1614
|
+
await waitFor(() => (surface.calls.prose.length === 2 ? true : null));
|
|
1615
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task", "Read the incoming task"]);
|
|
1616
|
+
assert.equal(agent.latestProse, "Read the incoming task");
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
// A surface is replaced while the process keeps its session: the new one holds
|
|
1620
|
+
// no prose of its own, and what the last welcome carried is what fills it.
|
|
1621
|
+
test("a replaced surface is handed the prose the agent remembers", async () => {
|
|
1622
|
+
const { agent, host } = await startAgent();
|
|
1623
|
+
agent.start();
|
|
1624
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1625
|
+
|
|
1626
|
+
const replacement = fakeSurface();
|
|
1627
|
+
agent.surface = replacement;
|
|
1628
|
+
assert.equal(agent.syncRoleProse(), true);
|
|
1629
|
+
assert.deepEqual(replacement.calls.prose, ["Read the incoming task"]);
|
|
1630
|
+
|
|
1631
|
+
// A surface a session switch never filled is still refilled before its first
|
|
1632
|
+
// run, so an agent that remembers nothing yet answers without a throw.
|
|
1633
|
+
const empty = new OnlyneAgent({
|
|
1634
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1635
|
+
cwd: tmpdir(),
|
|
1636
|
+
role: "planner",
|
|
1637
|
+
sessionId: SESSION_ID,
|
|
1638
|
+
taskId: TASK_ID,
|
|
1639
|
+
surface: fakeSurface(),
|
|
1640
|
+
});
|
|
1641
|
+
assert.equal(empty.syncRoleProse(), false);
|
|
1642
|
+
assert.equal(empty.latestProse, "");
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// The mount `index.ts` builds after a `/new` or a resumed session starts over
|
|
1646
|
+
// with a new surface, and the prose it was started with is what the new surface
|
|
1647
|
+
// carries from its first run.
|
|
1648
|
+
test("a mount built over a live session carries the prose into its surface", () => {
|
|
1649
|
+
const surface = fakeSurface();
|
|
1650
|
+
const agent = new OnlyneAgent({
|
|
1651
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1652
|
+
cwd: tmpdir(),
|
|
1653
|
+
role: "planner",
|
|
1654
|
+
sessionId: SESSION_ID,
|
|
1655
|
+
taskId: TASK_ID,
|
|
1656
|
+
surface,
|
|
1657
|
+
prose: "Read the incoming task",
|
|
1658
|
+
});
|
|
1659
|
+
assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
|
|
1660
|
+
assert.equal(agent.latestProse, "Read the incoming task");
|
|
1661
|
+
});
|
|
1662
|
+
|
package/src/config.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
28
28
|
*
|
|
29
29
|
* @param {string} cwd
|
|
30
30
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
31
|
-
* @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
|
|
31
|
+
* @returns {{ enabled: boolean, autoStart: boolean, role: string | null, sessionId: string | null, workspace: string | null, cluster: string | null, resident: boolean, path: string, warning: string | null, present: boolean }}
|
|
32
32
|
*/
|
|
33
33
|
export function loadConfig(cwd, options = {}) {
|
|
34
34
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -42,6 +42,7 @@ export function loadConfig(cwd, options = {}) {
|
|
|
42
42
|
role: null,
|
|
43
43
|
sessionId: null,
|
|
44
44
|
workspace: null,
|
|
45
|
+
cluster: null,
|
|
45
46
|
resident: false,
|
|
46
47
|
path,
|
|
47
48
|
warning: null,
|
|
@@ -57,6 +58,7 @@ export function loadConfig(cwd, options = {}) {
|
|
|
57
58
|
role: null,
|
|
58
59
|
sessionId: null,
|
|
59
60
|
workspace: null,
|
|
61
|
+
cluster: null,
|
|
60
62
|
resident: false,
|
|
61
63
|
path,
|
|
62
64
|
warning: `${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}); using defaults`,
|
|
@@ -69,6 +71,7 @@ export function loadConfig(cwd, options = {}) {
|
|
|
69
71
|
role: null,
|
|
70
72
|
sessionId: null,
|
|
71
73
|
workspace: null,
|
|
74
|
+
cluster: null,
|
|
72
75
|
resident: false,
|
|
73
76
|
path,
|
|
74
77
|
warning: `${path} must hold a JSON object; using defaults`,
|
|
@@ -83,6 +86,7 @@ export function loadConfig(cwd, options = {}) {
|
|
|
83
86
|
role: typeof parsed.role === "string" && parsed.role.trim() !== "" ? parsed.role.trim() : null,
|
|
84
87
|
sessionId: typeof rawSessionId === "string" && rawSessionId.trim() !== "" ? rawSessionId.trim() : null,
|
|
85
88
|
workspace: typeof parsed.workspace === "string" && parsed.workspace.trim() !== "" ? parsed.workspace.trim() : null,
|
|
89
|
+
cluster: typeof parsed.cluster === "string" && parsed.cluster.trim() !== "" ? parsed.cluster.trim() : null,
|
|
86
90
|
resident: typeof parsed.resident === "boolean" ? parsed.resident : false,
|
|
87
91
|
path,
|
|
88
92
|
warning: null,
|
|
@@ -155,7 +159,7 @@ export function parseWorkspaceConfigToml(raw) {
|
|
|
155
159
|
* @param {Record<string, string | undefined>} env
|
|
156
160
|
* @param {string} cwd
|
|
157
161
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
158
|
-
* @returns {{ role: string, sessionId: string | null, taskId: string | null, resident: true, workspaceRoot: string } | null}
|
|
162
|
+
* @returns {{ role: string, sessionId: string | null, taskId: string | null, cluster: string | null, resident: true, workspaceRoot: string } | null}
|
|
159
163
|
*/
|
|
160
164
|
export function residentIdentity(env, cwd, options = {}) {
|
|
161
165
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -241,6 +245,7 @@ export function residentIdentity(env, cwd, options = {}) {
|
|
|
241
245
|
role,
|
|
242
246
|
sessionId: envSessionId ?? config.sessionId ?? toml.sessionId ?? null,
|
|
243
247
|
taskId: envTaskId,
|
|
248
|
+
cluster: config.cluster,
|
|
244
249
|
resident: true,
|
|
245
250
|
workspaceRoot,
|
|
246
251
|
};
|
package/src/config.test.mjs
CHANGED
|
@@ -96,6 +96,7 @@ test("residentIdentity resolves pre-started sessions from env, .pi/onlyne.json,
|
|
|
96
96
|
role: "supervisor",
|
|
97
97
|
sessionId: null,
|
|
98
98
|
taskId: null,
|
|
99
|
+
cluster: null,
|
|
99
100
|
resident: true,
|
|
100
101
|
workspaceRoot: dir,
|
|
101
102
|
});
|
|
@@ -110,6 +111,27 @@ test("residentIdentity resolves pre-started sessions from env, .pi/onlyne.json,
|
|
|
110
111
|
role: "planner",
|
|
111
112
|
sessionId: "planner-main",
|
|
112
113
|
taskId: null,
|
|
114
|
+
cluster: null,
|
|
115
|
+
resident: true,
|
|
116
|
+
workspaceRoot: dir,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the switch file's cluster is trimmed, optional, and reaches residentIdentity", () => {
|
|
121
|
+
const dir = workspace(JSON.stringify({ role: "lazuli", cluster: " workshop " }));
|
|
122
|
+
assert.equal(loadConfig(dir).cluster, "workshop");
|
|
123
|
+
|
|
124
|
+
// Absent, blank and non-string all mean no declaration, never a guess.
|
|
125
|
+
for (const body of [{ role: "lazuli" }, { role: "lazuli", cluster: "" }, { role: "lazuli", cluster: 42 }]) {
|
|
126
|
+
assert.equal(loadConfig(workspace(JSON.stringify(body))).cluster, null);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The identity carries the declaration so the resolver can pass it through.
|
|
130
|
+
assert.deepEqual(residentIdentity({}, dir), {
|
|
131
|
+
role: "lazuli",
|
|
132
|
+
sessionId: null,
|
|
133
|
+
taskId: null,
|
|
134
|
+
cluster: "workshop",
|
|
113
135
|
resident: true,
|
|
114
136
|
workspaceRoot: dir,
|
|
115
137
|
});
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,17 @@
|
|
|
40
40
|
// environment, and a sidechain that called `onlyne_complete` would settle the
|
|
41
41
|
// parent's task. `session-scope.mjs` carries the rule, the guard behind it, and
|
|
42
42
|
// what a call from a foreign session is told.
|
|
43
|
-
|
|
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
|
+
//
|
|
44
54
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
45
55
|
import { Type } from "typebox";
|
|
46
56
|
|
|
@@ -49,7 +59,14 @@ import { ASSIGN_MESSAGE_TYPE, createAssignMessageComponent } from "./activity.mj
|
|
|
49
59
|
import { loadConfig, residentIdentity, sessionIdentity } from "./config.mjs";
|
|
50
60
|
import { resolveResidentSocketPath, resolveSocketPath } from "./socket.mjs";
|
|
51
61
|
import { createSurface } from "./pi-surface.mjs";
|
|
52
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
declaredSubagent,
|
|
64
|
+
foreignSessionRefusal,
|
|
65
|
+
isSessionReplacement,
|
|
66
|
+
mountsSession,
|
|
67
|
+
ownsSession,
|
|
68
|
+
sessionIdOf,
|
|
69
|
+
} from "./session-scope.mjs";
|
|
53
70
|
|
|
54
71
|
/**
|
|
55
72
|
* Node's process globals, declared here so the package needs no `@types/node`
|
|
@@ -90,7 +107,7 @@ interface PiSurface {
|
|
|
90
107
|
};
|
|
91
108
|
wakeUser(text: string, parts?: ImagePartInput[], meta?: unknown): boolean;
|
|
92
109
|
roleProse(text: string): boolean;
|
|
93
|
-
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }): boolean;
|
|
110
|
+
applyRoleProse(event: { systemPromptOptions?: { sections?: Record<string, string> } }, fallback?: string): boolean;
|
|
94
111
|
customEntry(customType: string, data: unknown): boolean;
|
|
95
112
|
widget(lines: string[] | undefined, activity?: unknown): void;
|
|
96
113
|
status(text: string): void;
|
|
@@ -146,6 +163,18 @@ const textResult = (text: string, details?: unknown) => ({
|
|
|
146
163
|
details,
|
|
147
164
|
});
|
|
148
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
|
+
|
|
149
178
|
export default function onlyne(pi: ExtensionAPI) {
|
|
150
179
|
const env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env;
|
|
151
180
|
let context: ExtensionContext | null = null;
|
|
@@ -353,47 +382,91 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
353
382
|
*/
|
|
354
383
|
const syncSessionContext = (_event: unknown, ctx: ExtensionContext) => {
|
|
355
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;
|
|
356
389
|
context = ctx;
|
|
357
390
|
const currentId = sessionIdOf(ctx?.sessionManager);
|
|
358
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;
|
|
359
399
|
boundSessionId = currentId;
|
|
360
400
|
}
|
|
361
401
|
if (surface?.available.widget) {
|
|
362
402
|
surface.widget(agent.activity.lines(), agent.activity);
|
|
363
403
|
}
|
|
364
404
|
surface?.status(agent.activity.lines()[0]);
|
|
365
|
-
|
|
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
|
+
}
|
|
366
410
|
|
|
367
411
|
pi.on("session_start", async (event, ctx) => {
|
|
368
412
|
const spawned = sessionIdentity(env);
|
|
369
413
|
const resident = spawned ? null : residentIdentity(env, ctx.cwd);
|
|
370
414
|
const identity = spawned ?? resident;
|
|
371
415
|
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
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
|
+
}
|
|
380
430
|
// A resident plugin already holds its connection, so another session_start
|
|
381
431
|
// in this process is a re-point, not a mount: /new, /resume and /fork
|
|
382
432
|
// replace the live session, and pi can re-bind this same session's
|
|
383
|
-
// extensions.
|
|
384
|
-
// with reason "startup" and a different id, and
|
|
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.
|
|
385
435
|
if (!spawned && agent !== null && (event.reason !== "startup" || starting === boundSessionId)) {
|
|
386
436
|
syncSessionContext(event, ctx);
|
|
387
437
|
return;
|
|
388
438
|
}
|
|
439
|
+
// Only the session the client names may mount. A pi process runs more than
|
|
440
|
+
// one session — a `task` subagent's sidechain, `/new` — and each of them
|
|
441
|
+
// loads this extension with the same inherited environment, so without this
|
|
442
|
+
// check a subagent's `onlyne_complete` settles the parent's task (observed:
|
|
443
|
+
// librarian task cfa73386). An unknown id means an older pi that does not
|
|
444
|
+
// expose one; the env-bound mount is then the only candidate and stays the
|
|
445
|
+
// behaviour, with no line to explain.
|
|
389
446
|
if (spawned) {
|
|
390
447
|
if (!mountsSession(spawned.sessionId, starting)) {
|
|
391
448
|
log(`session ${starting} is not the onlyne session ${spawned.sessionId}; staying inert`);
|
|
392
449
|
return;
|
|
393
450
|
}
|
|
394
|
-
} else
|
|
395
|
-
|
|
396
|
-
|
|
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;
|
|
397
470
|
}
|
|
398
471
|
const wireSessionId = identity.sessionId ?? starting;
|
|
399
472
|
context = ctx;
|
|
@@ -410,19 +483,24 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
410
483
|
// stays a plain pi session instead of retrying a path nothing serves.
|
|
411
484
|
let socketPath: string | null = null;
|
|
412
485
|
const targetWorkspace = resident ? resident.workspaceRoot : ctx.cwd;
|
|
486
|
+
const declaredCluster = resident ? resident.cluster : null;
|
|
413
487
|
try {
|
|
414
488
|
socketPath = resident
|
|
415
|
-
? resolveResidentSocketPath(env, targetWorkspace, identity.role)
|
|
489
|
+
? resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
|
|
416
490
|
: resolveSocketPath(env, ctx.cwd);
|
|
417
491
|
} catch (error) {
|
|
418
492
|
log(`socket unresolved: ${error instanceof Error ? error.message : String(error)}`);
|
|
419
493
|
}
|
|
420
494
|
if (socketPath === null) return;
|
|
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 ?? "";
|
|
421
499
|
surface = createSurface({ pi, log, context: () => context, sessionId: () => boundSessionId });
|
|
422
500
|
agent = new OnlyneAgent({
|
|
423
501
|
socketPath,
|
|
424
502
|
resolveSocket: resident
|
|
425
|
-
? () => resolveResidentSocketPath(env, targetWorkspace, identity.role)
|
|
503
|
+
? () => resolveResidentSocketPath(env, targetWorkspace, identity.role, { cluster: declaredCluster })
|
|
426
504
|
: undefined,
|
|
427
505
|
cwd: ctx.cwd,
|
|
428
506
|
role: identity.role,
|
|
@@ -431,6 +509,7 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
431
509
|
resident: Boolean(resident),
|
|
432
510
|
surface,
|
|
433
511
|
log,
|
|
512
|
+
prose: carriedProse,
|
|
434
513
|
});
|
|
435
514
|
log(`session ${wireSessionId ?? "resident"} role=${identity.role} socket=${socketPath}`);
|
|
436
515
|
registerSurface();
|
|
@@ -461,7 +540,10 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
461
540
|
// to read as an utterance. Outside an onlyne session there is no surface and
|
|
462
541
|
// nothing to add.
|
|
463
542
|
pi.on("before_agent_start", async (event) => {
|
|
464
|
-
surface
|
|
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 ?? "");
|
|
465
547
|
});
|
|
466
548
|
|
|
467
549
|
pi.on("turn_start", async () => {
|
|
@@ -491,6 +573,10 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
491
573
|
agent?.stop(`pi:${event.reason ?? "quit"}`);
|
|
492
574
|
surface?.closeBackground?.();
|
|
493
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;
|
|
494
580
|
agent = null;
|
|
495
581
|
surface = null;
|
|
496
582
|
registered = false;
|
package/src/pi-surface.mjs
CHANGED
|
@@ -241,7 +241,9 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
241
241
|
let warnedNoSections = false;
|
|
242
242
|
|
|
243
243
|
/**
|
|
244
|
-
*
|
|
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.
|
|
245
247
|
* @param {string} text
|
|
246
248
|
* @returns {boolean} whether there is prose to carry
|
|
247
249
|
*/
|
|
@@ -259,10 +261,17 @@ export function createSurface({ pi, log, context, sessionId = null }) {
|
|
|
259
261
|
* pi's own examples does this). pi rebuilds those options for every run, so the
|
|
260
262
|
* section is written again rather than once — the write is idempotent, and the
|
|
261
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.
|
|
262
269
|
* @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
|
|
270
|
+
* @param {string} [fallback]
|
|
263
271
|
* @returns {boolean} whether a section was written
|
|
264
272
|
*/
|
|
265
|
-
const applyRoleProse = (event) => {
|
|
273
|
+
const applyRoleProse = (event, fallback = "") => {
|
|
274
|
+
if (roleProseText.length === 0 && typeof fallback === "string") roleProseText = fallback;
|
|
266
275
|
if (roleProseText.length === 0) return false;
|
|
267
276
|
const sections = event?.systemPromptOptions?.sections;
|
|
268
277
|
if (!sections) {
|
package/src/pi-surface.test.mjs
CHANGED
|
@@ -260,6 +260,30 @@ test("a pi whose prompt options carry no sections reports it once and carries no
|
|
|
260
260
|
assert.match(lines[0], /no instruction layer/);
|
|
261
261
|
});
|
|
262
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
|
+
|
|
263
287
|
/**
|
|
264
288
|
* A surface over a fake pi that answers the two questions the phase is read
|
|
265
289
|
* from, plus the cases where pi answers neither.
|