@selesai/code 0.13.38 → 0.13.40
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/CHANGELOG.md +7 -0
- package/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-graft/cli.test.ts +5 -5
- package/dist/extensions/pi-graft/cli.ts +10 -5
- package/dist/extensions/pi-graft/commands.test.ts +5 -5
- package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
- package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
- package/dist/extensions/pi-graft/prompt.test.ts +1 -1
- package/dist/extensions/pi-graft/state.test.ts +9 -9
- package/dist/extensions/pi-graft/test-support.ts +1 -1
- package/dist/extensions/pi-graft/tools.test.ts +1 -1
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +142 -3
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { RpcClient } from "./rpc-client.js";
|
|
6
|
+
// Run after npm run build. Real bundled extensions and JSONL host, no model calls.
|
|
7
|
+
const rawSend = (client, command) => client.send(command);
|
|
8
|
+
const hostEnv = (dir, extensions) => ({
|
|
9
|
+
SELESAI_CODING_AGENT_DIR: dir,
|
|
10
|
+
RPC_SUBAGENT_EXTENSION: extensions ? resolve("dist/extensions/pi-subagents/index.ts") : "",
|
|
11
|
+
RPC_INTERCOM_EXTENSION: extensions ? resolve("dist/extensions/pi-intercom/index.ts") : "",
|
|
12
|
+
PI_INTERCOM_STABLE_ID: "", SELESAI_SUBAGENT_CHILD: "", SELESAI_SUBAGENT_RUN_ID: "", SELESAI_SUBAGENT_ORCHESTRATOR_TARGET: "",
|
|
13
|
+
SELESAI_SUBAGENT_ORCHESTRATOR_SESSION_ID: "", SELESAI_SUBAGENT_PARENT_SESSION: "",
|
|
14
|
+
});
|
|
15
|
+
const host = (dir, extensions) => new RpcClient({ cliPath: resolve("dist/modes/rpc/test-fixtures/intercom-host.js"), cwd: dir, env: hostEnv(dir, extensions) });
|
|
16
|
+
/** Resolves with the host's exit code/signal; attach BEFORE sending the command that should end the process. */
|
|
17
|
+
const exitOf = (client) => {
|
|
18
|
+
const child = client.process;
|
|
19
|
+
return new Promise(done => child.once("exit", (code, signal) => done({ code, signal })));
|
|
20
|
+
};
|
|
21
|
+
const stillRunning = (client) => client.process.exitCode === null;
|
|
22
|
+
const within = (promise, ms, what) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), ms))]);
|
|
23
|
+
describe("release JSONL transport (atomic release)", () => {
|
|
24
|
+
it("releases an idle session with the extensions loaded: released:true, then a gentle exit 0", async () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-go-"));
|
|
26
|
+
const client = host(dir, true);
|
|
27
|
+
try {
|
|
28
|
+
await client.start();
|
|
29
|
+
const exited = exitOf(client);
|
|
30
|
+
const reply = await rawSend(client, { type: "release", version: 1 });
|
|
31
|
+
expect(reply).toMatchObject({ type: "response", command: "release", success: true, data: { released: true, readiness: { version: 1, safe: true, blockers: [] } } });
|
|
32
|
+
const data = reply.data;
|
|
33
|
+
expect(data.readiness.contributors).toEqual(expect.arrayContaining(["subagents", "intercom", "scheduler"]));
|
|
34
|
+
expect(await within(exited, 15_000, "host exit")).toEqual({ code: 0, signal: null });
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await client.stop();
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
it("RpcClient.release() returns the typed result", async () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-client-"));
|
|
43
|
+
const client = host(dir, true);
|
|
44
|
+
try {
|
|
45
|
+
await client.start();
|
|
46
|
+
const exited = exitOf(client);
|
|
47
|
+
const result = await client.release();
|
|
48
|
+
expect(result.released).toBe(true);
|
|
49
|
+
expect(result.readiness).toMatchObject({ safe: true, blockers: [] });
|
|
50
|
+
expect(await within(exited, 15_000, "host exit")).toEqual({ code: 0, signal: null });
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await client.stop();
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it("refuses commands that follow a committed release, and keeps answering the release probes", async () => {
|
|
58
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-refuse-"));
|
|
59
|
+
const client = host(dir, true);
|
|
60
|
+
try {
|
|
61
|
+
await client.start();
|
|
62
|
+
const exited = exitOf(client);
|
|
63
|
+
// Sent back to back: the host reads both lines in order, the release commits synchronously before the next line is looked at.
|
|
64
|
+
const [released, refused, probe, again, bad] = await Promise.all([
|
|
65
|
+
rawSend(client, { type: "release", version: 1 }),
|
|
66
|
+
rawSend(client, { type: "get_state" }),
|
|
67
|
+
rawSend(client, { type: "release_readiness", version: 1 }),
|
|
68
|
+
rawSend(client, { type: "release", version: 1 }),
|
|
69
|
+
rawSend(client, { type: "release", version: 2 }),
|
|
70
|
+
]);
|
|
71
|
+
expect(released).toMatchObject({ command: "release", success: true, data: { released: true } });
|
|
72
|
+
expect(refused).toMatchObject({ type: "response", command: "get_state", success: false, error: "Session is releasing" });
|
|
73
|
+
expect(probe).toMatchObject({ command: "release_readiness", success: true, data: { safe: false, blockers: [{ source: "core", kind: "releasing" }] } });
|
|
74
|
+
expect(again).toMatchObject({ command: "release", success: true, data: { released: true } });
|
|
75
|
+
expect(bad).toMatchObject({ command: "release", success: false, error: expect.stringContaining("version") });
|
|
76
|
+
expect(await within(exited, 15_000, "host exit")).toEqual({ code: 0, signal: null });
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await client.stop();
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it("does not release when nobody answered (extensions absent): released:false and the host keeps running", async () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-nobody-"));
|
|
85
|
+
const client = host(dir, false);
|
|
86
|
+
try {
|
|
87
|
+
await client.start();
|
|
88
|
+
const result = await client.release();
|
|
89
|
+
expect(result).toMatchObject({ released: false, readiness: { version: 1, safe: true, blockers: [], contributors: [] } });
|
|
90
|
+
await new Promise(done => setTimeout(done, 500));
|
|
91
|
+
expect(stillRunning(client)).toBe(true);
|
|
92
|
+
expect(await client.getState()).toMatchObject({ isStreaming: false });
|
|
93
|
+
expect(await client.releaseReadiness()).toMatchObject({ safe: true, blockers: [] });
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await client.stop();
|
|
97
|
+
rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it("does not release while a core blocker exists, then releases once it clears", async () => {
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-blocked-"));
|
|
102
|
+
const client = host(dir, true);
|
|
103
|
+
try {
|
|
104
|
+
await client.start();
|
|
105
|
+
const running = client.bash("sleep 2");
|
|
106
|
+
// `bash` registers asynchronously; probe (read-only) until the blocker is visible so `release` cannot fire early.
|
|
107
|
+
let probe = await client.releaseReadiness();
|
|
108
|
+
for (let attempt = 0; attempt < 40 && probe.safe; attempt++) {
|
|
109
|
+
await new Promise(done => setTimeout(done, 50));
|
|
110
|
+
probe = await client.releaseReadiness();
|
|
111
|
+
}
|
|
112
|
+
expect(probe.safe).toBe(false);
|
|
113
|
+
const blocked = await client.release();
|
|
114
|
+
expect(blocked.released).toBe(false);
|
|
115
|
+
expect(blocked.readiness.safe).toBe(false);
|
|
116
|
+
expect(blocked.readiness.blockers).toEqual(expect.arrayContaining([expect.objectContaining({ source: "core", kind: "pending_bash" })]));
|
|
117
|
+
expect(stillRunning(client)).toBe(true);
|
|
118
|
+
await running;
|
|
119
|
+
const exited = exitOf(client);
|
|
120
|
+
const released = await client.release();
|
|
121
|
+
expect(released).toMatchObject({ released: true, readiness: { safe: true, blockers: [] } });
|
|
122
|
+
expect(await within(exited, 15_000, "host exit")).toEqual({ code: 0, signal: null });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await client.stop();
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it("rejects malformed release commands and keeps running", async () => {
|
|
130
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-malformed-"));
|
|
131
|
+
const client = host(dir, true);
|
|
132
|
+
try {
|
|
133
|
+
await client.start();
|
|
134
|
+
expect(await rawSend(client, { type: "release", version: 2 })).toMatchObject({ command: "release", success: false, error: expect.stringContaining("version") });
|
|
135
|
+
expect(await rawSend(client, { type: "release" })).toMatchObject({ command: "release", success: false });
|
|
136
|
+
expect(await rawSend(client, { type: "release", version: 1, method: "spawn" })).toMatchObject({ command: "release", success: false, error: expect.stringContaining("Malformed") });
|
|
137
|
+
expect(stillRunning(client)).toBe(true);
|
|
138
|
+
expect(await client.releaseReadiness()).toMatchObject({ safe: true, blockers: [] });
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await client.stop();
|
|
142
|
+
rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("release_readiness JSONL transport", () => {
|
|
147
|
+
it("is safe and lists every contributor for an idle session with the extensions loaded", async () => {
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-live-"));
|
|
149
|
+
const client = host(dir, true);
|
|
150
|
+
try {
|
|
151
|
+
await client.start();
|
|
152
|
+
const readiness = await client.releaseReadiness();
|
|
153
|
+
expect(readiness).toMatchObject({ version: 1, safe: true, blockers: [] });
|
|
154
|
+
expect(readiness.contributors).toEqual(expect.arrayContaining(["subagents", "intercom", "scheduler"]));
|
|
155
|
+
expect((await client.getState()).isRetrying).toBe(false);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await client.stop();
|
|
159
|
+
rmSync(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
it("reports safe with no contributors when the extensions are absent", async () => {
|
|
163
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-missing-"));
|
|
164
|
+
const client = host(dir, false);
|
|
165
|
+
try {
|
|
166
|
+
await client.start();
|
|
167
|
+
expect(await client.releaseReadiness()).toMatchObject({ version: 1, safe: true, blockers: [], contributors: [] });
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await client.stop();
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
it("rejects malformed commands", async () => {
|
|
175
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-bad-"));
|
|
176
|
+
const client = host(dir, false);
|
|
177
|
+
try {
|
|
178
|
+
await client.start();
|
|
179
|
+
expect(await rawSend(client, { type: "release_readiness", version: 2 })).toMatchObject({ command: "release_readiness", success: false, error: expect.stringContaining("version") });
|
|
180
|
+
expect(await rawSend(client, { type: "release_readiness" })).toMatchObject({ success: false });
|
|
181
|
+
expect(await rawSend(client, { type: "release_readiness", version: 1, method: "spawn" })).toMatchObject({ success: false, error: expect.stringContaining("Malformed") });
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await client.stop();
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
it("is scoped to the session binding and rotates its epoch on new_session", async () => {
|
|
189
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-scope-"));
|
|
190
|
+
const client = host(dir, true);
|
|
191
|
+
try {
|
|
192
|
+
await client.start();
|
|
193
|
+
const state = await client.getState();
|
|
194
|
+
const first = await client.releaseReadiness();
|
|
195
|
+
expect(first.sessionId).toBe(state.sessionId);
|
|
196
|
+
await client.newSession();
|
|
197
|
+
const next = await client.releaseReadiness();
|
|
198
|
+
const nextState = await client.getState();
|
|
199
|
+
expect(next.sessionId).toBe(nextState.sessionId);
|
|
200
|
+
expect(next.sessionId).not.toBe(first.sessionId);
|
|
201
|
+
expect(next.epoch).not.toBe(first.epoch);
|
|
202
|
+
expect(next).toMatchObject({ safe: true, blockers: [] });
|
|
203
|
+
expect(next.contributors).toEqual(expect.arrayContaining(["subagents", "intercom", "scheduler"]));
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await client.stop();
|
|
207
|
+
rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
it("blocks while a bash command is running and clears once it finishes", async () => {
|
|
211
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-release-bash-"));
|
|
212
|
+
const client = host(dir, false);
|
|
213
|
+
try {
|
|
214
|
+
await client.start();
|
|
215
|
+
const running = client.bash("sleep 2");
|
|
216
|
+
let blocked = await client.releaseReadiness();
|
|
217
|
+
for (let attempt = 0; attempt < 20 && blocked.safe; attempt++) {
|
|
218
|
+
await new Promise(done => setTimeout(done, 50));
|
|
219
|
+
blocked = await client.releaseReadiness();
|
|
220
|
+
}
|
|
221
|
+
expect(blocked.safe).toBe(false);
|
|
222
|
+
expect(blocked.blockers).toEqual(expect.arrayContaining([expect.objectContaining({ source: "core", kind: "pending_bash" })]));
|
|
223
|
+
await running;
|
|
224
|
+
const idle = await client.releaseReadiness();
|
|
225
|
+
expect(idle).toMatchObject({ safe: true, blockers: [] });
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await client.stop();
|
|
229
|
+
rmSync(dir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
describe("stop_all_background JSONL transport (kill switch)", () => {
|
|
234
|
+
it("answers on an idle session with an empty stopped list and the post-stop readiness; repeating it is a no-op", async () => {
|
|
235
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-stopall-idle-"));
|
|
236
|
+
const client = host(dir, true);
|
|
237
|
+
try {
|
|
238
|
+
await client.start();
|
|
239
|
+
const state = await client.getState();
|
|
240
|
+
const reply = await rawSend(client, { type: "stop_all_background", version: 1 });
|
|
241
|
+
expect(reply).toMatchObject({
|
|
242
|
+
type: "response", command: "stop_all_background", success: true,
|
|
243
|
+
data: { version: 1, sessionId: state.sessionId, stopped: [], remaining: { version: 1, safe: true, blockers: [] } },
|
|
244
|
+
});
|
|
245
|
+
const data = reply.data;
|
|
246
|
+
expect(data.remaining.contributors).toEqual(expect.arrayContaining(["subagents", "intercom", "scheduler"]));
|
|
247
|
+
const again = await client.stopAllBackground();
|
|
248
|
+
expect(again).toMatchObject({ sessionId: state.sessionId, stopped: [], remaining: { safe: true, blockers: [] } });
|
|
249
|
+
expect(stillRunning(client)).toBe(true);
|
|
250
|
+
expect(await client.getState()).toMatchObject({ isStreaming: false });
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await client.stop();
|
|
254
|
+
rmSync(dir, { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
it("also answers when no extension is loaded (core only)", async () => {
|
|
258
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-stopall-core-"));
|
|
259
|
+
const client = host(dir, false);
|
|
260
|
+
try {
|
|
261
|
+
await client.start();
|
|
262
|
+
expect(await client.stopAllBackground()).toMatchObject({ stopped: [], remaining: { safe: true, blockers: [], contributors: [] } });
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await client.stop();
|
|
266
|
+
rmSync(dir, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
it("aborts a running bash command, reports it, and leaves nothing pending", async () => {
|
|
270
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-stopall-bash-"));
|
|
271
|
+
const client = host(dir, true);
|
|
272
|
+
try {
|
|
273
|
+
await client.start();
|
|
274
|
+
const running = client.bash("sleep 30");
|
|
275
|
+
let probe = await client.releaseReadiness();
|
|
276
|
+
for (let attempt = 0; attempt < 40 && probe.safe; attempt++) {
|
|
277
|
+
await new Promise(done => setTimeout(done, 50));
|
|
278
|
+
probe = await client.releaseReadiness();
|
|
279
|
+
}
|
|
280
|
+
expect(probe.safe).toBe(false);
|
|
281
|
+
const startedAt = Date.now();
|
|
282
|
+
const result = await client.stopAllBackground();
|
|
283
|
+
expect(result.stopped).toEqual([expect.objectContaining({ source: "core", kind: "bash", ok: true })]);
|
|
284
|
+
await within(running, 10_000, "aborted bash to return");
|
|
285
|
+
expect(Date.now() - startedAt).toBeLessThan(10_000);
|
|
286
|
+
expect(await client.releaseReadiness()).toMatchObject({ safe: true, blockers: [] });
|
|
287
|
+
expect(await client.stopAllBackground()).toMatchObject({ stopped: [] });
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await client.stop();
|
|
291
|
+
rmSync(dir, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
it("rejects malformed requests with success:false and keeps running", async () => {
|
|
295
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-stopall-bad-"));
|
|
296
|
+
const client = host(dir, true);
|
|
297
|
+
try {
|
|
298
|
+
await client.start();
|
|
299
|
+
expect(await rawSend(client, { type: "stop_all_background", version: 2 })).toMatchObject({ command: "stop_all_background", success: false, error: expect.stringContaining("version") });
|
|
300
|
+
expect(await rawSend(client, { type: "stop_all_background" })).toMatchObject({ command: "stop_all_background", success: false });
|
|
301
|
+
expect(await rawSend(client, { type: "stop_all_background", version: 1, method: "spawn" })).toMatchObject({ success: false, error: "Malformed stop_all_background command" });
|
|
302
|
+
expect(stillRunning(client)).toBe(true);
|
|
303
|
+
expect(await client.releaseReadiness()).toMatchObject({ safe: true, blockers: [] });
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
await client.stop();
|
|
307
|
+
rmSync(dir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
it("is refused with \"Session is releasing\" once a release is committed", async () => {
|
|
311
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-stopall-releasing-"));
|
|
312
|
+
const client = host(dir, true);
|
|
313
|
+
try {
|
|
314
|
+
await client.start();
|
|
315
|
+
const exited = exitOf(client);
|
|
316
|
+
const [released, refused] = await Promise.all([
|
|
317
|
+
rawSend(client, { type: "release", version: 1 }),
|
|
318
|
+
rawSend(client, { type: "stop_all_background", version: 1 }),
|
|
319
|
+
]);
|
|
320
|
+
expect(released).toMatchObject({ command: "release", success: true, data: { released: true } });
|
|
321
|
+
expect(refused).toMatchObject({ type: "response", command: "stop_all_background", success: false, error: "Session is releasing" });
|
|
322
|
+
expect(await within(exited, 15_000, "host exit")).toEqual({ code: 0, signal: null });
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
await client.stop();
|
|
326
|
+
rmSync(dir, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { RpcClient } from "./rpc-client.js";
|
|
6
|
+
// Run after npm run build. Real bundled extension and JSONL host, no model calls.
|
|
7
|
+
describe("Subagent RPC JSONL transport", () => {
|
|
8
|
+
it("reports an explicit unavailable snapshot when extension is absent", async () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-observe-missing-"));
|
|
10
|
+
const client = new RpcClient({ cliPath: resolve("dist/modes/rpc/test-fixtures/intercom-host.js"), cwd: dir,
|
|
11
|
+
env: { SELESAI_CODING_AGENT_DIR: dir, RPC_INTERCOM_EXTENSION: "", RPC_SUBAGENT_EXTENSION: "" } });
|
|
12
|
+
try {
|
|
13
|
+
await client.start();
|
|
14
|
+
expect(await client.subagentStatus()).toMatchObject({ version: 1, available: false, capabilities: { snapshot: true, events: false }, runs: [], omitted: { runs: 0, children: 0 } });
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
await client.stop();
|
|
18
|
+
rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it("binds real native extension snapshots and scopes both streams across switch/reload", async () => {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "rpc-observe-live-"));
|
|
23
|
+
const client = new RpcClient({ cliPath: resolve("dist/modes/rpc/test-fixtures/intercom-host.js"), cwd: dir,
|
|
24
|
+
env: { SELESAI_CODING_AGENT_DIR: dir, RPC_SUBAGENT_EXTENSION: resolve("dist/extensions/pi-subagents/index.ts"),
|
|
25
|
+
RPC_INTERCOM_EXTENSION: resolve("dist/extensions/pi-intercom/index.ts"), PI_INTERCOM_STABLE_ID: "", SELESAI_SUBAGENT_CHILD: "",
|
|
26
|
+
SELESAI_SUBAGENT_RUN_ID: "", SELESAI_SUBAGENT_ORCHESTRATOR_TARGET: "", SELESAI_SUBAGENT_ORCHESTRATOR_SESSION_ID: "", SELESAI_SUBAGENT_PARENT_SESSION: "" } });
|
|
27
|
+
const events = [];
|
|
28
|
+
const off = client.onEvent(event => events.push(event));
|
|
29
|
+
try {
|
|
30
|
+
await client.start();
|
|
31
|
+
const first = await client.subagentStatus();
|
|
32
|
+
expect(first.available).toBe(true);
|
|
33
|
+
expect(first.capabilities).toEqual({ snapshot: true, events: true });
|
|
34
|
+
expect(first.runs).toEqual([]);
|
|
35
|
+
const firstIntercom = await client.intercomStatus();
|
|
36
|
+
expect(firstIntercom.scope).toEqual({ version: 1, sessionId: first.sessionId, epoch: first.epoch });
|
|
37
|
+
expect(events.some(event => event.type === "subagent_event" && event.snapshot.epoch === first.epoch)).toBe(true);
|
|
38
|
+
await client.newSession();
|
|
39
|
+
const next = await client.subagentStatus();
|
|
40
|
+
expect(next.sessionId).not.toBe(first.sessionId);
|
|
41
|
+
expect(next.epoch).not.toBe(first.epoch);
|
|
42
|
+
await client.reload();
|
|
43
|
+
const reloaded = await client.subagentStatus();
|
|
44
|
+
expect(reloaded.available).toBe(true);
|
|
45
|
+
expect(reloaded.sessionId).toBe(next.sessionId);
|
|
46
|
+
expect(reloaded.epoch).not.toBe(next.epoch);
|
|
47
|
+
expect((await client.intercomStatus()).scope.epoch).toBe(reloaded.epoch);
|
|
48
|
+
const initial = events.filter(event => event.type === "subagent_event" && event.revision === 1);
|
|
49
|
+
const epochs = new Set(initial.map(event => event.type === "subagent_event" ? event.epoch : ""));
|
|
50
|
+
expect(epochs.has(first.epoch) && epochs.has(next.epoch) && epochs.has(reloaded.epoch)).toBe(true);
|
|
51
|
+
expect(events.some(event => event.type === "agent_start")).toBe(false);
|
|
52
|
+
expect(events.filter(event => event.type.startsWith("intercom_")).every(event => "scope" in event && event.scope.version === 1)).toBe(true);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
off();
|
|
56
|
+
await client.stop();
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import type { SubagentRpcCommand, SubagentRpcEvent, SubagentRpcResponse } from "../../core/subagent-rpc.ts";
|
|
2
|
+
export type { SubagentRpcCommand, SubagentRpcEvent, SubagentRpcResponse, SubagentRpcSnapshot, SubagentRpcNode, SubagentRpcFleet, SubagentRpcTokens, SubagentRpcLifecycle, RpcObservationScope } from "../../core/subagent-rpc.ts";
|
|
3
|
+
import type { ReleaseCommand, ReleaseReadinessCommand, ReleaseReadinessResponse, ReleaseResponse, StopAllBackgroundCommand, StopAllBackgroundResponse } from "../../core/release-rpc.ts";
|
|
4
|
+
export type { ReleaseCommand, ReleaseResponse, ReleaseResult, ReleaseReadinessCommand, ReleaseReadinessResponse, ReleaseReadiness, ReleaseBlocker, ReleaseBlockerSource, StopAllBackgroundCommand, StopAllBackgroundResponse, StopAllBackgroundResult, StopAllBackgroundItem } from "../../core/release-rpc.ts";
|
|
1
5
|
/**
|
|
2
6
|
* RPC protocol types for headless operation.
|
|
3
7
|
*
|
|
4
8
|
* Commands are sent as JSON lines on stdin.
|
|
5
9
|
* Responses and events are emitted as JSON lines on stdout.
|
|
6
10
|
*/
|
|
11
|
+
import type { IntercomRpcCommand, IntercomRpcResponse, IntercomRpcEvent } from "../../core/intercom-rpc.ts";
|
|
12
|
+
import type { AgentSessionEvent } from "../../core/agent-session.ts";
|
|
13
|
+
export type { IntercomRpcCommand, IntercomRpcResponse, IntercomRpcEvent, IntercomRpcStatus, IntercomRpcDelivery, IntercomRpcMessageOptions, IntercomRpcDataMap } from "../../core/intercom-rpc.ts";
|
|
14
|
+
export type RpcEvent = AgentSessionEvent | IntercomRpcEvent | SubagentRpcEvent | RpcExtensionUIRequest;
|
|
7
15
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
8
16
|
import type { ImageContent, Model } from "@earendil-works/pi-ai";
|
|
9
17
|
import type { SessionStats } from "../../core/agent-session.ts";
|
|
@@ -14,7 +22,7 @@ import type { CompactionResult } from "../../core/compaction/index.ts";
|
|
|
14
22
|
import type { BranchSummaryEntry, SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
|
|
15
23
|
import type { SourceInfo } from "../../core/source-info.ts";
|
|
16
24
|
import type { ChangelogEntry } from "../../utils/changelog.ts";
|
|
17
|
-
export type RpcCommand = {
|
|
25
|
+
export type RpcCommand = IntercomRpcCommand | SubagentRpcCommand | ReleaseReadinessCommand | ReleaseCommand | StopAllBackgroundCommand | {
|
|
18
26
|
id?: string;
|
|
19
27
|
type: "prompt";
|
|
20
28
|
message: string;
|
|
@@ -304,6 +312,8 @@ export interface RpcSessionState {
|
|
|
304
312
|
thinkingLevel: ThinkingLevel;
|
|
305
313
|
isStreaming: boolean;
|
|
306
314
|
isCompacting: boolean;
|
|
315
|
+
/** True while an auto-retry is in flight (including the backoff sleep between attempts). */
|
|
316
|
+
isRetrying: boolean;
|
|
307
317
|
steeringMode: "all" | "one-at-a-time";
|
|
308
318
|
followUpMode: "all" | "one-at-a-time";
|
|
309
319
|
sessionFile?: string;
|
|
@@ -326,7 +336,7 @@ export interface WorkflowState {
|
|
|
326
336
|
step: number;
|
|
327
337
|
done: boolean;
|
|
328
338
|
}
|
|
329
|
-
export type RpcResponse = {
|
|
339
|
+
export type RpcResponse = IntercomRpcResponse | SubagentRpcResponse | ReleaseReadinessResponse | ReleaseResponse | StopAllBackgroundResponse | {
|
|
330
340
|
id?: string;
|
|
331
341
|
type: "response";
|
|
332
342
|
command: "prompt";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EventBus } from "../../core/event-bus.ts";
|
|
2
|
+
import { type RpcObservationScope, type SubagentRpcCommand, type SubagentRpcEvent } from "../../core/subagent-rpc.ts";
|
|
3
|
+
import type { RpcResponse } from "./rpc-types.ts";
|
|
4
|
+
/** One native source per host binding. Subscription callbacks cannot cross generations. */
|
|
5
|
+
export declare class RpcSubagentBridge {
|
|
6
|
+
private disposed;
|
|
7
|
+
private unsubscribe;
|
|
8
|
+
private source;
|
|
9
|
+
private revision;
|
|
10
|
+
private fingerprint;
|
|
11
|
+
private value;
|
|
12
|
+
private bus;
|
|
13
|
+
private output;
|
|
14
|
+
private scope;
|
|
15
|
+
private sourceError;
|
|
16
|
+
constructor(bus: EventBus | undefined, output: (event: SubagentRpcEvent) => void, scope: RpcObservationScope);
|
|
17
|
+
private missing;
|
|
18
|
+
/** Call after extension binding, including startup, session switch and reload. */
|
|
19
|
+
initialize(): void;
|
|
20
|
+
private update;
|
|
21
|
+
request(command: SubagentRpcCommand): RpcResponse;
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { SUBAGENT_OBSERVE_EVENT, isSubagentRpcProjection, } from "../../core/subagent-rpc.js";
|
|
2
|
+
/** One native source per host binding. Subscription callbacks cannot cross generations. */
|
|
3
|
+
export class RpcSubagentBridge {
|
|
4
|
+
disposed = false;
|
|
5
|
+
unsubscribe;
|
|
6
|
+
source;
|
|
7
|
+
revision = 0;
|
|
8
|
+
fingerprint = "";
|
|
9
|
+
value;
|
|
10
|
+
bus;
|
|
11
|
+
output;
|
|
12
|
+
scope;
|
|
13
|
+
sourceError;
|
|
14
|
+
constructor(bus, output, scope) {
|
|
15
|
+
this.bus = bus;
|
|
16
|
+
this.output = output;
|
|
17
|
+
this.scope = scope;
|
|
18
|
+
this.value = this.missing();
|
|
19
|
+
}
|
|
20
|
+
missing() {
|
|
21
|
+
return { ...this.scope, revision: this.revision, available: false, capabilities: { snapshot: true, events: false },
|
|
22
|
+
fleet: { version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0 }, runs: [], omitted: { runs: 0, children: 0 } };
|
|
23
|
+
}
|
|
24
|
+
/** Call after extension binding, including startup, session switch and reload. */
|
|
25
|
+
initialize() {
|
|
26
|
+
if (this.disposed)
|
|
27
|
+
return;
|
|
28
|
+
const request = { version: 1, sessionId: this.scope.sessionId, accept: source => {
|
|
29
|
+
if (!this.source && source && typeof source.snapshot === "function" && typeof source.subscribe === "function")
|
|
30
|
+
this.source = source;
|
|
31
|
+
} };
|
|
32
|
+
this.bus?.emit(SUBAGENT_OBSERVE_EVENT, request);
|
|
33
|
+
if (this.source) {
|
|
34
|
+
try {
|
|
35
|
+
this.unsubscribe = this.source.subscribe(projection => {
|
|
36
|
+
if (this.disposed)
|
|
37
|
+
return;
|
|
38
|
+
if (projection === null)
|
|
39
|
+
this.source = undefined;
|
|
40
|
+
try {
|
|
41
|
+
this.update(projection);
|
|
42
|
+
this.sourceError = undefined;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.sourceError = error.message;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
this.update(this.source.snapshot(), true);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.sourceError = error.message;
|
|
52
|
+
this.update(null, true);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
this.update(null, true);
|
|
57
|
+
}
|
|
58
|
+
update(projection, force = false) {
|
|
59
|
+
if (projection !== null && !isSubagentRpcProjection(projection))
|
|
60
|
+
throw new Error("Malformed subagent observation projection");
|
|
61
|
+
const fingerprint = JSON.stringify(projection);
|
|
62
|
+
if (!force && fingerprint === this.fingerprint)
|
|
63
|
+
return;
|
|
64
|
+
this.fingerprint = fingerprint;
|
|
65
|
+
this.revision++;
|
|
66
|
+
this.value = projection === null ? this.missing() : {
|
|
67
|
+
...this.scope, revision: this.revision, available: true, capabilities: { snapshot: true, events: true },
|
|
68
|
+
...JSON.parse(fingerprint),
|
|
69
|
+
};
|
|
70
|
+
this.output({ type: "subagent_event", ...this.scope, revision: this.revision, snapshot: this.value });
|
|
71
|
+
}
|
|
72
|
+
request(command) {
|
|
73
|
+
const failure = (error) => ({ id: command.id, type: "response", command: command.type, success: false, error });
|
|
74
|
+
if (command.version !== 1)
|
|
75
|
+
return failure("Unsupported subagent_status version; expected 1");
|
|
76
|
+
if (Object.keys(command).some(key => !["type", "version", "id"].includes(key))
|
|
77
|
+
|| command.id !== undefined && (typeof command.id !== "string" || command.id.length > 128 || /[\r\n]/.test(command.id)))
|
|
78
|
+
return failure("Malformed subagent_status command");
|
|
79
|
+
if (this.disposed)
|
|
80
|
+
return failure("Subagent observation session ended");
|
|
81
|
+
try {
|
|
82
|
+
if (this.sourceError)
|
|
83
|
+
return failure(this.sourceError);
|
|
84
|
+
if (this.source)
|
|
85
|
+
this.update(this.source.snapshot());
|
|
86
|
+
return { id: command.id, type: "response", command: "subagent_status", success: true, data: this.value };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return failure(error.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
dispose() { this.disposed = true; this.unsubscribe?.(); this.source = undefined; }
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, runRpcMode, } from "../../../index.js";
|
|
2
|
+
// No unrelated bundled startup/onboarding extensions: this fixture exercises the shipped RPC host.
|
|
3
|
+
const cwd = process.cwd();
|
|
4
|
+
const agentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
5
|
+
const createRuntime = async options => {
|
|
6
|
+
const services = await createAgentSessionServices({ cwd: options.cwd, agentDir: options.agentDir, resourceLoaderOptions: { noExtensions: true, noSkills: true, noContextFiles: true, additionalExtensionPaths: [process.env.RPC_INTERCOM_EXTENSION, process.env.RPC_SUBAGENT_EXTENSION].filter((value) => Boolean(value)) } });
|
|
7
|
+
const result = await createAgentSessionFromServices({ services, sessionManager: options.sessionManager, sessionStartEvent: options.sessionStartEvent });
|
|
8
|
+
return { ...result, services, diagnostics: [] };
|
|
9
|
+
};
|
|
10
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
11
|
+
cwd, agentDir, sessionManager: SessionManager.create(cwd, agentDir),
|
|
12
|
+
});
|
|
13
|
+
await runRpcMode(runtime);
|