@evandu/super-pi 0.1.94 → 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.
- 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/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 +58 -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 +243 -29
- package/dist/server/runtime.js +634 -60
- package/dist/server/store.js +4 -4
- package/dist/server/ws-server.js +5 -4
- package/package.json +3 -1
package/dist/client/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-gjLqMWYT.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-8olCbCbk.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
package/dist/server/agents.d.ts
CHANGED
|
@@ -116,6 +116,24 @@ export declare function toolFlags(tools: Frontmatter | undefined, options?: Tool
|
|
|
116
116
|
* of `toolFlags` because the picker needs to say "no tools" rather than render an empty allowlist.
|
|
117
117
|
*/
|
|
118
118
|
export declare function deniesAllTools(tools: Frontmatter | undefined): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* The same translation `toolFlags` performs, expressed as the SDK's two lists rather than as argv.
|
|
121
|
+
*
|
|
122
|
+
* WHY A SECOND TRANSLATION AND NOT A PARSER. The argv form is `--tools a,b,c` or `--exclude-tools x,y`
|
|
123
|
+
* or `--no-tools`, and parsing it back would be reading a serialization of a decision to recover the
|
|
124
|
+
* decision. Worse, `--no-tools` has no SDK equivalent that means the same thing: Pi's `noTools: "all"`
|
|
125
|
+
* disables the built-ins while leaving extension tools on, which is NOT what an agent that declared
|
|
126
|
+
* `{"*": false}` asked for - that agent asked for NO tools at all, and an extension tool left enabled
|
|
127
|
+
* would be a tool the author explicitly refused.
|
|
128
|
+
*
|
|
129
|
+
* So the DECISION is made once, here, in the same terms `toolFlags` uses, and each consumer takes the
|
|
130
|
+
* form it needs. The two must agree, and `scripts/agent-config-regression.test.mjs` asserts that they
|
|
131
|
+
* do for every shape of `tools:` map.
|
|
132
|
+
*/
|
|
133
|
+
export declare function toolSelection(tools: Frontmatter | undefined, options?: ToolFlagOptions): {
|
|
134
|
+
tools?: string[];
|
|
135
|
+
excludeTools?: string[];
|
|
136
|
+
};
|
|
119
137
|
/** The tools an allowlist names, for display. Empty when the agent adds no allowlist of its own. */
|
|
120
138
|
export declare function allowedToolNames(tools: Frontmatter | undefined): string[];
|
|
121
139
|
/** `provider/modelId`, split into the two halves Pi's `set_model` and `--provider/--model` both need. */
|
package/dist/server/agents.js
CHANGED
|
@@ -304,6 +304,45 @@ export function deniesAllTools(tools) {
|
|
|
304
304
|
return false;
|
|
305
305
|
return tools["*"] === false && !Object.entries(tools).some(([key, on]) => key !== "*" && on === true);
|
|
306
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The same translation `toolFlags` performs, expressed as the SDK's two lists rather than as argv.
|
|
309
|
+
*
|
|
310
|
+
* WHY A SECOND TRANSLATION AND NOT A PARSER. The argv form is `--tools a,b,c` or `--exclude-tools x,y`
|
|
311
|
+
* or `--no-tools`, and parsing it back would be reading a serialization of a decision to recover the
|
|
312
|
+
* decision. Worse, `--no-tools` has no SDK equivalent that means the same thing: Pi's `noTools: "all"`
|
|
313
|
+
* disables the built-ins while leaving extension tools on, which is NOT what an agent that declared
|
|
314
|
+
* `{"*": false}` asked for - that agent asked for NO tools at all, and an extension tool left enabled
|
|
315
|
+
* would be a tool the author explicitly refused.
|
|
316
|
+
*
|
|
317
|
+
* So the DECISION is made once, here, in the same terms `toolFlags` uses, and each consumer takes the
|
|
318
|
+
* form it needs. The two must agree, and `scripts/agent-config-regression.test.mjs` asserts that they
|
|
319
|
+
* do for every shape of `tools:` map.
|
|
320
|
+
*/
|
|
321
|
+
export function toolSelection(tools, options = {}) {
|
|
322
|
+
const denied = new Set((options.denyTools ?? []).filter((name) => name !== ""));
|
|
323
|
+
// No map at all is Pi's own default set. An inherited deny still has to become an exclusion, because
|
|
324
|
+
// there is no allowlist here to drop the name from.
|
|
325
|
+
if (!tools || typeof tools !== "object") {
|
|
326
|
+
return denied.size ? { excludeTools: [...denied] } : {};
|
|
327
|
+
}
|
|
328
|
+
const entries = Object.entries(tools).filter(([key]) => key !== "*");
|
|
329
|
+
const wildcard = tools["*"];
|
|
330
|
+
const explicit = new Map(entries);
|
|
331
|
+
const extra = (options.extraAllowedTools ?? []).filter((name) => name !== "" && explicit.get(name) !== false);
|
|
332
|
+
const declared = entries.filter(([, on]) => on === true).map(([name]) => name).filter((name) => !denied.has(name));
|
|
333
|
+
const deny = entries.filter(([, on]) => on === false).map(([name]) => name).filter((name) => !declared.includes(name));
|
|
334
|
+
if (wildcard === false) {
|
|
335
|
+
// An allowlist of nothing is an agent that talks and does not act. The SDK expresses that as an
|
|
336
|
+
// EMPTY allowlist - `tools: []` - which is different from omitting the option, and the difference
|
|
337
|
+
// matters: omitted means Pi's defaults, empty means none.
|
|
338
|
+
if (!declared.length)
|
|
339
|
+
return { tools: [] };
|
|
340
|
+
const allow = [...declared, ...extra.filter((name) => !declared.includes(name) && !denied.has(name))];
|
|
341
|
+
return { tools: allow };
|
|
342
|
+
}
|
|
343
|
+
const excluded = [...new Set([...deny, ...[...denied].filter((name) => !deny.includes(name))])];
|
|
344
|
+
return excluded.length ? { excludeTools: excluded } : {};
|
|
345
|
+
}
|
|
307
346
|
/** The tools an allowlist names, for display. Empty when the agent adds no allowlist of its own. */
|
|
308
347
|
export function allowedToolNames(tools) {
|
|
309
348
|
if (!isFrontmatter(tools) || tools["*"] !== false)
|
|
@@ -64,6 +64,21 @@ export declare class RunService {
|
|
|
64
64
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
65
65
|
reviewMemoryBoundary(conversationId: string): void;
|
|
66
66
|
private readonly activeRuns;
|
|
67
|
+
/**
|
|
68
|
+
* The completion of the run currently holding a conversation's slot.
|
|
69
|
+
*
|
|
70
|
+
* The slot and the promise are two halves of one fact - "this conversation has a run" and "that run is
|
|
71
|
+
* over" - and they are claimed and released together. A waiter that needs the slot (an interrupt, a
|
|
72
|
+
* send-now) AWAITS the promise rather than polling the map: polling samples a flag on a 10ms clock and
|
|
73
|
+
* has to guess at the state around it, while the promise IS the event, fired by the run's own `finally`.
|
|
74
|
+
*/
|
|
75
|
+
private readonly runCompletions;
|
|
76
|
+
/** A selected interrupt owns the next free run slot, even while the old child exits. */
|
|
77
|
+
private readonly interruptTargets;
|
|
78
|
+
/** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
|
|
79
|
+
private claimRunSlot;
|
|
80
|
+
/** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
|
|
81
|
+
private releaseRunSlot;
|
|
67
82
|
/**
|
|
68
83
|
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
69
84
|
*
|
|
@@ -162,6 +177,15 @@ export declare class RunService {
|
|
|
162
177
|
* Checks whether a conversation has an active turn running.
|
|
163
178
|
*/
|
|
164
179
|
isRunning(conversationId: string): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Checks the session credential together with the application-level turn truth.
|
|
182
|
+
*
|
|
183
|
+
* `activeRuns` is set synchronously before `runtime.answer()` is entered and cleared in that same
|
|
184
|
+
* turn's finally block. It is therefore the authoritative answer to "is there a primary turn to act
|
|
185
|
+
* for?". Keeping a second set in the runtime made correctness depend on two lifecycles agreeing and
|
|
186
|
+
* left HTTP-only paths with no owner at all.
|
|
187
|
+
*/
|
|
188
|
+
verifyPrimaryCapability(conversationId: string, capability: unknown): import("../capability.js").CapabilityVerdict;
|
|
165
189
|
/**
|
|
166
190
|
* Aborts all active conversation runs and sub-agents.
|
|
167
191
|
*/
|
|
@@ -174,6 +198,8 @@ export declare class RunService {
|
|
|
174
198
|
waitForIdle(): Promise<void>;
|
|
175
199
|
private trackDetached;
|
|
176
200
|
private serializeConversation;
|
|
201
|
+
/** Priority begins immediately after messages already handed to Pi, in their FIFO order. */
|
|
202
|
+
private moveAheadOfUnclaimed;
|
|
177
203
|
/**
|
|
178
204
|
* Stops an active turn run for a conversation.
|
|
179
205
|
*/
|
|
@@ -239,13 +265,31 @@ export declare class RunService {
|
|
|
239
265
|
steer(conversationId: string, messageId: string): Promise<void>;
|
|
240
266
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
241
267
|
sendQueued(conversationId: string, messageId: string): Promise<void>;
|
|
268
|
+
/**
|
|
269
|
+
* Waits for the run holding a conversation's slot to release it, by AWAITING ITS COMPLETION rather
|
|
270
|
+
* than polling a flag.
|
|
271
|
+
*
|
|
272
|
+
* Bounded, because a wedged run must not hang the message the user just asked for: after the deadline
|
|
273
|
+
* the caller proceeds to `processWaitingMessage`, whose slot check then refuses in words. With the
|
|
274
|
+
* session path the wait is short by construction - `runtime.stop()` has already awaited Pi going idle,
|
|
275
|
+
* so what is left is the old run's `finally` unwinding, which is local bookkeeping.
|
|
276
|
+
*
|
|
277
|
+
* `onlyWhileStopped` is `sendQueued`'s contract: it waits out the stop-clearing window after a stop,
|
|
278
|
+
* and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
|
|
279
|
+
* whenever the current answer finishes", which is what the queue is for.
|
|
280
|
+
*/
|
|
281
|
+
private awaitRunSlotReleased;
|
|
242
282
|
/**
|
|
243
283
|
* Stops the current turn and immediately gives one waiting message its own fresh turn.
|
|
244
284
|
*
|
|
245
285
|
* `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
|
|
246
|
-
* finishes. Stopping first is what makes a long-running tool give way now.
|
|
247
|
-
*
|
|
248
|
-
*
|
|
286
|
+
* finishes. Stopping first is what makes a long-running tool give way now.
|
|
287
|
+
*
|
|
288
|
+
* THE HAND-OFF IS AN AWAITED HAND-OFF, not a poll. `stopRun` aborts the turn and - on the session
|
|
289
|
+
* path - `runtime.stop()` has already awaited Pi going idle, so what remains is the old run's own
|
|
290
|
+
* `finally` unwinding. `awaitRunSlotReleased` waits for exactly that event (the run's completion
|
|
291
|
+
* promise), then the selected message opens its turn ON THE SAME SESSION: the pool's fingerprint has
|
|
292
|
+
* not changed, so no process is spawned and no history is replayed.
|
|
249
293
|
*/
|
|
250
294
|
interruptQueued(conversationId: string, messageId: string): Promise<void>;
|
|
251
295
|
/**
|
|
@@ -121,11 +121,7 @@ function composePrompt(message) {
|
|
|
121
121
|
const bodyMatch = /<task_(?:result|error)>\n?([\s\S]*?)\n?<\/(?:task_result|task_error)>/.exec(content);
|
|
122
122
|
const body = bodyMatch ? bodyMatch[1] : content;
|
|
123
123
|
const stateLabel = state === "completed" ? "已完成" : state === "error" ? "失败" : state;
|
|
124
|
-
|
|
125
|
-
// a trailing `@`-token, which is what its other tools do too). The fake-pi fixture recognises the
|
|
126
|
-
// marker and settles immediately so test paths through this turn do not hang on the BLOCKING
|
|
127
|
-
// extension-UI default. A real model treats the marker as part of the user's prose.
|
|
128
|
-
return `[后台任务「${summary}」${stateLabel}]\n\n${body}\n\n@bgdelivery:${state}`;
|
|
124
|
+
return `[后台任务「${summary}」${stateLabel}]\n\n${body}`;
|
|
129
125
|
}
|
|
130
126
|
/**
|
|
131
127
|
* Service managing execution of conversation turns, message queueing,
|
|
@@ -147,6 +143,30 @@ export class RunService {
|
|
|
147
143
|
this.memory?.review(conversation);
|
|
148
144
|
}
|
|
149
145
|
activeRuns = new Map();
|
|
146
|
+
/**
|
|
147
|
+
* The completion of the run currently holding a conversation's slot.
|
|
148
|
+
*
|
|
149
|
+
* The slot and the promise are two halves of one fact - "this conversation has a run" and "that run is
|
|
150
|
+
* over" - and they are claimed and released together. A waiter that needs the slot (an interrupt, a
|
|
151
|
+
* send-now) AWAITS the promise rather than polling the map: polling samples a flag on a 10ms clock and
|
|
152
|
+
* has to guess at the state around it, while the promise IS the event, fired by the run's own `finally`.
|
|
153
|
+
*/
|
|
154
|
+
runCompletions = new Map();
|
|
155
|
+
/** A selected interrupt owns the next free run slot, even while the old child exits. */
|
|
156
|
+
interruptTargets = new Map();
|
|
157
|
+
/** Claims the run slot and arms its completion promise. Call sites release with `releaseRunSlot`. */
|
|
158
|
+
claimRunSlot(conversationId, controller) {
|
|
159
|
+
let resolve;
|
|
160
|
+
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
|
161
|
+
this.runCompletions.set(conversationId, { promise, resolve });
|
|
162
|
+
this.activeRuns.set(conversationId, controller);
|
|
163
|
+
}
|
|
164
|
+
/** Releases the run slot and settles its completion promise, so a waiter wakes on the SAME instruction. */
|
|
165
|
+
releaseRunSlot(conversationId) {
|
|
166
|
+
this.activeRuns.delete(conversationId);
|
|
167
|
+
this.runCompletions.get(conversationId)?.resolve();
|
|
168
|
+
this.runCompletions.delete(conversationId);
|
|
169
|
+
}
|
|
150
170
|
/**
|
|
151
171
|
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
152
172
|
*
|
|
@@ -245,15 +265,13 @@ export class RunService {
|
|
|
245
265
|
// QUEUE ITEM, NOT A NOTICE. `delivery: "waiting"` is what makes the row show in the dock; without
|
|
246
266
|
// it the message is a transcript entry only and `takeNextWaitingMessage` (which `runConversationTurns`
|
|
247
267
|
// calls after every turn) never picks it up.
|
|
248
|
-
const queued = await this.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const waiting = this.store.listWaitingMessages(task.conversationId);
|
|
253
|
-
if (waiting.length > 1) {
|
|
254
|
-
await this.store.reorderWaitingMessage(task.conversationId, queued.id, 0);
|
|
268
|
+
const queued = await this.serializeConversation(task.conversationId, async () => {
|
|
269
|
+
const row = await this.store.addToolMessage(task.conversationId, message, { delivery: "waiting" });
|
|
270
|
+
// Pi already owns every `sending` row. Put a result ahead of unclaimed user messages.
|
|
271
|
+
await this.moveAheadOfUnclaimed(task.conversationId, row.id);
|
|
255
272
|
this.broadcaster.broadcastState();
|
|
256
|
-
|
|
273
|
+
return row;
|
|
274
|
+
});
|
|
257
275
|
// AUTO-TRIGGER WHEN IDLE. The conversation may already be busy (running turn, taking the next
|
|
258
276
|
// waiting message in its own follow-up loop); if so, the existing loop picks this one up and
|
|
259
277
|
// this branch is a no-op. If idle, kick off a fresh turn so the model processes the result
|
|
@@ -262,7 +280,7 @@ export class RunService {
|
|
|
262
280
|
// processes the result" contract, and the auto-triggered turn goes through the same
|
|
263
281
|
// `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
|
|
264
282
|
// prose the agent can react to).
|
|
265
|
-
if (!this.activeRuns.has(task.conversationId)) {
|
|
283
|
+
if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
266
284
|
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
267
285
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
268
286
|
});
|
|
@@ -422,10 +440,22 @@ export class RunService {
|
|
|
422
440
|
isRunning(conversationId) {
|
|
423
441
|
return this.activeRuns.has(conversationId);
|
|
424
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Checks the session credential together with the application-level turn truth.
|
|
445
|
+
*
|
|
446
|
+
* `activeRuns` is set synchronously before `runtime.answer()` is entered and cleared in that same
|
|
447
|
+
* turn's finally block. It is therefore the authoritative answer to "is there a primary turn to act
|
|
448
|
+
* for?". Keeping a second set in the runtime made correctness depend on two lifecycles agreeing and
|
|
449
|
+
* left HTTP-only paths with no owner at all.
|
|
450
|
+
*/
|
|
451
|
+
verifyPrimaryCapability(conversationId, capability) {
|
|
452
|
+
return verifyCapability(conversationId, capability, { running: this.isRunning(conversationId) });
|
|
453
|
+
}
|
|
425
454
|
/**
|
|
426
455
|
* Aborts all active conversation runs and sub-agents.
|
|
427
456
|
*/
|
|
428
457
|
abortAll() {
|
|
458
|
+
this.interruptTargets.clear();
|
|
429
459
|
this.memory?.cancelAll();
|
|
430
460
|
for (const controller of this.activeRuns.values()) {
|
|
431
461
|
controller.abort();
|
|
@@ -464,6 +494,12 @@ export class RunService {
|
|
|
464
494
|
this.conversationOps.delete(conversationId); });
|
|
465
495
|
return next;
|
|
466
496
|
}
|
|
497
|
+
/** Priority begins immediately after messages already handed to Pi, in their FIFO order. */
|
|
498
|
+
async moveAheadOfUnclaimed(conversationId, messageId) {
|
|
499
|
+
const waiting = this.store.listWaitingMessages(conversationId);
|
|
500
|
+
const lastClaimed = waiting.reduce((last, item, index) => item.delivery === "sending" ? index : last, -1);
|
|
501
|
+
await this.store.reorderWaitingMessage(conversationId, messageId, lastClaimed + 1);
|
|
502
|
+
}
|
|
467
503
|
/**
|
|
468
504
|
* Stops an active turn run for a conversation.
|
|
469
505
|
*/
|
|
@@ -996,12 +1032,12 @@ export class RunService {
|
|
|
996
1032
|
// itself as an ordinary message and now finds the slot taken - its words are on screen with nobody
|
|
997
1033
|
// going to answer them. Repairing that is what the guard below is for; it is not a rare path, it is
|
|
998
1034
|
// what two fast sends looks like from the inside.
|
|
999
|
-
const busy = this.activeRuns.has(conversation.id);
|
|
1035
|
+
const busy = this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id);
|
|
1000
1036
|
const message = await this.store.addUserMessage(conversationId, trimmed, validImages, attachments, { clientId, ...(busy ? { delivery: "waiting" } : {}) });
|
|
1001
1037
|
this.broadcaster.broadcastState();
|
|
1002
1038
|
// Claim the run slot synchronously (no await between has() and set()) so two
|
|
1003
1039
|
// rapid sends can never both pass the guard and start concurrent turns.
|
|
1004
|
-
if (this.activeRuns.has(conversation.id)) {
|
|
1040
|
+
if (this.activeRuns.has(conversation.id) || this.interruptTargets.has(conversation.id)) {
|
|
1005
1041
|
if (!busy) {
|
|
1006
1042
|
// Lost the race described above. No turn has opened on this message and no model has seen it, so
|
|
1007
1043
|
// calling it waiting is a statement of fact - and it puts the words exactly where a message born
|
|
@@ -1012,7 +1048,7 @@ export class RunService {
|
|
|
1012
1048
|
return;
|
|
1013
1049
|
}
|
|
1014
1050
|
const controller = new AbortController();
|
|
1015
|
-
this.
|
|
1051
|
+
this.claimRunSlot(conversation.id, controller);
|
|
1016
1052
|
try {
|
|
1017
1053
|
let expanded;
|
|
1018
1054
|
try {
|
|
@@ -1095,7 +1131,7 @@ export class RunService {
|
|
|
1095
1131
|
throw error;
|
|
1096
1132
|
}
|
|
1097
1133
|
finally {
|
|
1098
|
-
this.
|
|
1134
|
+
this.releaseRunSlot(conversation.id);
|
|
1099
1135
|
this.extensionUiService.cancelPendingUi(conversation.id, "expired");
|
|
1100
1136
|
this.broadcaster.broadcastState();
|
|
1101
1137
|
}
|
|
@@ -1108,26 +1144,68 @@ export class RunService {
|
|
|
1108
1144
|
}
|
|
1109
1145
|
/** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
|
|
1110
1146
|
async sendQueued(conversationId, messageId) {
|
|
1147
|
+
if (this.interruptTargets.has(conversationId))
|
|
1148
|
+
throw new Error("打断回复正在启动,请等待所选消息开始回答");
|
|
1111
1149
|
// A stop broadcasts `stopped` before the previous run has finished its finally block. Allow that
|
|
1112
1150
|
// short cleanup window; a click on the newly visible button must not race the old run slot.
|
|
1151
|
+
await this.awaitRunSlotReleased(conversationId, true);
|
|
1152
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Waits for the run holding a conversation's slot to release it, by AWAITING ITS COMPLETION rather
|
|
1156
|
+
* than polling a flag.
|
|
1157
|
+
*
|
|
1158
|
+
* Bounded, because a wedged run must not hang the message the user just asked for: after the deadline
|
|
1159
|
+
* the caller proceeds to `processWaitingMessage`, whose slot check then refuses in words. With the
|
|
1160
|
+
* session path the wait is short by construction - `runtime.stop()` has already awaited Pi going idle,
|
|
1161
|
+
* so what is left is the old run's `finally` unwinding, which is local bookkeeping.
|
|
1162
|
+
*
|
|
1163
|
+
* `onlyWhileStopped` is `sendQueued`'s contract: it waits out the stop-clearing window after a stop,
|
|
1164
|
+
* and nothing more. Waiting out an arbitrarily long RUNNING turn would turn "send now" into "send
|
|
1165
|
+
* whenever the current answer finishes", which is what the queue is for.
|
|
1166
|
+
*/
|
|
1167
|
+
async awaitRunSlotReleased(conversationId, onlyWhileStopped = false) {
|
|
1113
1168
|
const deadline = Date.now() + 15_000;
|
|
1114
|
-
while (this.activeRuns.has(conversationId) &&
|
|
1115
|
-
|
|
1169
|
+
while (this.activeRuns.has(conversationId) && Date.now() < deadline) {
|
|
1170
|
+
if (onlyWhileStopped && this.store.snapshot().conversations.find((item) => item.id === conversationId)?.status !== "stopped")
|
|
1171
|
+
return;
|
|
1172
|
+
const completion = this.runCompletions.get(conversationId)?.promise;
|
|
1173
|
+
if (!completion)
|
|
1174
|
+
return;
|
|
1175
|
+
// The completion usually wins this race by a wide margin; the timer is what re-checks the
|
|
1176
|
+
// `onlyWhileStopped` condition if the run keeps going, and what enforces the deadline.
|
|
1177
|
+
await Promise.race([completion, new Promise((resolve) => setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))))]);
|
|
1116
1178
|
}
|
|
1117
|
-
await this.processWaitingMessage(conversationId, messageId);
|
|
1118
1179
|
}
|
|
1119
1180
|
/**
|
|
1120
1181
|
* Stops the current turn and immediately gives one waiting message its own fresh turn.
|
|
1121
1182
|
*
|
|
1122
1183
|
* `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
|
|
1123
|
-
* finishes. Stopping first is what makes a long-running tool give way now.
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1184
|
+
* finishes. Stopping first is what makes a long-running tool give way now.
|
|
1185
|
+
*
|
|
1186
|
+
* THE HAND-OFF IS AN AWAITED HAND-OFF, not a poll. `stopRun` aborts the turn and - on the session
|
|
1187
|
+
* path - `runtime.stop()` has already awaited Pi going idle, so what remains is the old run's own
|
|
1188
|
+
* `finally` unwinding. `awaitRunSlotReleased` waits for exactly that event (the run's completion
|
|
1189
|
+
* promise), then the selected message opens its turn ON THE SAME SESSION: the pool's fingerprint has
|
|
1190
|
+
* not changed, so no process is spawned and no history is replayed.
|
|
1126
1191
|
*/
|
|
1127
1192
|
async interruptQueued(conversationId, messageId) {
|
|
1128
|
-
if (this.
|
|
1129
|
-
|
|
1130
|
-
|
|
1193
|
+
if (this.interruptTargets.has(conversationId))
|
|
1194
|
+
throw new Error("已有一条打断回复正在启动,请稍候");
|
|
1195
|
+
const selected = this.store.listWaitingMessages(conversationId).find((item) => item.id === messageId);
|
|
1196
|
+
if (!selected || selected.delivery !== "waiting")
|
|
1197
|
+
throw new Error("这条消息已不在等待队列中,无法打断并回答");
|
|
1198
|
+
this.interruptTargets.set(conversationId, messageId);
|
|
1199
|
+
try {
|
|
1200
|
+
if (this.activeRuns.has(conversationId))
|
|
1201
|
+
await this.stopRun(conversationId);
|
|
1202
|
+
await this.awaitRunSlotReleased(conversationId);
|
|
1203
|
+
await this.processWaitingMessage(conversationId, messageId);
|
|
1204
|
+
}
|
|
1205
|
+
finally {
|
|
1206
|
+
if (this.interruptTargets.get(conversationId) === messageId)
|
|
1207
|
+
this.interruptTargets.delete(conversationId);
|
|
1208
|
+
}
|
|
1131
1209
|
}
|
|
1132
1210
|
/**
|
|
1133
1211
|
* The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
|
|
@@ -1145,59 +1223,48 @@ export class RunService {
|
|
|
1145
1223
|
* transcript, but the prose form of the same fact is what reaches Pi.
|
|
1146
1224
|
*/
|
|
1147
1225
|
async processWaitingMessage(conversationId, messageId) {
|
|
1148
|
-
|
|
1226
|
+
// Serialize only the run claim. Holding the command lane for the entire Pi turn makes every
|
|
1227
|
+
// steer/reorder/cancel wait until that turn ends, which is especially visible after a background
|
|
1228
|
+
// result auto-starts a long follow-up. The activeRuns slot protects the turn after this lane exits.
|
|
1229
|
+
const controller = await this.serializeConversation(conversationId, async () => {
|
|
1230
|
+
const reserved = this.interruptTargets.get(conversationId);
|
|
1231
|
+
if (reserved && reserved !== messageId)
|
|
1232
|
+
throw new Error("打断回复正在启动,请等待所选消息开始回答");
|
|
1149
1233
|
if (this.activeRuns.has(conversationId))
|
|
1150
1234
|
throw new Error("当前轮仍在运行;停止或等待它结束后再发送这条消息");
|
|
1235
|
+
const message = this.store.listWaitingMessages(conversationId).find((item) => item.id === messageId);
|
|
1236
|
+
if (!message || message.delivery !== "waiting")
|
|
1237
|
+
throw new Error("等待中的消息不存在或已被处理");
|
|
1151
1238
|
const controller = new AbortController();
|
|
1152
|
-
this.
|
|
1153
|
-
|
|
1154
|
-
const { conversation, workspace } = this.turnContext(conversationId);
|
|
1155
|
-
const message = conversation.messages.find((item) => item.id === messageId);
|
|
1156
|
-
if (!message)
|
|
1157
|
-
throw new Error("消息不存在或已被撤回");
|
|
1158
|
-
// Do not call openTurnOnSpecificMessage here. runConversationTurns opens this exact first
|
|
1159
|
-
// message before streaming it, which creates one assistant placeholder and one only.
|
|
1160
|
-
await this.runConversationTurns(conversation, workspace, composePrompt(message), controller, message.id, message.images);
|
|
1161
|
-
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
|
|
1162
|
-
}
|
|
1163
|
-
catch (error) {
|
|
1164
|
-
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
|
|
1165
|
-
if (!controller.signal.aborted) {
|
|
1166
|
-
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1167
|
-
}
|
|
1168
|
-
// A preflight failure must put the message BACK where the reader can see it. Re-adding it would
|
|
1169
|
-
// duplicate the row that is still in the transcript, so this only re-marks the one that is
|
|
1170
|
-
// there - which is what makes "the send failed, this row is still in the queue" a true statement.
|
|
1171
|
-
await this.store.releaseWaitingMessage(conversationId, messageId).catch(() => undefined);
|
|
1172
|
-
throw error;
|
|
1173
|
-
}
|
|
1174
|
-
finally {
|
|
1175
|
-
this.activeRuns.delete(conversationId);
|
|
1176
|
-
this.extensionUiService.cancelPendingUi(conversationId, "expired");
|
|
1177
|
-
this.broadcaster.broadcastState();
|
|
1178
|
-
}
|
|
1239
|
+
this.claimRunSlot(conversationId, controller);
|
|
1240
|
+
return controller;
|
|
1179
1241
|
});
|
|
1180
|
-
}
|
|
1181
|
-
async steerUnsafe(conversationId, messageId) {
|
|
1182
|
-
// MOVED TO THE FRONT FIRST, ALWAYS - AND THE MOVE IS BEST-EFFORT.
|
|
1183
|
-
//
|
|
1184
|
-
// The move is what makes "插到最前" mean what it says rather than leaving a flag nobody can explain.
|
|
1185
|
-
// But the move CAN fail, and the case where it fails is a real one a person causes on purpose: two
|
|
1186
|
-
// steers in a row. The first message is already in Pi's stdin, nothing can be placed in front of it,
|
|
1187
|
-
// and the second click means "and this one too". Refusing it would break a capability the old
|
|
1188
|
-
// implementation had and would leave the person with a message that cannot be sent at all until the
|
|
1189
|
-
// turn ends - which is the thing they were trying to avoid by clicking twice.
|
|
1190
|
-
//
|
|
1191
|
-
// So the refusal is swallowed HERE and only here: being second in Pi's own FIFO queue is still a jump
|
|
1192
|
-
// past everything else that is merely waiting, which is what the second click was asking for. The
|
|
1193
|
-
// `message.reorder` command does not swallow it, because a button that says 「插到最前」 and silently
|
|
1194
|
-
// did not move anything is the promise this refactor exists to stop making.
|
|
1195
1242
|
try {
|
|
1196
|
-
|
|
1243
|
+
const { conversation, workspace } = this.turnContext(conversationId);
|
|
1244
|
+
const message = conversation.messages.find((item) => item.id === messageId);
|
|
1245
|
+
if (!message)
|
|
1246
|
+
throw new Error("消息不存在或已被撤回");
|
|
1247
|
+
// runConversationTurns opens this first message; do not create a second assistant placeholder.
|
|
1248
|
+
await this.runConversationTurns(conversation, workspace, composePrompt(message), controller, message.id, message.images);
|
|
1249
|
+
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "completed");
|
|
1197
1250
|
}
|
|
1198
|
-
catch {
|
|
1199
|
-
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
await this.store.setStatus(conversationId, controller.signal.aborted ? "stopped" : "failed");
|
|
1253
|
+
if (!controller.signal.aborted)
|
|
1254
|
+
await this.store.setFailureReason(conversationId, formatTurnFailure(error));
|
|
1255
|
+
throw error;
|
|
1200
1256
|
}
|
|
1257
|
+
finally {
|
|
1258
|
+
this.releaseRunSlot(conversationId);
|
|
1259
|
+
this.extensionUiService.cancelPendingUi(conversationId, "expired");
|
|
1260
|
+
this.broadcaster.broadcastState();
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
async steerUnsafe(conversationId, messageId) {
|
|
1264
|
+
if (this.interruptTargets.has(conversationId))
|
|
1265
|
+
throw new Error("打断回复正在启动,请稍后再插队");
|
|
1266
|
+
// Already-sent Pi steers are FIFO. Put this row directly behind them, ahead of unclaimed rows.
|
|
1267
|
+
await this.moveAheadOfUnclaimed(conversationId, messageId);
|
|
1201
1268
|
this.broadcaster.broadcastState();
|
|
1202
1269
|
if (!this.runtime.steer)
|
|
1203
1270
|
return;
|
|
@@ -1231,6 +1298,8 @@ export class RunService {
|
|
|
1231
1298
|
return this.serializeConversation(conversationId, () => this.cancelQueuedUnsafe(conversationId, messageId));
|
|
1232
1299
|
}
|
|
1233
1300
|
async cancelQueuedUnsafe(conversationId, messageId) {
|
|
1301
|
+
if (this.interruptTargets.get(conversationId) === messageId)
|
|
1302
|
+
throw new Error("这条消息正在打断并回答,无法撤回");
|
|
1234
1303
|
await this.store.cancelWaitingMessage(conversationId, messageId);
|
|
1235
1304
|
this.broadcaster.broadcastState();
|
|
1236
1305
|
}
|
|
@@ -1239,6 +1308,8 @@ export class RunService {
|
|
|
1239
1308
|
*/
|
|
1240
1309
|
async editQueued(conversationId, messageId, content) {
|
|
1241
1310
|
return this.serializeConversation(conversationId, async () => {
|
|
1311
|
+
if (this.interruptTargets.get(conversationId) === messageId)
|
|
1312
|
+
throw new Error("这条消息正在打断并回答,无法修改");
|
|
1242
1313
|
await this.store.editWaitingMessage(conversationId, messageId, content);
|
|
1243
1314
|
this.broadcaster.broadcastState();
|
|
1244
1315
|
});
|
|
@@ -1248,6 +1319,8 @@ export class RunService {
|
|
|
1248
1319
|
*/
|
|
1249
1320
|
async reorderQueued(conversationId, messageId, toIndex) {
|
|
1250
1321
|
return this.serializeConversation(conversationId, async () => {
|
|
1322
|
+
if (this.interruptTargets.get(conversationId) === messageId)
|
|
1323
|
+
throw new Error("这条消息正在打断并回答,无法移动");
|
|
1251
1324
|
await this.store.reorderWaitingMessage(conversationId, messageId, toIndex);
|
|
1252
1325
|
this.broadcaster.broadcastState();
|
|
1253
1326
|
});
|
|
@@ -1288,7 +1361,7 @@ export class RunService {
|
|
|
1288
1361
|
// BEFORE anything else, including before the conversation is looked up: a request that cannot prove it
|
|
1289
1362
|
// came from this server's primary agent gets no answer about what exists. Everything below this line
|
|
1290
1363
|
// is about shaping a dispatch; this line is about whether the caller may have one at all.
|
|
1291
|
-
const capability =
|
|
1364
|
+
const capability = this.verifyPrimaryCapability(conversationId, body.capability);
|
|
1292
1365
|
if (!capability.ok)
|
|
1293
1366
|
throw new Error(capability.message);
|
|
1294
1367
|
if (body.depth !== undefined)
|
|
@@ -1344,14 +1417,13 @@ export class RunService {
|
|
|
1344
1417
|
// still the 后台回传 column.
|
|
1345
1418
|
const message = `<task id="${run.id}" state="${run.status === "completed" ? "completed" : "error"}" kind="subagent">\n<summary>Background task ${run.status === "completed" ? "completed" : "failed"}: ${body.description?.trim() || subagentType}</summary>\n<${tag}>\n${result}</${tag}>\n</task>`;
|
|
1346
1419
|
try {
|
|
1347
|
-
const queued = await this.
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
if (waiting.length > 1) {
|
|
1351
|
-
await this.store.reorderWaitingMessage(conversation.id, queued.id, 0);
|
|
1420
|
+
const queued = await this.serializeConversation(conversation.id, async () => {
|
|
1421
|
+
const row = await this.store.addToolMessage(conversation.id, message, { delivery: "waiting" });
|
|
1422
|
+
await this.moveAheadOfUnclaimed(conversation.id, row.id);
|
|
1352
1423
|
this.broadcaster.broadcastState();
|
|
1353
|
-
|
|
1354
|
-
|
|
1424
|
+
return row;
|
|
1425
|
+
});
|
|
1426
|
+
if (!this.activeRuns.has(conversation.id) && !this.interruptTargets.has(conversation.id)) {
|
|
1355
1427
|
void this.processWaitingMessage(conversation.id, queued.id).catch((error) => {
|
|
1356
1428
|
console.warn(`Super Pi: auto-trigger for background sub-agent result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1357
1429
|
});
|
|
@@ -1365,13 +1437,11 @@ export class RunService {
|
|
|
1365
1437
|
}).catch(async (error) => {
|
|
1366
1438
|
const message = `<task id="${backgroundId}" state="error" kind="subagent">\n<summary>Background task failed: ${body.description?.trim() || subagentType}</summary>\n<task_error>Background task failed before completion: ${error instanceof Error ? error.message : "unknown error"}</task_error>\n</task>`;
|
|
1367
1439
|
try {
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
if (waiting.length > 1) {
|
|
1372
|
-
await this.store.reorderWaitingMessage(conversationId, queued.id, 0);
|
|
1440
|
+
await this.serializeConversation(conversationId, async () => {
|
|
1441
|
+
const queued = await this.store.addToolMessage(conversationId, message, { delivery: "waiting" });
|
|
1442
|
+
await this.moveAheadOfUnclaimed(conversationId, queued.id);
|
|
1373
1443
|
this.broadcaster.broadcastState();
|
|
1374
|
-
}
|
|
1444
|
+
});
|
|
1375
1445
|
// No auto-trigger on this failure branch (the result envelope says `state="error"` and the
|
|
1376
1446
|
// conversation is by construction not in a turn that asked for it). The row is visible in the
|
|
1377
1447
|
// dock for the reader to send-now or cancel.
|
|
@@ -1424,7 +1494,7 @@ export class RunService {
|
|
|
1424
1494
|
// Same rule as `dispatchTask`, and for a larger reason: this endpoint triggers a SEQUENCE of
|
|
1425
1495
|
// dispatches, so a caller that cannot prove it is the primary agent would get an unattended pipeline
|
|
1426
1496
|
// of sub-agents writing to the workspace rather than one delegation.
|
|
1427
|
-
const capability =
|
|
1497
|
+
const capability = this.verifyPrimaryCapability(conversationId, body.capability);
|
|
1428
1498
|
if (!capability.ok)
|
|
1429
1499
|
throw new Error(capability.message);
|
|
1430
1500
|
const { conversation, cwd } = this.dispatchContext(conversationId);
|
|
@@ -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;
|