privateer-agent 0.15.3 → 0.15.5
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/bin/fresh-supervisor.d.mts +5 -1
- package/bin/fresh-supervisor.mjs +47 -10
- package/bin/privateer-launch.mjs +13 -1
- package/bin/privateer-subagent.mjs +5 -2
- package/bin/session-recovery.d.mts +53 -0
- package/bin/session-recovery.mjs +240 -0
- package/bin/session-seal-preload.mjs +17 -0
- package/bin/session-seal.d.mts +25 -0
- package/bin/session-seal.mjs +242 -0
- package/extensions/privateer-gate.ts +75 -3
- package/extensions/privateer-recovery.ts +175 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +89 -0
- package/src/cli/chat.ts +7 -1
- package/src/config/moatManifest.json +1 -0
- package/src/config/whatsNew.json +4 -4
- package/src/permissions/classify.ts +11 -4
- package/src/remote/attentionPush.ts +40 -0
- package/src/remote/liveTaskSession.ts +7 -1
- package/src/remote/relayClient.ts +38 -4
- package/src/remote/remoteBridge.ts +17 -0
- package/src/remote/transcriptSnapshot.ts +104 -0
- package/src/tools/media.ts +291 -49
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
27
27
|
{ "entry": "extensions/privateer-truncation.ts", "name": "privateer-truncation", "note": "picks the task back up when a reply is cut off at the output limit" },
|
|
28
28
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
29
|
+
{ "entry": "extensions/privateer-recovery.ts", "name": "privateer-recovery", "note": "crash recovery — the live marker the supervisor resumes from, the note that tells the model its last action may have half-run, and session-sealing warnings" },
|
|
29
30
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
30
31
|
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
|
|
31
32
|
{ "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.15.
|
|
3
|
+
"reviewedFor": "0.15.5",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "If the agent crashes mid-task it comes back into your session, and saved sessions are now encrypted on disk", "since": "0.15.5" },
|
|
6
|
+
{ "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
|
|
5
7
|
{ "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
|
|
6
|
-
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
|
|
7
|
-
{ "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" },
|
|
8
|
-
{ "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" }
|
|
8
|
+
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -234,6 +234,14 @@ const MEDIA_TITLES: Record<string, string> = {
|
|
|
234
234
|
// rather than their names (see `resuming` below).
|
|
235
235
|
const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
|
|
236
236
|
const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
|
|
237
|
+
const RESUME_TITLES: Record<string, string> = {
|
|
238
|
+
generate_video: RESUME_VIDEO_TITLE,
|
|
239
|
+
generate_sprite: RESUME_SPRITE_TITLE,
|
|
240
|
+
generate_image: "Save an image already generated (nothing further is billed)",
|
|
241
|
+
generate_speech: "Save speech already generated (nothing further is billed)",
|
|
242
|
+
generate_music: "Save music already generated (nothing further is billed)",
|
|
243
|
+
generate_sfx: "Save a sound effect already generated (nothing further is billed)",
|
|
244
|
+
};
|
|
237
245
|
|
|
238
246
|
// Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
|
|
239
247
|
// every connected server — network reads from connectors the user configured.
|
|
@@ -533,11 +541,10 @@ export function classifyToolCall(
|
|
|
533
541
|
// cheaper path than re-generating. Getting that backwards is what teaches a model
|
|
534
542
|
// to pay twice — and it costs the most on a sprite, where the alternative to a
|
|
535
543
|
// resume is a whole second fan-out of up to five video generations.
|
|
536
|
-
|
|
544
|
+
// Image, speech, music and sfx became deferred jobs too, so the same holds for them.
|
|
545
|
+
const resumable = RESUME_TITLES[name] !== undefined;
|
|
537
546
|
const resuming = resumable && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
|
|
538
|
-
const mediaTitle = resuming
|
|
539
|
-
? (name === "generate_sprite" ? RESUME_SPRITE_TITLE : RESUME_VIDEO_TITLE)
|
|
540
|
-
: MEDIA_TITLES[name];
|
|
547
|
+
const mediaTitle = resuming ? RESUME_TITLES[name] : MEDIA_TITLES[name];
|
|
541
548
|
const inputs = [
|
|
542
549
|
...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
|
|
543
550
|
str(obj.input),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// "Your agent is asking you something" — a push to the account's phones when a
|
|
2
|
+
// question (ask_user_question, a select, a text prompt) is put to an app that isn't
|
|
3
|
+
// there to see it.
|
|
4
|
+
//
|
|
5
|
+
// A question sent up the relay only reaches a LIVE socket. A phone that backgrounded
|
|
6
|
+
// a minute ago has none, so the question sat held on this machine (remoteBridge's
|
|
7
|
+
// holdOpenQuestions) with nothing telling anyone it was waiting — the user found it
|
|
8
|
+
// by chance, or after the hold ran out and the tool came back as an error.
|
|
9
|
+
//
|
|
10
|
+
// Content-free on purpose, like the Inbox nudge (server services/pushService.js): the
|
|
11
|
+
// question itself never leaves the relay, so the push says only that one is waiting.
|
|
12
|
+
// Fire-and-forget and throttled — a failed or skipped push must never touch the turn.
|
|
13
|
+
|
|
14
|
+
const COOLDOWN_MS = 30_000;
|
|
15
|
+
let lastAt = 0;
|
|
16
|
+
let sink: (() => void) | null = null;
|
|
17
|
+
|
|
18
|
+
export function requestAttentionPush(): void {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (now - lastAt < COOLDOWN_MS) return;
|
|
21
|
+
lastAt = now;
|
|
22
|
+
if (sink) return sink();
|
|
23
|
+
void (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const { apiRequest, hasCredentials } = await import("../auth/privateer.ts");
|
|
26
|
+
if (!hasCredentials()) return;
|
|
27
|
+
await apiRequest("/api/push/attention", { method: "POST", signal: AbortSignal.timeout(10_000) });
|
|
28
|
+
} catch {
|
|
29
|
+
/* best effort — the question is still held and re-sent when the app comes back */
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test seam: count pushes instead of sending them (null restores the real one), and
|
|
35
|
+
* forget the cooldown. Tests run on machines that are signed in — without this a
|
|
36
|
+
* bridge test would buzz the developer's phone. */
|
|
37
|
+
export function setAttentionPushSink(fn: (() => void) | null): void {
|
|
38
|
+
sink = fn;
|
|
39
|
+
lastAt = 0;
|
|
40
|
+
}
|
|
@@ -25,6 +25,7 @@ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccou
|
|
|
25
25
|
import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
26
26
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
27
27
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
28
|
+
import { transcriptSnapshot, type SnapshotEntry } from "./transcriptSnapshot.ts";
|
|
28
29
|
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
29
30
|
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
30
31
|
import { createUIContext } from "../ext/headlessUi.ts";
|
|
@@ -190,7 +191,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
190
191
|
onControllerAttached: () => {
|
|
191
192
|
attached = true;
|
|
192
193
|
if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
|
|
193
|
-
|
|
194
|
+
// The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
|
|
195
|
+
{
|
|
196
|
+
let entries: SnapshotEntry[] = [];
|
|
197
|
+
try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
|
|
198
|
+
relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
|
|
199
|
+
}
|
|
194
200
|
// cwd rides along here (and nowhere else — see RelayClient.sendContext): this
|
|
195
201
|
// session's working directory was either named by the driver in the spawn form
|
|
196
202
|
// or defaulted to the harbor's own, and they have no other way to see which.
|
|
@@ -24,6 +24,7 @@ import type { EngineEvent } from "../engine/events.ts";
|
|
|
24
24
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
25
25
|
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
26
26
|
import { authorizeControl } from "./controlAuth.ts";
|
|
27
|
+
import type { SnapshotTool } from "./transcriptSnapshot.ts";
|
|
27
28
|
import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
|
|
28
29
|
import {
|
|
29
30
|
LIBRARY_CHUNK_CHARS,
|
|
@@ -1175,10 +1176,43 @@ export class RelayClient {
|
|
|
1175
1176
|
|
|
1176
1177
|
// Catch-up history sent to a controller on attach. Structured (not markdown) so
|
|
1177
1178
|
// the app renders it with the same styling as the live feed. Bounded: last 80
|
|
1178
|
-
// entries, each clipped
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1179
|
+
// entries, each clipped, and the whole frame kept well under the relay's 256 KB
|
|
1180
|
+
// frame cap by dropping the OLDEST entries first.
|
|
1181
|
+
//
|
|
1182
|
+
// `full` marks the entries as the terminal's whole current transcript (see
|
|
1183
|
+
// transcriptSnapshot.ts), so the app may REPLACE what it holds rather than only
|
|
1184
|
+
// adopting into an empty feed; `running` says whether a turn is still going, which
|
|
1185
|
+
// is how a reattaching app learns that the turn it last saw "Link dropped" on is
|
|
1186
|
+
// over. Both absent = the old notice-only snapshot, which the app keeps treating as
|
|
1187
|
+
// such.
|
|
1188
|
+
sendSnapshot(
|
|
1189
|
+
entries: { kind: string; text: string; tool?: SnapshotTool }[],
|
|
1190
|
+
meta?: { full?: boolean; running?: boolean },
|
|
1191
|
+
): void {
|
|
1192
|
+
const out: Record<string, unknown>[] = [];
|
|
1193
|
+
let budget = 160 * 1024;
|
|
1194
|
+
for (const e of entries.slice(-80).reverse()) {
|
|
1195
|
+
const t = e.tool;
|
|
1196
|
+
const entry: Record<string, unknown> = t
|
|
1197
|
+
? {
|
|
1198
|
+
kind: e.kind,
|
|
1199
|
+
text: "",
|
|
1200
|
+
tool: {
|
|
1201
|
+
id: t.id,
|
|
1202
|
+
name: t.name,
|
|
1203
|
+
input: safe(t.input, 500),
|
|
1204
|
+
...(t.output !== undefined ? { output: safe(t.output, 1500) } : {}),
|
|
1205
|
+
...(t.error !== undefined ? { error: safe(t.error, 1000) } : {}),
|
|
1206
|
+
...(t.running ? { running: true } : {}),
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
: { kind: e.kind, text: safe(String(e.text ?? ""), e.kind === "reasoning" ? 1500 : 4000) };
|
|
1210
|
+
budget -= JSON.stringify(entry).length;
|
|
1211
|
+
if (budget < 0) break;
|
|
1212
|
+
out.push(entry);
|
|
1213
|
+
}
|
|
1214
|
+
out.reverse();
|
|
1215
|
+
this.rawSend({ type: "snapshot", entries: out, ...(meta?.full ? { full: true, running: meta.running === true } : {}) });
|
|
1182
1216
|
}
|
|
1183
1217
|
|
|
1184
1218
|
// ── agent → app file transfer (chunked) ─────────────────────────────────────
|
|
@@ -20,6 +20,7 @@ import { redactText } from "../util/redact.ts";
|
|
|
20
20
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
21
21
|
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
22
22
|
import type { RelayCallbacks } from "./relayClient.ts";
|
|
23
|
+
import { requestAttentionPush } from "./attentionPush.ts";
|
|
23
24
|
import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
|
|
24
25
|
import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
|
|
25
26
|
import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
|
|
@@ -168,6 +169,10 @@ export interface RemoteBridgeConfig {
|
|
|
168
169
|
onFilesSearch?: (id: string, query: string) => void;
|
|
169
170
|
// A controller (re)attached — the owner should push a transcript snapshot.
|
|
170
171
|
onControllerAttached?: () => void;
|
|
172
|
+
// A question is open and no app is attached to see it. Defaults to a content-free
|
|
173
|
+
// push to the account's phones (attentionPush.ts); a test or an owner with its own
|
|
174
|
+
// channel may replace it.
|
|
175
|
+
onQuestionUnwatched?: () => void;
|
|
171
176
|
// The last controller went away. Informational for the owner (the bridge already
|
|
172
177
|
// fails pending approvals closed); a driven turn in flight keeps running.
|
|
173
178
|
onControllerDetached?: () => void;
|
|
@@ -380,6 +385,7 @@ export class RemoteBridge {
|
|
|
380
385
|
this.remote = false;
|
|
381
386
|
this.rejectAllPending();
|
|
382
387
|
this.holdOpenQuestions();
|
|
388
|
+
if (this.hasOpenQuestion()) this.questionUnwatched();
|
|
383
389
|
this.cfg.onControllerDetached?.();
|
|
384
390
|
},
|
|
385
391
|
onAttachment: (file) => {
|
|
@@ -472,6 +478,9 @@ export class RemoteBridge {
|
|
|
472
478
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
473
479
|
}
|
|
474
480
|
this.relay!.requestSelect(id, req);
|
|
481
|
+
// Sent into a socket with no app on the other end: it is held and re-sent on
|
|
482
|
+
// reattach, but nobody knows to come back unless they are told.
|
|
483
|
+
if (this.relay!.hasController?.() === false) this.questionUnwatched();
|
|
475
484
|
});
|
|
476
485
|
};
|
|
477
486
|
|
|
@@ -495,6 +504,9 @@ export class RemoteBridge {
|
|
|
495
504
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
496
505
|
}
|
|
497
506
|
this.relay!.requestInput(id, req);
|
|
507
|
+
// Sent into a socket with no app on the other end: it is held and re-sent on
|
|
508
|
+
// reattach, but nobody knows to come back unless they are told.
|
|
509
|
+
if (this.relay!.hasController?.() === false) this.questionUnwatched();
|
|
498
510
|
});
|
|
499
511
|
};
|
|
500
512
|
|
|
@@ -711,6 +723,11 @@ export class RemoteBridge {
|
|
|
711
723
|
return true;
|
|
712
724
|
}
|
|
713
725
|
|
|
726
|
+
private questionUnwatched(): void {
|
|
727
|
+
if (this.cfg.onQuestionUnwatched) return this.cfg.onQuestionUnwatched();
|
|
728
|
+
requestAttentionPush();
|
|
729
|
+
}
|
|
730
|
+
|
|
714
731
|
private resendOpenQuestions(): void {
|
|
715
732
|
this.releaseQuestionGrace();
|
|
716
733
|
for (const [id, q] of this.pendingSelects) this.relay?.requestSelect(id, q.req);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The catch-up transcript a controller gets when it (re)attaches.
|
|
2
|
+
//
|
|
3
|
+
// The app used to get a single "Privateer terminal connected." notice here, and kept
|
|
4
|
+
// whatever feed it already held. So a phone that slept through the end of a turn came
|
|
5
|
+
// back to a transcript frozen at the moment its socket died — a "Link dropped" marker
|
|
6
|
+
// and nothing after it — while the answer sat in the terminal's session. The session
|
|
7
|
+
// IS the transcript: this projects its current branch into the same entry kinds the
|
|
8
|
+
// live feed uses, so the app can replace its stale copy with the real one.
|
|
9
|
+
//
|
|
10
|
+
// Pure (no Pi import) so it can be tested against plain entry objects.
|
|
11
|
+
|
|
12
|
+
export interface SnapshotTool {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
input: string;
|
|
16
|
+
output?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
// No result in the session yet: the call is running right now (or the turn was cut
|
|
19
|
+
// off mid-call, which `running: false` on the frame tells the app to settle).
|
|
20
|
+
running?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SnapshotEntry {
|
|
24
|
+
kind: "you" | "assistant" | "reasoning" | "tool" | "notice";
|
|
25
|
+
text: string;
|
|
26
|
+
tool?: SnapshotTool;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asText(v: unknown): string {
|
|
30
|
+
if (typeof v === "string") return v;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(v) ?? "";
|
|
33
|
+
} catch {
|
|
34
|
+
return String(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function partsText(content: unknown): string {
|
|
39
|
+
if (typeof content === "string") return content;
|
|
40
|
+
if (!Array.isArray(content)) return "";
|
|
41
|
+
return content
|
|
42
|
+
.map((p: any) => (p?.type === "text" ? String(p.text ?? "") : p?.type === "image" ? "[image]" : ""))
|
|
43
|
+
.join("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Session entries (SessionManager.getBranch(), root → leaf) → feed entries, oldest
|
|
48
|
+
* first. Tool calls are paired with their results by id into one entry, the way the
|
|
49
|
+
* live feed settles a card in place. Entries that carry nothing a reader would see
|
|
50
|
+
* (model/thinking-level changes, labels, custom state) are skipped.
|
|
51
|
+
*/
|
|
52
|
+
export function transcriptSnapshot(entries: readonly any[]): SnapshotEntry[] {
|
|
53
|
+
const out: SnapshotEntry[] = [];
|
|
54
|
+
const toolAt = new Map<string, number>();
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
if (e?.type === "compaction") {
|
|
57
|
+
out.length = 0; // what came before is no longer the conversation the agent has
|
|
58
|
+
toolAt.clear();
|
|
59
|
+
out.push({ kind: "notice", text: "Earlier conversation was compacted." });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (e?.type !== "message") continue;
|
|
63
|
+
const m = e.message;
|
|
64
|
+
switch (m?.role) {
|
|
65
|
+
case "user": {
|
|
66
|
+
const text = partsText(m.content).trim();
|
|
67
|
+
if (text) out.push({ kind: "you", text });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "assistant": {
|
|
71
|
+
for (const p of Array.isArray(m.content) ? m.content : []) {
|
|
72
|
+
if (p?.type === "text" && String(p.text ?? "").trim()) out.push({ kind: "assistant", text: String(p.text) });
|
|
73
|
+
else if (p?.type === "thinking" && !p.redacted && String(p.thinking ?? "").trim()) {
|
|
74
|
+
out.push({ kind: "reasoning", text: String(p.thinking) });
|
|
75
|
+
} else if (p?.type === "toolCall" && p.id) {
|
|
76
|
+
const input = asText(p.arguments ?? {});
|
|
77
|
+
toolAt.set(String(p.id), out.length);
|
|
78
|
+
out.push({
|
|
79
|
+
kind: "tool",
|
|
80
|
+
text: `▸ ${p.name}${input ? ` ${input}` : ""}`,
|
|
81
|
+
tool: { id: String(p.id), name: String(p.name ?? ""), input, running: true },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (m.stopReason === "error" && typeof m.errorMessage === "string" && m.errorMessage) {
|
|
86
|
+
out.push({ kind: "notice", text: `⚠ ${m.errorMessage}` });
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "toolResult": {
|
|
91
|
+
const at = toolAt.get(String(m.toolCallId));
|
|
92
|
+
const text = partsText(m.content);
|
|
93
|
+
if (at === undefined) break; // a result whose call was compacted away
|
|
94
|
+
const entry = out[at];
|
|
95
|
+
const tool = { ...entry.tool!, running: false };
|
|
96
|
+
if (m.isError) tool.error = text;
|
|
97
|
+
else tool.output = text;
|
|
98
|
+
out[at] = { ...entry, text: `${entry.text}\n ↳ ${text}`, tool };
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|