@evandu/super-pi 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/agents/super-pi-task/core.ts +23 -0
- package/agents/super-pi-task/index.ts +8 -6
- package/dist/client/.super-pi-client-sources.json +25 -0
- package/dist/client/assets/index-C7wCG7Jn.css +1 -0
- package/dist/client/assets/index-DCtDhAuw.js +58 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +45 -0
- package/dist/server/application/run-service.js +139 -5
- package/dist/server/http-server.js +158 -75
- package/dist/server/runtime.d.ts +5 -2
- package/dist/server/runtime.js +7 -1
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +9 -2
- package/dist/server/subagent-runs.d.ts +8 -0
- package/dist/server/subagent-runs.js +1 -1
- package/dist/server/tool-runner.js +3 -3
- package/dist/server/types.d.ts +42 -0
- package/dist/server/ws-server.js +11 -4
- package/package.json +4 -2
- package/dist/client/assets/index-CHRkbLTs.js +0 -59
- package/dist/client/assets/index-CKdlMXJt.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DCtDhAuw.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C7wCG7Jn.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -17,6 +17,18 @@ export interface SubagentDispatchRequestBody {
|
|
|
17
17
|
role?: string;
|
|
18
18
|
/** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
|
|
19
19
|
capability?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Pi's id for the tool call that asked for this dispatch, which the extension already receives as
|
|
22
|
+
* `execute`'s first argument and previously discarded. The server resolves it to a position in the
|
|
23
|
+
* dispatching message so the card renders AT THE CALL rather than at a point in time.
|
|
24
|
+
*
|
|
25
|
+
* UNVERIFIED AND THEREFORE INERT: it is a claim about which row a card belongs in, not a credential,
|
|
26
|
+
* so it is checked against nothing. An id that resolves to no call is simply no anchor. That is
|
|
27
|
+
* deliberate - this field crosses a boundary a model can reach, and a sub-agent inventing an id to park
|
|
28
|
+
* its card inside somebody else's tool call would be a way to make a run look like it belongs to work it
|
|
29
|
+
* never did. A position is not a permission, and it grants none.
|
|
30
|
+
*/
|
|
31
|
+
toolCallId?: string;
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* A whole orchestrated plan, as it arrives over the wire.
|
|
@@ -32,6 +44,9 @@ export interface WorkflowRunRequestBody {
|
|
|
32
44
|
steps?: unknown;
|
|
33
45
|
/** The per-turn dispatch capability, presented by the primary agent's extension. See `capability.ts`. */
|
|
34
46
|
capability?: string;
|
|
47
|
+
/** Pi's id for the tool call that asked for this, and resolved the same inert way as a sub-agent
|
|
48
|
+
* dispatch's - a position in the transcript, never a permission. */
|
|
49
|
+
toolCallId?: string;
|
|
35
50
|
}
|
|
36
51
|
/**
|
|
37
52
|
* Service managing execution of conversation turns, message queueing,
|
|
@@ -49,6 +64,36 @@ export declare class RunService {
|
|
|
49
64
|
setMemory(memory: import("../active-memory.js").ActiveMemory): void;
|
|
50
65
|
reviewMemoryBoundary(conversationId: string): void;
|
|
51
66
|
private readonly activeRuns;
|
|
67
|
+
/**
|
|
68
|
+
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
69
|
+
*
|
|
70
|
+
* Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
|
|
71
|
+
* first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
|
|
72
|
+
* call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
|
|
73
|
+
* SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
|
|
74
|
+
* a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
|
|
75
|
+
* HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
|
|
76
|
+
* a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
|
|
77
|
+
*
|
|
78
|
+
* The outer map is per conversation rather than global so two conversations cannot read each other's
|
|
79
|
+
* positions, and it is dropped when the turn ends because that is when the fact stops being true: a
|
|
80
|
+
* dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
|
|
81
|
+
*/
|
|
82
|
+
private readonly toolCallLanes;
|
|
83
|
+
private readonly toolCallWaiters;
|
|
84
|
+
private readonly toolCallSites;
|
|
85
|
+
/** Records that Pi's `toolCallId` became row `row` of `messageId`. */
|
|
86
|
+
private noteToolCallSite;
|
|
87
|
+
/**
|
|
88
|
+
* The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
|
|
89
|
+
* there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
|
|
90
|
+
* extension has no id, and a dispatch that landed after its turn ended has outlived the map.
|
|
91
|
+
*/
|
|
92
|
+
originForToolCall(conversationId: string, toolCallId: string | undefined): {
|
|
93
|
+
originMessageId: string;
|
|
94
|
+
originCallRow: number;
|
|
95
|
+
} | undefined;
|
|
96
|
+
waitForToolCall(conversationId: string, toolCallId: string | undefined): Promise<ReturnType<RunService["originForToolCall"]>>;
|
|
52
97
|
private execService;
|
|
53
98
|
setExecService(execService: import("../exec-service.js").ExecService): void;
|
|
54
99
|
/**
|
|
@@ -58,6 +58,70 @@ export class RunService {
|
|
|
58
58
|
this.memory?.review(conversation);
|
|
59
59
|
}
|
|
60
60
|
activeRuns = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* WHERE EACH TOOL CALL LANDED, per conversation, for as long as the turn that made it is still running.
|
|
63
|
+
*
|
|
64
|
+
* Keyed by Pi's own `toolCallId` - the id the model sees, which Pi also hands to the tool as `execute`'s
|
|
65
|
+
* first argument. That is the only handle shared by the two halves of a dispatch: the server learns the
|
|
66
|
+
* call exists while streaming the turn, and learns it was a sub-agent only when the child, running in a
|
|
67
|
+
* SEPARATE process, posts back over HTTP. `rowByToolCallId` already maps the same key to a row, but it is
|
|
68
|
+
* a local of `streamTurn` and the dispatch arrives in `dispatchTask` - a different closure, reached by an
|
|
69
|
+
* HTTP request. Without a bridge between them the server could only stamp a card with a start time, and
|
|
70
|
+
* a card sorted by start time lands BELOW every message still streaming after the call that asked for it.
|
|
71
|
+
*
|
|
72
|
+
* The outer map is per conversation rather than global so two conversations cannot read each other's
|
|
73
|
+
* positions, and it is dropped when the turn ends because that is when the fact stops being true: a
|
|
74
|
+
* dispatch that arrives after its turn has finished gets no anchor, which renders as it always did.
|
|
75
|
+
*/
|
|
76
|
+
toolCallLanes = new Map();
|
|
77
|
+
toolCallWaiters = new Map();
|
|
78
|
+
toolCallSites = new Map();
|
|
79
|
+
/** Records that Pi's `toolCallId` became row `row` of `messageId`. */
|
|
80
|
+
noteToolCallSite(conversationId, toolCallId, messageId, row) {
|
|
81
|
+
if (!toolCallId)
|
|
82
|
+
return;
|
|
83
|
+
let sites = this.toolCallSites.get(conversationId);
|
|
84
|
+
if (!sites) {
|
|
85
|
+
sites = new Map();
|
|
86
|
+
this.toolCallSites.set(conversationId, sites);
|
|
87
|
+
}
|
|
88
|
+
sites.set(toolCallId, { messageId, row });
|
|
89
|
+
for (const wake of this.toolCallWaiters.get(`${conversationId}:${toolCallId}`) ?? [])
|
|
90
|
+
wake();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The call a dispatch belongs to, as the two fields a card carries. Absent - rather than row zero - when
|
|
94
|
+
* there is no call to point at: a person pressing 派子智能体 has no tool call, a dispatch from an older
|
|
95
|
+
* extension has no id, and a dispatch that landed after its turn ended has outlived the map.
|
|
96
|
+
*/
|
|
97
|
+
originForToolCall(conversationId, toolCallId) {
|
|
98
|
+
if (!toolCallId)
|
|
99
|
+
return undefined;
|
|
100
|
+
const site = this.toolCallSites.get(conversationId)?.get(toolCallId);
|
|
101
|
+
return site ? { originMessageId: site.messageId, originCallRow: site.row } : undefined;
|
|
102
|
+
}
|
|
103
|
+
async waitForToolCall(conversationId, toolCallId) {
|
|
104
|
+
const origin = this.originForToolCall(conversationId, toolCallId);
|
|
105
|
+
if (origin) {
|
|
106
|
+
await this.toolCallLanes.get(conversationId);
|
|
107
|
+
return origin;
|
|
108
|
+
}
|
|
109
|
+
if (!toolCallId || !this.activeRuns.has(conversationId))
|
|
110
|
+
return undefined;
|
|
111
|
+
// The tool POST and Pi's stdout use independent transports. HTTP may win even
|
|
112
|
+
// though Pi emitted tool_execution_start first; wait for that event, not a clock position.
|
|
113
|
+
const key = `${conversationId}:${toolCallId}`;
|
|
114
|
+
await new Promise(resolve => {
|
|
115
|
+
const waiters = this.toolCallWaiters.get(key) ?? new Set();
|
|
116
|
+
const wake = () => { clearTimeout(timer); waiters.delete(wake); if (!waiters.size)
|
|
117
|
+
this.toolCallWaiters.delete(key); resolve(); };
|
|
118
|
+
const timer = setTimeout(wake, 2000);
|
|
119
|
+
waiters.add(wake);
|
|
120
|
+
this.toolCallWaiters.set(key, waiters);
|
|
121
|
+
});
|
|
122
|
+
await this.toolCallLanes.get(conversationId);
|
|
123
|
+
return this.originForToolCall(conversationId, toolCallId);
|
|
124
|
+
}
|
|
61
125
|
execService;
|
|
62
126
|
setExecService(execService) { this.execService = execService; }
|
|
63
127
|
/**
|
|
@@ -78,6 +142,15 @@ export class RunService {
|
|
|
78
142
|
const message = `<task id="${sessionId}" state="${exitCode === 0 ? "completed" : "error"}" kind="exec">\n<summary>${task.title}</summary>\n<${tag}>\n${body}</${tag}>\n</task>`;
|
|
79
143
|
void this.trackDetached((async () => {
|
|
80
144
|
try {
|
|
145
|
+
// Keep the dispatch card at its call after the running-task list is cleaned up.
|
|
146
|
+
// The result message below keeps its separate arrival position in the transcript.
|
|
147
|
+
if (task.originMessageId !== undefined && task.originCallRow !== undefined
|
|
148
|
+
&& this.store.snapshot().conversations.find(conversation => conversation.id === task.conversationId)
|
|
149
|
+
?.messages.find(message => message.id === task.originMessageId)?.toolCalls?.[task.originCallRow]) {
|
|
150
|
+
const toolCalls = await this.store.updateToolCall(task.conversationId, task.originMessageId, task.originCallRow, { backgroundTask: { id: sessionId, status: exitCode === 0 ? "completed" : "failed", output: (stdout + stderr).slice(-512_000), exitCode } });
|
|
151
|
+
this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
|
|
152
|
+
}
|
|
153
|
+
this.broadcaster.broadcast({ type: "exec.exit", sessionId, exitCode, stdout, stderr });
|
|
81
154
|
await this.store.addToolMessage(task.conversationId, message);
|
|
82
155
|
this.broadcaster.broadcastState();
|
|
83
156
|
}
|
|
@@ -367,6 +440,7 @@ export class RunService {
|
|
|
367
440
|
}).catch((error) => {
|
|
368
441
|
console.warn(`Super Pi: recording ${what} failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
369
442
|
});
|
|
443
|
+
this.toolCallLanes.set(conversation.id, pendingTools);
|
|
370
444
|
};
|
|
371
445
|
// HOW MUCH OF THE ANSWER HAD BEEN SPOKEN WHEN EACH CALL WAS ANNOUNCED, counted HERE rather than read
|
|
372
446
|
// from the store when the write runs.
|
|
@@ -383,6 +457,18 @@ export class RunService {
|
|
|
383
457
|
// The running total is what lets a call made during reasoning anchor inside the thinking block rather than
|
|
384
458
|
// after it, which is the order the events had.
|
|
385
459
|
let thought = 0;
|
|
460
|
+
let thinkingActive = false;
|
|
461
|
+
let thinkingBlock = -1;
|
|
462
|
+
let thinkingAnswerAt = -1;
|
|
463
|
+
let thinkingInterrupted = false;
|
|
464
|
+
const currentThinkingBlock = () => {
|
|
465
|
+
if (thinkingActive)
|
|
466
|
+
return thinkingBlock;
|
|
467
|
+
// An outside call separates two thoughts even if no answer text was emitted.
|
|
468
|
+
if (thinkingBlock >= 0)
|
|
469
|
+
thinkingInterrupted = true;
|
|
470
|
+
return null;
|
|
471
|
+
};
|
|
386
472
|
/**
|
|
387
473
|
* ONE COUNTER FOR EVERY ROW, AND THE ONLY WAY A ROW NUMBER IS EVER MADE.
|
|
388
474
|
*
|
|
@@ -432,6 +518,7 @@ export class RunService {
|
|
|
432
518
|
// this one was not, and the JSON persistence backend's slower writes widen the window enough for
|
|
433
519
|
// the browser test to catch the difference between capturing and reading.
|
|
434
520
|
const thinkingAt = thought;
|
|
521
|
+
const callThinkingBlock = currentThinkingBlock();
|
|
435
522
|
// The block says WHICH TOOL and WHAT IT IS AIMED AT as soon as either is known. 「工具」 as the name
|
|
436
523
|
// and 「正在生成参数…」 as the only line below it told a reader nothing for the seconds a large `edit`
|
|
437
524
|
// spends writing its replacement text - and by the time the file path was on screen, the block looked
|
|
@@ -443,6 +530,7 @@ export class RunService {
|
|
|
443
530
|
preparing: true,
|
|
444
531
|
at,
|
|
445
532
|
thinkingAt,
|
|
533
|
+
thinkingBlock: callThinkingBlock,
|
|
446
534
|
}));
|
|
447
535
|
preparingRows.set(call.contentIndex, { row, name: call.name });
|
|
448
536
|
};
|
|
@@ -466,18 +554,23 @@ export class RunService {
|
|
|
466
554
|
// The id is bound to the row it was FILLED IN, not to a row of the caller's own counting. Every
|
|
467
555
|
// later update and the outcome resolve through this binding, so a wrong number here is a block
|
|
468
556
|
// that never settles and one that belongs to a different call.
|
|
469
|
-
if (call.id)
|
|
557
|
+
if (call.id) {
|
|
470
558
|
rowByToolCallId.set(call.id, row);
|
|
559
|
+
this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
|
|
560
|
+
}
|
|
471
561
|
enqueueTools("a tool call", () => this.store.updateToolCall(conversation.id, assistant.id, row, { label: call.name, detail: call.detail, args: call.args, live: "", preparing: false, ...(call.question ? { question: call.question } : {}) }));
|
|
472
562
|
return;
|
|
473
563
|
}
|
|
474
564
|
const row = rows++;
|
|
475
|
-
if (call.id)
|
|
565
|
+
if (call.id) {
|
|
476
566
|
rowByToolCallId.set(call.id, row);
|
|
567
|
+
this.noteToolCallSite(conversation.id, call.id, assistant.id, row);
|
|
568
|
+
}
|
|
477
569
|
// Captured for the same reason as the preparing path above: the queued append must anchor the call
|
|
478
570
|
// where it was MADE, not where the queue happened to be when it drained.
|
|
479
571
|
const thinkingAt = thought;
|
|
480
|
-
|
|
572
|
+
const callThinkingBlock = currentThinkingBlock();
|
|
573
|
+
enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
|
|
481
574
|
};
|
|
482
575
|
const onToolUpdate = (call) => {
|
|
483
576
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
@@ -508,12 +601,36 @@ export class RunService {
|
|
|
508
601
|
// BROADCAST AND KEEP. It used to only broadcast, into an event no one handled - so the reasoning
|
|
509
602
|
// reached nobody, live or afterwards. Both halves now happen: the browser draws the delta as it
|
|
510
603
|
// arrives, and the message keeps it so the finished turn has something to go back to.
|
|
604
|
+
//
|
|
605
|
+
// `active: true` on every delta is not a field Pi sent - it is the conclusion Super Pi draws from
|
|
606
|
+
// having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
|
|
607
|
+
// the delta, so these two halves together are the whole lifecycle.
|
|
511
608
|
onThinking: (delta) => {
|
|
609
|
+
if (!delta)
|
|
610
|
+
return;
|
|
611
|
+
const newBlock = thinkingAnswerAt !== spoken || thinkingInterrupted;
|
|
612
|
+
if (newBlock) {
|
|
613
|
+
thinkingBlock++;
|
|
614
|
+
thinkingAnswerAt = spoken;
|
|
615
|
+
}
|
|
616
|
+
thinkingInterrupted = false;
|
|
617
|
+
thinkingActive = true;
|
|
618
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, true);
|
|
512
619
|
thought += delta.length;
|
|
513
|
-
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken);
|
|
514
|
-
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken });
|
|
620
|
+
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
621
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
515
622
|
scheduleCheckpoint();
|
|
516
623
|
},
|
|
624
|
+
// AN EMPTY DELTA THAT ONLY CARRIES THE FLAG. `thinking_end` is a fact the browser needs and not
|
|
625
|
+
// text, so it rides as a delta of length zero and the client is required to leave `thinking` and
|
|
626
|
+
// `thinkingBlocks` alone when it sees one. Without that rule this event appends an EMPTY fold
|
|
627
|
+
// block to every turn whose second reasoning run starts after the answer has already begun,
|
|
628
|
+
// because that block's `at` then differs from the previous one's.
|
|
629
|
+
onThinkingEnd: () => {
|
|
630
|
+
thinkingActive = false;
|
|
631
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
632
|
+
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta: "", active: false });
|
|
633
|
+
},
|
|
517
634
|
images,
|
|
518
635
|
})) {
|
|
519
636
|
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
@@ -529,8 +646,20 @@ export class RunService {
|
|
|
529
646
|
finally {
|
|
530
647
|
// A failed or cancelled producer still owns live text and a checkpoint timer.
|
|
531
648
|
// Drain the lane before the caller changes status or permits another turn.
|
|
649
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
650
|
+
checkpointPending = true;
|
|
532
651
|
checkpointNow();
|
|
533
652
|
await pendingTools;
|
|
653
|
+
// The positions are facts ABOUT THIS TURN and stop being true when it ends, so they go with it.
|
|
654
|
+
// A dispatch whose POST arrives after this line gets no anchor and renders where it always did -
|
|
655
|
+
// which is the honest outcome, because by then the call that asked for it may no longer be on screen.
|
|
656
|
+
this.toolCallSites.delete(conversation.id);
|
|
657
|
+
this.toolCallLanes.delete(conversation.id);
|
|
658
|
+
for (const [key, waiters] of this.toolCallWaiters) {
|
|
659
|
+
if (key.startsWith(`${conversation.id}:`))
|
|
660
|
+
for (const wake of waiters)
|
|
661
|
+
wake();
|
|
662
|
+
}
|
|
534
663
|
}
|
|
535
664
|
if (!controller.signal.aborted) {
|
|
536
665
|
const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
|
|
@@ -741,6 +870,9 @@ export class RunService {
|
|
|
741
870
|
if (parent)
|
|
742
871
|
checkSubagentDepth(1);
|
|
743
872
|
const backgroundId = body.background === true ? newSubagentRunId() : undefined;
|
|
873
|
+
// WHERE THE CARD GOES. Resolved once, here, and written onto the record at creation - the run is born
|
|
874
|
+
// knowing where it was asked for, so no later update has to supply it and a card cannot drift.
|
|
875
|
+
const origin = await this.waitForToolCall(conversationId, body.toolCallId);
|
|
744
876
|
const dispatch = this.subagents.dispatch({
|
|
745
877
|
...(backgroundId ? { id: backgroundId } : {}),
|
|
746
878
|
conversationId: conversation.id,
|
|
@@ -749,6 +881,7 @@ export class RunService {
|
|
|
749
881
|
cwd,
|
|
750
882
|
model: conversation.model,
|
|
751
883
|
dispatchedBy: "model",
|
|
884
|
+
...(origin ? origin : {}),
|
|
752
885
|
...(parent ? { parentRunId: parent.id } : {}),
|
|
753
886
|
depth: authoritativeDepth,
|
|
754
887
|
...(body.loopId ? { loopId: body.loopId } : {}),
|
|
@@ -764,6 +897,7 @@ export class RunService {
|
|
|
764
897
|
status: "running",
|
|
765
898
|
startedAt: new Date().toISOString(),
|
|
766
899
|
resultBlockId: backgroundId,
|
|
900
|
+
...(origin ? origin : {}),
|
|
767
901
|
});
|
|
768
902
|
this.broadcaster.broadcastState();
|
|
769
903
|
void this.trackDetached(dispatch.then(async (run) => {
|
|
@@ -18,6 +18,41 @@ const MIME_TYPES = {
|
|
|
18
18
|
".ico": "image/x-icon",
|
|
19
19
|
".png": "image/png",
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* The only browser API a mounted tool needs. It is served beneath the tool's own
|
|
23
|
+
* mount, so every request stays relative to that mount and the server can derive
|
|
24
|
+
* the tool identity without exposing a registry id, a workspace, or a credential.
|
|
25
|
+
*/
|
|
26
|
+
function toolBrowserSdk() {
|
|
27
|
+
return String.raw `(() => {
|
|
28
|
+
const request = async (path, init) => {
|
|
29
|
+
const response = await fetch(path, init);
|
|
30
|
+
let body;
|
|
31
|
+
try { body = await response.json(); } catch { body = { error: "Super Pi returned an unreadable response" }; }
|
|
32
|
+
if (!response.ok) throw new Error(body && body.error ? body.error : "Tool request failed (HTTP " + response.status + ")");
|
|
33
|
+
return body;
|
|
34
|
+
};
|
|
35
|
+
const api = {
|
|
36
|
+
run: (params = {}) => request("./api/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params }) }),
|
|
37
|
+
get: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "GET" }),
|
|
38
|
+
cancel: (runId) => request("./api/runs/" + encodeURIComponent(runId), { method: "DELETE" }),
|
|
39
|
+
wait: async (runId, { intervalMs = 250, signal } = {}) => {
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
42
|
+
const run = await api.get(runId);
|
|
43
|
+
if (run.status !== "running") return run;
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
const timer = setTimeout(resolve, Math.max(50, intervalMs));
|
|
46
|
+
if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
runAndWait: async (params = {}, options) => { const run = await api.run(params); return api.wait(run.id, options); },
|
|
51
|
+
};
|
|
52
|
+
Object.freeze(api);
|
|
53
|
+
Object.defineProperty(window, "SuperPiTool", { value: api, writable: false, configurable: false });
|
|
54
|
+
})();`;
|
|
55
|
+
}
|
|
21
56
|
/**
|
|
22
57
|
* Checks if the remote address is a local loopback interface.
|
|
23
58
|
*/
|
|
@@ -138,17 +173,18 @@ export function createHttpServer(options) {
|
|
|
138
173
|
return createServer(async (request, response) => {
|
|
139
174
|
// Internal tools use a narrow per-run capability, never the global management token.
|
|
140
175
|
const requestPath = request.url?.split("?")[0] ?? "";
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
176
|
+
// Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
|
|
177
|
+
// there, while an interactive browser uses the ordinary server access token. Reading and DELETING
|
|
178
|
+
// the registry have no such second channel, so they are NOT exempted here and fall through to the
|
|
179
|
+
// ordinary token check below - exempting the whole path is what let any local process list and
|
|
180
|
+
// delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
|
|
181
|
+
if (requestPath !== "/api/memory/tool" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
145
182
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
146
183
|
return;
|
|
147
184
|
}
|
|
148
185
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
149
186
|
const remote = request.socket.remoteAddress ?? "";
|
|
150
187
|
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
|
|
151
|
-
const toolJson = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" }); response.end(JSON.stringify(body)); };
|
|
152
188
|
if (url.pathname === "/api/tools") {
|
|
153
189
|
try {
|
|
154
190
|
if (request.method === "GET") {
|
|
@@ -161,7 +197,10 @@ export function createHttpServer(options) {
|
|
|
161
197
|
json(400, { error: "id required" });
|
|
162
198
|
return;
|
|
163
199
|
}
|
|
164
|
-
|
|
200
|
+
const removed = await toolRegistry.remove(id);
|
|
201
|
+
if (removed)
|
|
202
|
+
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
203
|
+
json(removed ? 200 : 404, { ok: removed });
|
|
165
204
|
return;
|
|
166
205
|
}
|
|
167
206
|
if (request.method !== "POST") {
|
|
@@ -203,97 +242,128 @@ export function createHttpServer(options) {
|
|
|
203
242
|
}
|
|
204
243
|
}
|
|
205
244
|
if (url.pathname.startsWith("/tools/")) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
|
|
209
|
-
const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
|
|
210
|
-
if (!listed) {
|
|
211
|
-
response.writeHead(404).end("Tool not found");
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
245
|
+
let id;
|
|
246
|
+
let resource;
|
|
214
247
|
try {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" });
|
|
221
|
-
createReadStream(resolved.file).pipe(response);
|
|
222
|
-
return;
|
|
248
|
+
const parts = url.pathname.slice("/tools/".length).split("/");
|
|
249
|
+
id = decodeURIComponent(parts.shift() ?? "");
|
|
250
|
+
resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
|
|
223
251
|
}
|
|
224
252
|
catch {
|
|
225
|
-
response.writeHead(
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (url.pathname.startsWith("/api/tools/") && url.pathname.endsWith("/run")) {
|
|
230
|
-
const id = decodeURIComponent(url.pathname.slice("/api/tools/".length, -"/run".length));
|
|
231
|
-
if (!opaqueToolApi) {
|
|
232
|
-
toolJson(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
253
|
+
response.writeHead(400).end("Malformed tool resource");
|
|
233
254
|
return;
|
|
234
255
|
}
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
|
|
257
|
+
if (!listed) {
|
|
258
|
+
response.writeHead(404).end("Tool not found");
|
|
237
259
|
return;
|
|
238
260
|
}
|
|
239
|
-
|
|
240
|
-
|
|
261
|
+
// A mounted tool never learns its random registry id. Its API is instead addressed relative
|
|
262
|
+
// to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
|
|
263
|
+
// making a generated tool usable on its first render, this binds status and cancellation to the
|
|
264
|
+
// same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
|
|
265
|
+
const toolOrigin = request.headers.origin === "null";
|
|
266
|
+
const toolApi = (status, body) => {
|
|
267
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
|
|
268
|
+
response.end(JSON.stringify(body));
|
|
269
|
+
};
|
|
270
|
+
if (resource === "api/run") {
|
|
271
|
+
if (!toolOrigin) {
|
|
272
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (request.method === "OPTIONS") {
|
|
276
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (request.method !== "POST") {
|
|
280
|
+
toolApi(405, { error: "method" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let raw = "";
|
|
284
|
+
for await (const chunk of request) {
|
|
285
|
+
raw += chunk;
|
|
286
|
+
if (raw.length > 64_000) {
|
|
287
|
+
toolApi(413, { error: "request too large" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const body = JSON.parse(raw);
|
|
293
|
+
const resolved = await toolRegistry.resolve(id);
|
|
294
|
+
if (!resolved?.tool.script) {
|
|
295
|
+
toolApi(404, { error: "python script is not registered" });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {} });
|
|
299
|
+
toolApi(202, run);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
toolApi(400, { error: error instanceof Error ? error.message : "tool run failed" });
|
|
303
|
+
}
|
|
241
304
|
return;
|
|
242
305
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
json(413, { error: "request too large" });
|
|
306
|
+
const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
|
|
307
|
+
if (runMatch) {
|
|
308
|
+
if (!toolOrigin) {
|
|
309
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
248
310
|
return;
|
|
249
311
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const body = JSON.parse(raw);
|
|
253
|
-
const resolved = await toolRegistry.resolve(id);
|
|
254
|
-
if (!resolved?.tool.script) {
|
|
255
|
-
toolJson(404, { error: "python script is not registered" });
|
|
312
|
+
if (request.method === "OPTIONS") {
|
|
313
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
256
314
|
return;
|
|
257
315
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
316
|
+
let runId;
|
|
317
|
+
try {
|
|
318
|
+
runId = decodeURIComponent(runMatch[1]);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (request.method === "GET") {
|
|
325
|
+
const run = toolRunner.get(runId, id);
|
|
326
|
+
if (!run) {
|
|
327
|
+
toolApi(404, { error: "run not found" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
toolApi(200, run);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (request.method === "DELETE") {
|
|
334
|
+
const run = toolRunner.cancel(runId, id);
|
|
335
|
+
if (!run) {
|
|
336
|
+
toolApi(404, { error: "run not found" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
toolApi(200, run);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
toolApi(405, { error: "method" });
|
|
271
343
|
return;
|
|
272
344
|
}
|
|
273
|
-
if (
|
|
274
|
-
response.writeHead(
|
|
345
|
+
if (resource === "super-pi-tool.js") {
|
|
346
|
+
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
|
|
275
347
|
return;
|
|
276
348
|
}
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
if (!
|
|
280
|
-
|
|
349
|
+
try {
|
|
350
|
+
const resolved = await toolRegistry.resolve(id, resource);
|
|
351
|
+
if (!resolved) {
|
|
352
|
+
response.writeHead(404).end("Tool not found");
|
|
281
353
|
return;
|
|
282
354
|
}
|
|
283
|
-
|
|
355
|
+
// Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
|
|
356
|
+
// `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
|
|
357
|
+
// does not authorize a request back to this server, even for a relative URL.
|
|
358
|
+
const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
|
|
359
|
+
response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
|
|
360
|
+
createReadStream(resolved.file).pipe(response);
|
|
284
361
|
return;
|
|
285
362
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (!run) {
|
|
289
|
-
toolJson(404, { error: "run not found" });
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
toolJson(200, run);
|
|
363
|
+
catch {
|
|
364
|
+
response.writeHead(404).end("Tool file not found");
|
|
293
365
|
return;
|
|
294
366
|
}
|
|
295
|
-
toolJson(405, { error: "method" });
|
|
296
|
-
return;
|
|
297
367
|
}
|
|
298
368
|
/**
|
|
299
369
|
* THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
|
|
@@ -523,7 +593,7 @@ export function createHttpServer(options) {
|
|
|
523
593
|
json(400, { error: "invalid JSON" });
|
|
524
594
|
return;
|
|
525
595
|
}
|
|
526
|
-
const { cmd, cwd, conversationId, capability } = body;
|
|
596
|
+
const { cmd, cwd, conversationId, capability, toolCallId } = body;
|
|
527
597
|
if (!cmd || !cwd || !conversationId) {
|
|
528
598
|
json(400, { error: "cmd, cwd and conversationId are required" });
|
|
529
599
|
return;
|
|
@@ -546,6 +616,10 @@ export function createHttpServer(options) {
|
|
|
546
616
|
// Set up the background task record BEFORE spawning, so the UI shows it immediately
|
|
547
617
|
const { randomUUID } = await import("node:crypto");
|
|
548
618
|
const sessionId = `exec_${randomUUID()}`;
|
|
619
|
+
// The same anchor a sub-agent card carries, for the same reason: a background command asked for
|
|
620
|
+
// inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
|
|
621
|
+
// is worth reading only from a caller already inside the turn.
|
|
622
|
+
const origin = await runService.waitForToolCall(conversationId, toolCallId);
|
|
549
623
|
await store.setBackgroundTask({
|
|
550
624
|
id: sessionId,
|
|
551
625
|
conversationId,
|
|
@@ -554,13 +628,22 @@ export function createHttpServer(options) {
|
|
|
554
628
|
status: "running",
|
|
555
629
|
startedAt: new Date().toISOString(),
|
|
556
630
|
resultBlockId: sessionId,
|
|
631
|
+
...(origin ? origin : {}),
|
|
557
632
|
});
|
|
633
|
+
if (origin) {
|
|
634
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
|
|
635
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
636
|
+
}
|
|
558
637
|
broadcaster.broadcastState();
|
|
559
638
|
try {
|
|
560
639
|
execService.spawn(sessionId, cmd, cwd);
|
|
561
640
|
json(202, { sessionId, status: "running" });
|
|
562
641
|
}
|
|
563
642
|
catch (error) {
|
|
643
|
+
if (origin) {
|
|
644
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
|
|
645
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
646
|
+
}
|
|
564
647
|
await store.removeBackgroundTask(sessionId);
|
|
565
648
|
broadcaster.broadcastState();
|
|
566
649
|
json(500, { error: error instanceof Error ? error.message : "spawn failed" });
|