@evandu/super-pi 0.1.94 → 0.1.96
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/agents/super-pi-task/core.ts +6 -5
- package/agents/super-pi-task/index.ts +2 -1
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +3 -3
- package/dist/client/assets/{index-zbxFjL-O.js → index-gjLqMWYT.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/extension.js +13 -3
- package/dist/server/agents.d.ts +18 -0
- package/dist/server/agents.js +39 -0
- package/dist/server/application/run-service.d.ts +47 -3
- package/dist/server/application/run-service.js +158 -88
- package/dist/server/capability.d.ts +28 -12
- package/dist/server/capability.js +54 -20
- package/dist/server/http-server.js +14 -6
- package/dist/server/index.js +10 -1
- package/dist/server/pi-command.d.ts +29 -1
- package/dist/server/pi-command.js +82 -5
- package/dist/server/pi-session-events.d.ts +111 -0
- package/dist/server/pi-session-events.js +291 -0
- package/dist/server/pi-session-pool.d.ts +166 -0
- package/dist/server/pi-session-pool.js +262 -0
- package/dist/server/pi-session.d.ts +204 -0
- package/dist/server/pi-session.js +226 -0
- package/dist/server/runtime.d.ts +262 -29
- package/dist/server/runtime.js +664 -60
- package/dist/server/store.js +4 -4
- package/dist/server/ws-server.js +5 -4
- package/package.json +7 -3
- package/scripts/patch-brace-expansion.mjs +179 -0
|
@@ -36,27 +36,41 @@
|
|
|
36
36
|
*/
|
|
37
37
|
export declare const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
|
|
38
38
|
/**
|
|
39
|
-
* Mints the capability for one
|
|
39
|
+
* Mints the capability for one conversation's primary session, and returns it for the extension's
|
|
40
|
+
* environment.
|
|
40
41
|
*
|
|
41
|
-
* CALLED
|
|
42
|
-
* A
|
|
42
|
+
* CALLED WHEN THE SESSION IS CREATED, so the value is decided before the session exists and cannot be
|
|
43
|
+
* changed underneath it. A session that is never created mints nothing, which is correct: there is
|
|
44
|
+
* nothing to hand it to.
|
|
43
45
|
*
|
|
44
|
-
* ROTATED EVERY
|
|
45
|
-
* the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
46
|
-
* valid only while its holder is alive is a token that stops being a credential the moment the
|
|
47
|
-
* so there is no window in which a leaked value is still live.
|
|
46
|
+
* ROTATED EVERY SESSION, and that is the property the duration would not give on its own. A token valid
|
|
47
|
+
* for the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
48
|
+
* valid only while its holder is alive is a token that stops being a credential the moment the session
|
|
49
|
+
* ends, so there is no window in which a leaked value is still live.
|
|
50
|
+
*
|
|
51
|
+
* WHY IT IS THE SESSION AND NOT THE TURN. The capability travels in the extension's ENVIRONMENT, and an
|
|
52
|
+
* extension reads its environment when it is LOADED. A session that outlives its turns loads its
|
|
53
|
+
* extension once, so a per-turn token would have to be re-read from an environment that cannot change -
|
|
54
|
+
* and the second turn of a conversation would carry the FIRST turn's token, which the server would
|
|
55
|
+
* (correctly) refuse. The session is therefore the unit the credential belongs to, and the server
|
|
56
|
+
* enforces the turn separately. The application service owns the authoritative active-turn map, and
|
|
57
|
+
* passes that fact to `verifyCapability` at each protected endpoint. A stolen token is therefore worth
|
|
58
|
+
* nothing to a caller that cannot also start a turn.
|
|
48
59
|
*/
|
|
49
60
|
export declare function mintCapability(conversationId: string): string;
|
|
50
61
|
/**
|
|
51
|
-
* Forgets a conversation's capability,
|
|
62
|
+
* Forgets a conversation's capability, when its session ends.
|
|
52
63
|
*
|
|
53
|
-
* CALLED
|
|
54
|
-
*
|
|
55
|
-
*
|
|
64
|
+
* CALLED WHEN THE SESSION IS DESTROYED, so it runs on every exit - a session that was evicted as idle, a
|
|
65
|
+
* session that was thrown away by a revert, a session that was replaced because the conversation's
|
|
66
|
+
* configuration changed, and a session that was disposed at shutdown. A capability that outlived its
|
|
67
|
+
* session would keep working for a session that no longer exists, which is precisely the shape of a
|
|
68
|
+
* credential with no owner.
|
|
56
69
|
*/
|
|
57
70
|
export declare function releaseCapability(conversationId: string): void;
|
|
58
71
|
/** Forgets every capability. Called on shutdown, so nothing survives the process that minted it. */
|
|
59
72
|
export declare function releaseAllCapabilities(): void;
|
|
73
|
+
/**
|
|
60
74
|
/**
|
|
61
75
|
* The capability currently valid for a conversation, or `undefined` when no primary turn is running.
|
|
62
76
|
*
|
|
@@ -86,4 +100,6 @@ export type CapabilityVerdict = {
|
|
|
86
100
|
* silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
|
|
87
101
|
* retrying forever.
|
|
88
102
|
*/
|
|
89
|
-
export declare function verifyCapability(conversationId: string, presented: unknown
|
|
103
|
+
export declare function verifyCapability(conversationId: string, presented: unknown, options?: {
|
|
104
|
+
running?: boolean;
|
|
105
|
+
}): CapabilityVerdict;
|
|
@@ -37,12 +37,12 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
37
37
|
*/
|
|
38
38
|
export const TASK_CAPABILITY_ENV = "SUPER_PI_TASK_CAPABILITY";
|
|
39
39
|
/**
|
|
40
|
-
* The live capability per conversation, while that conversation's primary
|
|
40
|
+
* The live capability per conversation, while that conversation's primary session is alive.
|
|
41
41
|
*
|
|
42
42
|
* Keyed by CONVERSATION rather than being one global token, so two conversations being driven at once
|
|
43
|
-
* cannot dispatch as each other and so releasing one
|
|
44
|
-
* is replaced rather than accumulated: a conversation has one primary
|
|
45
|
-
*
|
|
43
|
+
* cannot dispatch as each other and so releasing one session's capability cannot revoke another's. The
|
|
44
|
+
* value is replaced rather than accumulated: a conversation has one primary session, and rebuilding it
|
|
45
|
+
* supersedes the first.
|
|
46
46
|
*/
|
|
47
47
|
const live = new Map();
|
|
48
48
|
/** A fixed capability, for tests. See `seededCapability` for why this does not weaken anything. */
|
|
@@ -50,21 +50,32 @@ const SEED = process.env.SUPER_PI_TASK_CAPABILITY_SEED;
|
|
|
50
50
|
/** Announced once, because a capability that silently stopped rotating is a security change nobody agreed to. */
|
|
51
51
|
let warnedSeed = false;
|
|
52
52
|
/**
|
|
53
|
-
* Mints the capability for one
|
|
53
|
+
* Mints the capability for one conversation's primary session, and returns it for the extension's
|
|
54
|
+
* environment.
|
|
54
55
|
*
|
|
55
|
-
* CALLED
|
|
56
|
-
* A
|
|
56
|
+
* CALLED WHEN THE SESSION IS CREATED, so the value is decided before the session exists and cannot be
|
|
57
|
+
* changed underneath it. A session that is never created mints nothing, which is correct: there is
|
|
58
|
+
* nothing to hand it to.
|
|
57
59
|
*
|
|
58
|
-
* ROTATED EVERY
|
|
59
|
-
* the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
60
|
-
* valid only while its holder is alive is a token that stops being a credential the moment the
|
|
61
|
-
* so there is no window in which a leaked value is still live.
|
|
60
|
+
* ROTATED EVERY SESSION, and that is the property the duration would not give on its own. A token valid
|
|
61
|
+
* for the whole server lifetime would still be a token an unrelated process could not guess - but a token
|
|
62
|
+
* valid only while its holder is alive is a token that stops being a credential the moment the session
|
|
63
|
+
* ends, so there is no window in which a leaked value is still live.
|
|
64
|
+
*
|
|
65
|
+
* WHY IT IS THE SESSION AND NOT THE TURN. The capability travels in the extension's ENVIRONMENT, and an
|
|
66
|
+
* extension reads its environment when it is LOADED. A session that outlives its turns loads its
|
|
67
|
+
* extension once, so a per-turn token would have to be re-read from an environment that cannot change -
|
|
68
|
+
* and the second turn of a conversation would carry the FIRST turn's token, which the server would
|
|
69
|
+
* (correctly) refuse. The session is therefore the unit the credential belongs to, and the server
|
|
70
|
+
* enforces the turn separately. The application service owns the authoritative active-turn map, and
|
|
71
|
+
* passes that fact to `verifyCapability` at each protected endpoint. A stolen token is therefore worth
|
|
72
|
+
* nothing to a caller that cannot also start a turn.
|
|
62
73
|
*/
|
|
63
74
|
export function mintCapability(conversationId) {
|
|
64
75
|
if (SEED) {
|
|
65
76
|
if (!warnedSeed) {
|
|
66
77
|
warnedSeed = true;
|
|
67
|
-
console.warn(`Super Pi: SUPER_PI_TASK_CAPABILITY_SEED is set, so every dispatch capability is the same fixed value instead of being minted per
|
|
78
|
+
console.warn(`Super Pi: SUPER_PI_TASK_CAPABILITY_SEED is set, so every dispatch capability is the same fixed value instead of being minted per session. This is for tests - set it only on a server nothing else can reach.`);
|
|
68
79
|
}
|
|
69
80
|
live.set(conversationId, SEED);
|
|
70
81
|
return SEED;
|
|
@@ -74,11 +85,13 @@ export function mintCapability(conversationId) {
|
|
|
74
85
|
return token;
|
|
75
86
|
}
|
|
76
87
|
/**
|
|
77
|
-
* Forgets a conversation's capability,
|
|
88
|
+
* Forgets a conversation's capability, when its session ends.
|
|
78
89
|
*
|
|
79
|
-
* CALLED
|
|
80
|
-
*
|
|
81
|
-
*
|
|
90
|
+
* CALLED WHEN THE SESSION IS DESTROYED, so it runs on every exit - a session that was evicted as idle, a
|
|
91
|
+
* session that was thrown away by a revert, a session that was replaced because the conversation's
|
|
92
|
+
* configuration changed, and a session that was disposed at shutdown. A capability that outlived its
|
|
93
|
+
* session would keep working for a session that no longer exists, which is precisely the shape of a
|
|
94
|
+
* credential with no owner.
|
|
82
95
|
*/
|
|
83
96
|
export function releaseCapability(conversationId) {
|
|
84
97
|
live.delete(conversationId);
|
|
@@ -87,6 +100,7 @@ export function releaseCapability(conversationId) {
|
|
|
87
100
|
export function releaseAllCapabilities() {
|
|
88
101
|
live.clear();
|
|
89
102
|
}
|
|
103
|
+
/**
|
|
90
104
|
/**
|
|
91
105
|
* The capability currently valid for a conversation, or `undefined` when no primary turn is running.
|
|
92
106
|
*
|
|
@@ -111,8 +125,8 @@ export function capabilityFor(conversationId) {
|
|
|
111
125
|
* silently omitted the header would otherwise be indistinguishable from an attacker, and would keep
|
|
112
126
|
* retrying forever.
|
|
113
127
|
*/
|
|
114
|
-
export function verifyCapability(conversationId, presented) {
|
|
115
|
-
// A seeded server accepts its fixed value for EVERY conversation, including one with no
|
|
128
|
+
export function verifyCapability(conversationId, presented, options = {}) {
|
|
129
|
+
// A seeded server accepts its fixed value for EVERY conversation, including one with no session.
|
|
116
130
|
// That relaxation is exactly the test affordance and nothing else: `SEED` is documented as test-only,
|
|
117
131
|
// and a process that can set it could set `SUPER_PI_ACCESS_TOKEN` instead and already have full control.
|
|
118
132
|
// The comparison below is still real - a wrong value is still refused - so a seeded server still catches
|
|
@@ -121,7 +135,7 @@ export function verifyCapability(conversationId, presented) {
|
|
|
121
135
|
if (!expected) {
|
|
122
136
|
return {
|
|
123
137
|
ok: false,
|
|
124
|
-
message: `No primary
|
|
138
|
+
message: `No primary session is running for conversation ${conversationId}, so there is nothing that could have dispatched this. Dispatch happens from inside a turn: send the tool call during a turn rather than posting to the server directly.`,
|
|
125
139
|
};
|
|
126
140
|
}
|
|
127
141
|
if (typeof presented !== "string" || !presented) {
|
|
@@ -130,7 +144,27 @@ export function verifyCapability(conversationId, presented) {
|
|
|
130
144
|
const left = Buffer.from(expected);
|
|
131
145
|
const right = Buffer.from(presented);
|
|
132
146
|
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
|
133
|
-
return { ok: false, message: `The ${TASK_CAPABILITY_ENV} presented does not match the one this
|
|
147
|
+
return { ok: false, message: `The ${TASK_CAPABILITY_ENV} presented does not match the one this session was given. It is minted per session, so a value from an earlier session - or from another conversation - is not accepted. Do not retry the same call.` };
|
|
148
|
+
}
|
|
149
|
+
// THE SECOND HALF. The token proves the caller holds this session's credential; this proves the
|
|
150
|
+
// session is actually doing something. A dispatch is accepted only while a turn is running, which is
|
|
151
|
+
// what a session-scoped token would otherwise have lost: the value is now good for the whole life of
|
|
152
|
+
// the session rather than for one turn, and a live turn is the difference between "the right
|
|
153
|
+
// credential" and "the right credential being used at the right time".
|
|
154
|
+
//
|
|
155
|
+
// A conversation that has a session but is idle has nothing to dispatch FOR: there is no turn, no
|
|
156
|
+
// model, no transcript being written. A caller presenting a valid token for an idle conversation is
|
|
157
|
+
// not the primary agent of a running turn - it is something replaying a value it read earlier.
|
|
158
|
+
//
|
|
159
|
+
// A SEEDED SERVER IS EXEMPT, and that is the same relaxation the seeded value itself gets: a seeded
|
|
160
|
+
// server already accepts its fixed token for a conversation with no session at all, and the seed is a
|
|
161
|
+
// documented test-only affordance. A process able to set the seed could set
|
|
162
|
+
// `SUPER_PI_ACCESS_TOKEN` instead and already have full control.
|
|
163
|
+
if (!SEED && options.running !== true) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
message: `Conversation ${conversationId} is not running a turn, so there is no primary agent to dispatch for. Dispatch happens from inside a turn: send the tool call during a turn rather than posting to the server directly.`,
|
|
167
|
+
};
|
|
134
168
|
}
|
|
135
169
|
return { ok: true };
|
|
136
170
|
}
|
|
@@ -8,7 +8,6 @@ import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES
|
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
9
|
import { checkDiskVersion } from "./version.js";
|
|
10
10
|
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
11
|
-
import { verifyCapability } from "./capability.js";
|
|
12
11
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
13
12
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
14
13
|
import { packageUpdateArgs } from "./update.js";
|
|
@@ -222,7 +221,7 @@ export function createHttpServer(options) {
|
|
|
222
221
|
// the same reason: it is reached only by the Pi process of one turn, carrying that turn's
|
|
223
222
|
// capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
|
|
224
223
|
// imports and deletes documents, so it stays behind the ordinary token.
|
|
225
|
-
if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
224
|
+
if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/todos" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
226
225
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
227
226
|
return;
|
|
228
227
|
}
|
|
@@ -300,7 +299,7 @@ export function createHttpServer(options) {
|
|
|
300
299
|
json(401, { error: "authentication or agent capability required" });
|
|
301
300
|
return;
|
|
302
301
|
}
|
|
303
|
-
const verdict =
|
|
302
|
+
const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
|
|
304
303
|
if (!verdict.ok) {
|
|
305
304
|
json(403, { error: verdict.message });
|
|
306
305
|
return;
|
|
@@ -811,9 +810,8 @@ export function createHttpServer(options) {
|
|
|
811
810
|
json(400, { error: "cmd, cwd and conversationId are required" });
|
|
812
811
|
return;
|
|
813
812
|
}
|
|
814
|
-
// Verify
|
|
815
|
-
const
|
|
816
|
-
const result = verifyCapability(conversationId, capability);
|
|
813
|
+
// Verify both the credential and RunService's authoritative active-turn state.
|
|
814
|
+
const result = runService.verifyPrimaryCapability(conversationId, capability);
|
|
817
815
|
if (!result.ok) {
|
|
818
816
|
json(403, { error: result.message });
|
|
819
817
|
return;
|
|
@@ -915,6 +913,11 @@ export function createHttpServer(options) {
|
|
|
915
913
|
const conversationId = body.conversationId?.trim();
|
|
916
914
|
if (!conversationId)
|
|
917
915
|
throw new Error("todowrite requires conversationId");
|
|
916
|
+
const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
|
|
917
|
+
if (!verdict.ok) {
|
|
918
|
+
json(403, { error: verdict.message });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
918
921
|
const { todos, dropped } = await store.setTodos(conversationId, body.todos);
|
|
919
922
|
broadcaster.broadcastState();
|
|
920
923
|
json(200, { todos, dropped, conversationId });
|
|
@@ -936,6 +939,11 @@ export function createHttpServer(options) {
|
|
|
936
939
|
const body = JSON.parse(raw);
|
|
937
940
|
if (!body.conversationId || !body.question || !Array.isArray(body.options))
|
|
938
941
|
throw new Error("invalid question");
|
|
942
|
+
const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability);
|
|
943
|
+
if (!verdict.ok) {
|
|
944
|
+
response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
939
947
|
const result = await runService.askQuestion(body.conversationId);
|
|
940
948
|
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
|
|
941
949
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -198,11 +198,20 @@ async function shutdown(reason) {
|
|
|
198
198
|
execService.closeAll();
|
|
199
199
|
runService.abortAll();
|
|
200
200
|
workflows.abortAll();
|
|
201
|
-
releaseAllCapabilities();
|
|
202
201
|
for (const c of store.snapshot().conversations) {
|
|
203
202
|
extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
204
203
|
}
|
|
205
204
|
await Promise.all([runService.waitForIdle(), workflows.waitForIdle()]);
|
|
205
|
+
// THE LIVE SESSIONS GO NEXT, AND THEY HAVE TO GO EXPLICITLY. `runService.abortAll()` stops the
|
|
206
|
+
// TURNS, and a stopped turn is not a stopped session: a session with nothing running on it is idle,
|
|
207
|
+
// it holds a model client and a session file, and nothing in the abort path would reach it. So
|
|
208
|
+
// without this the server would exit with a session per conversation still alive - and on Windows
|
|
209
|
+
// those sessions hold their working directory open, which is the lock that made a deleted workspace
|
|
210
|
+
// fail to go away.
|
|
211
|
+
await runtime.disposeSessions?.();
|
|
212
|
+
// The capabilities die AFTER the sessions that carry them: a session that is still alive could
|
|
213
|
+
// otherwise use a credential the server has already forgotten about.
|
|
214
|
+
releaseAllCapabilities();
|
|
206
215
|
await store.flush();
|
|
207
216
|
wsServer.close();
|
|
208
217
|
httpServer.closeAllConnections();
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
export type PiCommandResolution = {
|
|
17
17
|
found: true;
|
|
18
18
|
command: string;
|
|
19
|
-
via: string;
|
|
19
|
+
via: string; /** Whether the command was named explicitly rather than found. */
|
|
20
|
+
explicit: boolean;
|
|
20
21
|
} | {
|
|
21
22
|
found: false;
|
|
22
23
|
reason: string;
|
|
@@ -29,6 +30,17 @@ export interface PiCommandOptions {
|
|
|
29
30
|
env?: Record<string, string | undefined>;
|
|
30
31
|
platform?: NodeJS.Platform;
|
|
31
32
|
exists?: (path: string) => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Where the "copy of Pi that came with Super Pi" is looked for, and how it is found.
|
|
35
|
+
*
|
|
36
|
+
* INJECTED, and the reason is the same reason `exists` is: the bundled lookup walks the REAL
|
|
37
|
+
* filesystem, so a test that asserts a resolution RULE ("PATH wins", "the global directory is
|
|
38
|
+
* searched") would otherwise be answered by whatever happens to be installed on the machine running
|
|
39
|
+
* the test. The rule being asserted has to be the only thing that decides the answer.
|
|
40
|
+
*
|
|
41
|
+
* The default walks up from this module's own location, which is the tree Super Pi was loaded from.
|
|
42
|
+
*/
|
|
43
|
+
bundled?: (exists: (path: string) => boolean, platform: NodeJS.Platform) => string | undefined;
|
|
32
44
|
}
|
|
33
45
|
/**
|
|
34
46
|
* Resolves the command that starts Pi: the explicit override, then PATH, then npm's global bin
|
|
@@ -38,6 +50,22 @@ export interface PiCommandOptions {
|
|
|
38
50
|
* silent fallback would make a typo in `SUPER_PI_PI_COMMAND` look like a working Pi somewhere else.
|
|
39
51
|
*/
|
|
40
52
|
export declare function resolvePiCommand(options?: PiCommandOptions): PiCommandResolution;
|
|
53
|
+
/**
|
|
54
|
+
* The `pi` executable of the Pi package Super Pi depends on, if it is installed next to Super Pi.
|
|
55
|
+
*
|
|
56
|
+
* npm puts a dependency's binaries in `node_modules/.bin`, as a shell script on POSIX and as a `.cmd`
|
|
57
|
+
* shim on Windows. Two layouts have to be covered, because Super Pi runs in both:
|
|
58
|
+
*
|
|
59
|
+
* - INSTALLED: `<node_modules>/@evandu/super-pi/dist/server/pi-command.js`, and the shim is at
|
|
60
|
+
* `<node_modules>/.bin/pi` - the `.bin` of the tree that HOLDS Super Pi, two levels up.
|
|
61
|
+
* - A CHECKOUT: `<repo>/dist/server/pi-command.js`, and the shim is at `<repo>/node_modules/.bin/pi`
|
|
62
|
+
* - the `.bin` of Super Pi's OWN tree.
|
|
63
|
+
*
|
|
64
|
+
* So every level of the walk checks both `<dir>/.bin` and `<dir>/node_modules/.bin`, and the walk
|
|
65
|
+
* starts at this module's own location rather than at `process.cwd()`: the server is started from
|
|
66
|
+
* wherever the user happened to be, and the answer must not depend on that.
|
|
67
|
+
*/
|
|
68
|
+
export declare function bundledPiCommand(exists?: (path: string) => boolean, platform?: NodeJS.Platform): string | undefined;
|
|
41
69
|
/**
|
|
42
70
|
* Decodes a child process's output, falling back to the console code page when it is not UTF-8.
|
|
43
71
|
*
|
|
@@ -100,6 +100,7 @@ const firstHit = (names, directories, exists, platform) => {
|
|
|
100
100
|
export function resolvePiCommand(options = {}) {
|
|
101
101
|
const { command, env = process.env, platform = process.platform } = options;
|
|
102
102
|
const exists = options.exists ?? defaultExists(platform);
|
|
103
|
+
const findBundled = options.bundled ?? bundledPiCommand;
|
|
103
104
|
const names = launcherNames(platform);
|
|
104
105
|
const explicit = (command ?? env.SUPER_PI_PI_COMMAND ?? "").trim();
|
|
105
106
|
const onPath = pathDirectories(env, platform);
|
|
@@ -109,7 +110,7 @@ export function resolvePiCommand(options = {}) {
|
|
|
109
110
|
if (/[\\/]/.test(explicit)) {
|
|
110
111
|
const full = nativeEntry(explicit, platform) ?? explicit;
|
|
111
112
|
if (exists(full))
|
|
112
|
-
return { found: true, command: full, via: label };
|
|
113
|
+
return { found: true, command: full, via: label, explicit: true };
|
|
113
114
|
return {
|
|
114
115
|
found: false,
|
|
115
116
|
reason: `${label} points at ${full}, and no such file exists. Fix ${command ? "that argument" : "SUPER_PI_PI_COMMAND"} or unset it to fall back to PATH and npm's global bin directory. This is an environment problem, not a defect in the agent mechanism.`,
|
|
@@ -118,26 +119,102 @@ export function resolvePiCommand(options = {}) {
|
|
|
118
119
|
}
|
|
119
120
|
const hit = firstHit([explicit], [...onPath, ...globalDirs], exists, platform);
|
|
120
121
|
if (hit)
|
|
121
|
-
return { found: true, command: hit.command, via: `${label} (${hit.dir})
|
|
122
|
+
return { found: true, command: hit.command, via: `${label} (${hit.dir})`, explicit: true };
|
|
122
123
|
return {
|
|
123
124
|
found: false,
|
|
124
125
|
reason: `${label} names ${explicit}, which is on no PATH entry and in none of npm's global bin directories. This is an environment problem, not a defect in the agent mechanism.`,
|
|
125
126
|
searched: [...onPath, ...globalDirs],
|
|
126
127
|
};
|
|
127
128
|
}
|
|
129
|
+
// THE PI THAT IS HOSTING SUPER PI, when Super Pi was started from inside Pi itself.
|
|
130
|
+
//
|
|
131
|
+
// It is NOT the same thing as an explicitly named Pi, and the difference is the whole point:
|
|
132
|
+
// `SUPER_PI_PI_COMMAND` means "the USER named this Pi", and naming a Pi is what tells Super Pi to
|
|
133
|
+
// run CONVERSATIONS by spawning it - the SDK is a library, and it can only be the one that came with
|
|
134
|
+
// Super Pi. A conversation that spawns a process for every turn is the slow architecture this all
|
|
135
|
+
// replaced, so the hosting Pi must NOT set that flag.
|
|
136
|
+
//
|
|
137
|
+
// What the hosting Pi IS good for is the one-shot work: the sub-agents spawn a Pi process, and they
|
|
138
|
+
// should run on the same Pi that is hosting Super Pi. So it is preferred over the bundled copy here,
|
|
139
|
+
// without being a user override.
|
|
140
|
+
const hostPi = (env.SUPER_PI_HOST_PI ?? "").trim();
|
|
141
|
+
if (hostPi) {
|
|
142
|
+
if (/[\\/]/.test(hostPi)) {
|
|
143
|
+
const full = nativeEntry(hostPi, platform) ?? hostPi;
|
|
144
|
+
if (exists(full))
|
|
145
|
+
return { found: true, command: full, via: "the Pi that is hosting Super Pi", explicit: false };
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const hit = firstHit([hostPi], [...onPath, ...globalDirs], exists, platform);
|
|
149
|
+
if (hit)
|
|
150
|
+
return { found: true, command: hit.command, via: `the Pi that is hosting Super Pi (${hit.dir})`, explicit: false };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// THE COPY OF PI THAT CAME WITH SUPER PI, and it comes FIRST among the automatic choices.
|
|
154
|
+
//
|
|
155
|
+
// Super Pi depends on Pi's package, so npm installs it and puts its `pi` executable in
|
|
156
|
+
// `node_modules/.bin` - which is not on the user's PATH, and which is therefore invisible to the
|
|
157
|
+
// lookups below. Two reasons it wins over a Pi the user installed separately:
|
|
158
|
+
//
|
|
159
|
+
// 1. ONE Pi version in the product. The primary agent runs on the SDK - the very same package - and
|
|
160
|
+
// the sub-agents spawn an executable. If those two were different versions, one conversation could
|
|
161
|
+
// load the same extension file into two different Pi versions, and the older one would quietly
|
|
162
|
+
// lack a tool the newer one has. One package, one version, one set of extensions.
|
|
163
|
+
// 2. "Installing Super Pi installs Pi" is the product's promise, and it only holds if the copy that
|
|
164
|
+
// came with Super Pi is the copy Super Pi runs.
|
|
165
|
+
//
|
|
166
|
+
// A user's own Pi is still reachable by NAMING it (`SUPER_PI_PI_COMMAND`), which is the explicit
|
|
167
|
+
// override and wins outright - that is how the test suite drives a fake peer, and it is a legitimate
|
|
168
|
+
// thing for a person to want.
|
|
169
|
+
const bundled = findBundled(exists, platform);
|
|
170
|
+
if (bundled)
|
|
171
|
+
return { found: true, command: bundled, via: "the copy of Pi that came with Super Pi", explicit: false };
|
|
128
172
|
const namesFound = firstHit(names, onPath, exists, platform);
|
|
129
173
|
if (namesFound)
|
|
130
|
-
return { found: true, command: namesFound.command, via: `PATH (${namesFound.dir})
|
|
174
|
+
return { found: true, command: namesFound.command, via: `PATH (${namesFound.dir})`, explicit: false };
|
|
131
175
|
const globalFound = firstHit(names, globalDirs, exists, platform);
|
|
132
176
|
if (globalFound)
|
|
133
|
-
return { found: true, command: globalFound.command, via: `npm's global bin directory (${globalFound.dir})
|
|
177
|
+
return { found: true, command: globalFound.command, via: `npm's global bin directory (${globalFound.dir})`, explicit: false };
|
|
134
178
|
const searched = [...new Set([...onPath, ...globalDirs])];
|
|
135
179
|
return {
|
|
136
180
|
found: false,
|
|
137
|
-
reason: `Pi was not found: no ${names.map((name) => `"${name}"`).join(" or ")} in ${searched.length} directory(ies) on PATH or in npm's global bin location (${searched.join(", ") || "none known"}).
|
|
181
|
+
reason: `Pi was not found: no ${names.map((name) => `"${name}"`).join(" or ")} in ${searched.length} directory(ies) on PATH or in npm's global bin location (${searched.join(", ") || "none known"}), and Super Pi's own copy of Pi is not installed either. Reinstall Super Pi (npm i -g @evandu/super-pi) so that Pi comes with it, or set SUPER_PI_PI_COMMAND to a Pi's full path. This is an environment fact, NOT evidence that the agent mechanism is broken - only a Pi that IS present and disagrees can say that.`,
|
|
138
182
|
searched,
|
|
139
183
|
};
|
|
140
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* The `pi` executable of the Pi package Super Pi depends on, if it is installed next to Super Pi.
|
|
187
|
+
*
|
|
188
|
+
* npm puts a dependency's binaries in `node_modules/.bin`, as a shell script on POSIX and as a `.cmd`
|
|
189
|
+
* shim on Windows. Two layouts have to be covered, because Super Pi runs in both:
|
|
190
|
+
*
|
|
191
|
+
* - INSTALLED: `<node_modules>/@evandu/super-pi/dist/server/pi-command.js`, and the shim is at
|
|
192
|
+
* `<node_modules>/.bin/pi` - the `.bin` of the tree that HOLDS Super Pi, two levels up.
|
|
193
|
+
* - A CHECKOUT: `<repo>/dist/server/pi-command.js`, and the shim is at `<repo>/node_modules/.bin/pi`
|
|
194
|
+
* - the `.bin` of Super Pi's OWN tree.
|
|
195
|
+
*
|
|
196
|
+
* So every level of the walk checks both `<dir>/.bin` and `<dir>/node_modules/.bin`, and the walk
|
|
197
|
+
* starts at this module's own location rather than at `process.cwd()`: the server is started from
|
|
198
|
+
* wherever the user happened to be, and the answer must not depend on that.
|
|
199
|
+
*/
|
|
200
|
+
export function bundledPiCommand(exists = defaultExists(process.platform), platform = process.platform) {
|
|
201
|
+
const at = joinFor(platform);
|
|
202
|
+
let dir = import.meta.dirname;
|
|
203
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
204
|
+
for (const binDir of [at(dir, ".bin"), at(dir, "node_modules", ".bin")]) {
|
|
205
|
+
for (const name of launcherNames(platform)) {
|
|
206
|
+
const candidate = at(binDir, name);
|
|
207
|
+
if (exists(candidate))
|
|
208
|
+
return candidate;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const parent = at(dir, "..");
|
|
212
|
+
if (parent === dir)
|
|
213
|
+
break;
|
|
214
|
+
dir = parent;
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
141
218
|
/**
|
|
142
219
|
* Decodes a child process's output, falling back to the console code page when it is not UTF-8.
|
|
143
220
|
*
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE EVENT BRIDGE: turns a Pi session's event stream into the turn-shaped stream Super Pi consumes.
|
|
3
|
+
*
|
|
4
|
+
* WHY A BRIDGE AND NOT AN INLINE LOOP. The old design read a child's stdout in a `while` loop and
|
|
5
|
+
* dispatched records inline. The new design has no child and no stdout: Pi emits EVENTS through a
|
|
6
|
+
* subscription. A subscription is a push, and Super Pi's turn is a pull - `answer()` is an async
|
|
7
|
+
* generator that yields text deltas to its caller.
|
|
8
|
+
*
|
|
9
|
+
* A push has to become a pull somewhere, and this is where. The bridge keeps a queue of pending items
|
|
10
|
+
* and hands them out one at a time; the generator awaits the next item when the queue is empty. The
|
|
11
|
+
* ordering that matters is preserved exactly: the subscription callback pushes, and the queue is FIFO,
|
|
12
|
+
* so the order the reader sees is the order Pi emitted.
|
|
13
|
+
*
|
|
14
|
+
* THE BOUNDARY IS THE SAME AS BEFORE. Only the events Super Pi already consumed are routed; everything
|
|
15
|
+
* else is counted and reported once, exactly as the old `noteUnhandled` did. A record type that used to
|
|
16
|
+
* be announced as unhandled is still announced as unhandled - this bridge changes WHERE the events come
|
|
17
|
+
* from, not what they mean.
|
|
18
|
+
*/
|
|
19
|
+
import type { PiQueueSnapshot, ToolCallNotice, ToolCallPreparing } from "./types.js";
|
|
20
|
+
/**
|
|
21
|
+
* Reads a `queue_update` payload, or returns undefined when the record is not one.
|
|
22
|
+
*
|
|
23
|
+
* The same recogniser the RPC drain loop uses, and deliberately so: Pi's RPC mode forwards the session's
|
|
24
|
+
* own events verbatim, so a `queue_update` from a persistent SDK session and one from a child's stdout
|
|
25
|
+
* are the same fact in the same shape - Pi reporting what its in-memory steering/follow-up queues hold
|
|
26
|
+
* (`agent-session.ts:_emitQueueUpdate`). It is the ONLY proof that a steer was actually picked up: Pi
|
|
27
|
+
* removes the entry from its queue and emits this snapshot BEFORE it emits the delivered `message_start`,
|
|
28
|
+
* so a host that ignored it would have no way to tell "queued in Pi" from "delivered to the model".
|
|
29
|
+
*/
|
|
30
|
+
export declare function describePiQueue(record: unknown): PiQueueSnapshot | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Reads the text of a steer Pi has just delivered, or undefined when this record is not one.
|
|
33
|
+
*
|
|
34
|
+
* A `message_start` carrying a `user` message is exactly that, and nothing else is: the prompt that
|
|
35
|
+
* opened the turn is never re-announced this way, and `assistant` messages are not deliveries.
|
|
36
|
+
*
|
|
37
|
+
* Content is joined with a newline rather than taken from the first block, so a multi-block steer comes
|
|
38
|
+
* back as the text the user actually wrote instead of its first paragraph. The text is a MATCHING HINT
|
|
39
|
+
* only - Pi identifies its own queued entry by `indexOf` on this string (`agent-session.ts:1123`) and so
|
|
40
|
+
* cannot tell two identical messages apart either, which is why the caller resolves a delivery by
|
|
41
|
+
* position and not by comparing this.
|
|
42
|
+
*/
|
|
43
|
+
export declare function describeDeliveredUserMessage(record: unknown): string | undefined;
|
|
44
|
+
/** What the bridge hands to the turn, one item at a time. */
|
|
45
|
+
export type TurnItem =
|
|
46
|
+
/** A chunk of assistant text. */
|
|
47
|
+
{
|
|
48
|
+
kind: "text";
|
|
49
|
+
delta: string;
|
|
50
|
+
}
|
|
51
|
+
/** The turn is over. */
|
|
52
|
+
| {
|
|
53
|
+
kind: "end";
|
|
54
|
+
}
|
|
55
|
+
/** The turn failed. */
|
|
56
|
+
| {
|
|
57
|
+
kind: "error";
|
|
58
|
+
error: Error;
|
|
59
|
+
};
|
|
60
|
+
/** The callbacks a turn supplies. The names match the RPC runtime's, so the two paths behave alike. */
|
|
61
|
+
export interface TurnEventHandlers {
|
|
62
|
+
onToolCall?(call: ToolCallNotice): void;
|
|
63
|
+
onToolUpdate?(call: ToolCallNotice): void;
|
|
64
|
+
onToolPreparing?(call: ToolCallPreparing): void;
|
|
65
|
+
onToolEnd?(call: ToolCallNotice): void;
|
|
66
|
+
onThinking?(delta: string): void;
|
|
67
|
+
onThinkingEnd?(): void;
|
|
68
|
+
/** A steer this turn delivered - the text Pi picked up from its own queue. */
|
|
69
|
+
onSteerDelivered?(text: string): void;
|
|
70
|
+
/** Pi's own queue, as a full snapshot. */
|
|
71
|
+
onPiQueue?(queue: {
|
|
72
|
+
steering: string[];
|
|
73
|
+
followUp: string[];
|
|
74
|
+
}): void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A queue of turn items, with the waiters that are blocked on it.
|
|
78
|
+
*
|
|
79
|
+
* The `settled` flag matters: a turn that ends must release every waiter, including one that is
|
|
80
|
+
* waiting for text that will never arrive. A generator awaiting an item on a queue nobody will ever
|
|
81
|
+
* push to is a turn that hangs forever, and a hung turn is a conversation that never answers.
|
|
82
|
+
*/
|
|
83
|
+
export declare class TurnQueue {
|
|
84
|
+
private readonly items;
|
|
85
|
+
private readonly waiters;
|
|
86
|
+
private settled;
|
|
87
|
+
/**
|
|
88
|
+
* Resolves when Pi has SETTLED the turn, which is the real end of a turn.
|
|
89
|
+
*
|
|
90
|
+
* The distinction matters: Pi's `prompt()` resolves when the message has been accepted, which is
|
|
91
|
+
* long before the agent has settled. A caller that treats the prompt's resolution as the end of the
|
|
92
|
+
* turn would release the reader while the model was still producing - and the next turn would then
|
|
93
|
+
* arrive at a session Pi considers busy, which Pi refuses outright.
|
|
94
|
+
*/
|
|
95
|
+
readonly settledSignal: Promise<void>;
|
|
96
|
+
private markSettled;
|
|
97
|
+
constructor();
|
|
98
|
+
push(item: TurnItem): void;
|
|
99
|
+
/** Settles the queue with an error, for a turn that failed before it produced anything. */
|
|
100
|
+
fail(error: Error): void;
|
|
101
|
+
next(): Promise<TurnItem>;
|
|
102
|
+
get isSettled(): boolean;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Handles one Pi event, routing it to the queue and to the turn's callbacks.
|
|
106
|
+
*
|
|
107
|
+
* Returns whether the event was consumed. An unconsumed event is reported once per distinct type and
|
|
108
|
+
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
109
|
+
* understands is worth saying out loud exactly once.
|
|
110
|
+
*/
|
|
111
|
+
export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void): boolean;
|