@evandu/super-pi 0.1.93 → 0.1.95

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.
@@ -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 turn is running.
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 turn's capability cannot revoke another's. The value
44
- * is replaced rather than accumulated: a conversation has one primary turn in flight, and a second spawn
45
- * for the same conversation supersedes the first.
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 turn of one conversation, and returns it for the extension's environment.
53
+ * Mints the capability for one conversation's primary session, and returns it for the extension's
54
+ * environment.
54
55
  *
55
- * CALLED AT SPAWN, so the value is decided before the child exists and cannot be changed underneath it.
56
- * A turn that never reaches the spawn mints nothing, which is correct: there is no process to hand it to.
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 TURN, and that is the property the duration would not give on its own. A token valid for
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 turn ends,
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 turn. This is for tests - set it only on a server nothing else can reach.`);
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, at the end of its turn.
88
+ * Forgets a conversation's capability, when its session ends.
78
89
  *
79
- * CALLED FROM `answer()`'s `finally`, so it runs on every exit - a turn that answered, a turn that threw,
80
- * and a turn whose child was killed. A capability that outlived its turn would keep working for a process
81
- * that no longer exists, which is precisely the shape of a credential with no owner.
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 turn running.
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 turn 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.`,
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 turn was given. It is minted per turn, so a value from an earlier turn - or from another conversation - is not accepted. Do not retry the same call.` };
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 = verifyCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
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 capability to prove this came from the primary agent
815
- const { verifyCapability } = await import("./capability.js");
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
  }
@@ -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,78 @@ 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 COPY OF PI THAT CAME WITH SUPER PI, and it comes FIRST among the automatic choices.
130
+ //
131
+ // Super Pi depends on Pi's package, so npm installs it and puts its `pi` executable in
132
+ // `node_modules/.bin` - which is not on the user's PATH, and which is therefore invisible to the
133
+ // lookups below. Two reasons it wins over a Pi the user installed separately:
134
+ //
135
+ // 1. ONE Pi version in the product. The primary agent runs on the SDK - the very same package - and
136
+ // the sub-agents spawn an executable. If those two were different versions, one conversation could
137
+ // load the same extension file into two different Pi versions, and the older one would quietly
138
+ // lack a tool the newer one has. One package, one version, one set of extensions.
139
+ // 2. "Installing Super Pi installs Pi" is the product's promise, and it only holds if the copy that
140
+ // came with Super Pi is the copy Super Pi runs.
141
+ //
142
+ // A user's own Pi is still reachable by NAMING it (`SUPER_PI_PI_COMMAND`), which is the explicit
143
+ // override and wins outright - that is how the test suite drives a fake peer, and it is a legitimate
144
+ // thing for a person to want.
145
+ const bundled = findBundled(exists, platform);
146
+ if (bundled)
147
+ return { found: true, command: bundled, via: "the copy of Pi that came with Super Pi", explicit: false };
128
148
  const namesFound = firstHit(names, onPath, exists, platform);
129
149
  if (namesFound)
130
- return { found: true, command: namesFound.command, via: `PATH (${namesFound.dir})` };
150
+ return { found: true, command: namesFound.command, via: `PATH (${namesFound.dir})`, explicit: false };
131
151
  const globalFound = firstHit(names, globalDirs, exists, platform);
132
152
  if (globalFound)
133
- return { found: true, command: globalFound.command, via: `npm's global bin directory (${globalFound.dir})` };
153
+ return { found: true, command: globalFound.command, via: `npm's global bin directory (${globalFound.dir})`, explicit: false };
134
154
  const searched = [...new Set([...onPath, ...globalDirs])];
135
155
  return {
136
156
  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"}). Install it with "npm i -g pi" or set SUPER_PI_PI_COMMAND to its 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.`,
157
+ 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
158
  searched,
139
159
  };
140
160
  }
161
+ /**
162
+ * The `pi` executable of the Pi package Super Pi depends on, if it is installed next to Super Pi.
163
+ *
164
+ * npm puts a dependency's binaries in `node_modules/.bin`, as a shell script on POSIX and as a `.cmd`
165
+ * shim on Windows. Two layouts have to be covered, because Super Pi runs in both:
166
+ *
167
+ * - INSTALLED: `<node_modules>/@evandu/super-pi/dist/server/pi-command.js`, and the shim is at
168
+ * `<node_modules>/.bin/pi` - the `.bin` of the tree that HOLDS Super Pi, two levels up.
169
+ * - A CHECKOUT: `<repo>/dist/server/pi-command.js`, and the shim is at `<repo>/node_modules/.bin/pi`
170
+ * - the `.bin` of Super Pi's OWN tree.
171
+ *
172
+ * So every level of the walk checks both `<dir>/.bin` and `<dir>/node_modules/.bin`, and the walk
173
+ * starts at this module's own location rather than at `process.cwd()`: the server is started from
174
+ * wherever the user happened to be, and the answer must not depend on that.
175
+ */
176
+ export function bundledPiCommand(exists = defaultExists(process.platform), platform = process.platform) {
177
+ const at = joinFor(platform);
178
+ let dir = import.meta.dirname;
179
+ for (let depth = 0; depth < 6; depth += 1) {
180
+ for (const binDir of [at(dir, ".bin"), at(dir, "node_modules", ".bin")]) {
181
+ for (const name of launcherNames(platform)) {
182
+ const candidate = at(binDir, name);
183
+ if (exists(candidate))
184
+ return candidate;
185
+ }
186
+ }
187
+ const parent = at(dir, "..");
188
+ if (parent === dir)
189
+ break;
190
+ dir = parent;
191
+ }
192
+ return undefined;
193
+ }
141
194
  /**
142
195
  * Decodes a child process's output, falling back to the console code page when it is not UTF-8.
143
196
  *
@@ -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;