@jyork0828/pi-pilot 0.0.6 → 0.0.7
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/dist/index.js +1850 -207
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/public/assets/ArtifactsPanel-DUiRwx7S.js +1 -0
- package/public/assets/ResourcesPanel-Cn_gw159.js +19 -0
- package/public/assets/SessionTree-CBIw_kzf.js +16 -0
- package/public/assets/SettingsPage-cULKjgtu.js +51 -0
- package/public/assets/index-CX2ohSDO.js +238 -0
- package/public/assets/index-CyoTMDCN.css +1 -0
- package/public/assets/markdown-CY-Rm0E5.js +45 -0
- package/public/assets/shiki-BZ0sbaMe.js +152 -0
- package/public/index.html +12 -3
- package/public/assets/index-CsC5-YPT.js +0 -506
- package/public/assets/index-R8FKUxOS.css +0 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { existsSync } from "fs";
|
|
5
|
-
import { readFile as
|
|
6
|
-
import { dirname as dirname6, extname, join as
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
4
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
6
|
+
import { dirname as dirname6, extname, join as join16, resolve as resolve7, sep as sep3 } from "path";
|
|
7
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8
8
|
import { serve } from "@hono/node-server";
|
|
9
|
-
import { Hono as
|
|
9
|
+
import { Hono as Hono6 } from "hono";
|
|
10
10
|
import { cors } from "hono/cors";
|
|
11
11
|
|
|
12
12
|
// src/config.ts
|
|
@@ -41,8 +41,8 @@ function configureHttpProxy() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// src/api/workspaces.ts
|
|
44
|
-
import { readFile as
|
|
45
|
-
import { basename as basename2, isAbsolute as isAbsolute3, resolve as
|
|
44
|
+
import { readFile as readFile7, stat as stat2 } from "fs/promises";
|
|
45
|
+
import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve5 } from "path";
|
|
46
46
|
import { Hono as Hono2 } from "hono";
|
|
47
47
|
|
|
48
48
|
// src/storage/resource-writer.ts
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
unlink,
|
|
55
55
|
writeFile
|
|
56
56
|
} from "fs/promises";
|
|
57
|
-
import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
57
|
+
import { basename, dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
58
58
|
var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
59
59
|
var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
60
60
|
function ensureSkillName(name) {
|
|
@@ -311,7 +311,7 @@ async function readPromptFile(filePath, roots) {
|
|
|
311
311
|
assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
|
|
312
312
|
const text = await readFile(filePath, "utf8");
|
|
313
313
|
const { frontmatter, body } = parseFile(text);
|
|
314
|
-
const stem = basename(filePath
|
|
314
|
+
const stem = basename(filePath, ".md");
|
|
315
315
|
return {
|
|
316
316
|
body,
|
|
317
317
|
name: stem,
|
|
@@ -319,10 +319,6 @@ async function readPromptFile(filePath, roots) {
|
|
|
319
319
|
argumentHint: stringOr(frontmatter["argument-hint"], void 0)
|
|
320
320
|
};
|
|
321
321
|
}
|
|
322
|
-
function basename(p) {
|
|
323
|
-
const parts = p.split(sep);
|
|
324
|
-
return parts.at(-1) || p;
|
|
325
|
-
}
|
|
326
322
|
function stringOr(value, fallback) {
|
|
327
323
|
return typeof value === "string" ? value : fallback;
|
|
328
324
|
}
|
|
@@ -338,17 +334,47 @@ async function exists(p) {
|
|
|
338
334
|
}
|
|
339
335
|
}
|
|
340
336
|
var HttpError = class extends Error {
|
|
341
|
-
constructor(
|
|
337
|
+
constructor(status2, message) {
|
|
342
338
|
super(message);
|
|
343
|
-
this.status =
|
|
339
|
+
this.status = status2;
|
|
344
340
|
}
|
|
345
341
|
status;
|
|
346
342
|
};
|
|
347
343
|
|
|
348
344
|
// src/storage/workspace-registry.ts
|
|
349
|
-
import {
|
|
350
|
-
import {
|
|
345
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
346
|
+
import { join as join3 } from "path";
|
|
347
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
348
|
+
|
|
349
|
+
// src/storage/atomic-json.ts
|
|
350
|
+
import { chmod, mkdir as mkdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
351
|
+
import { dirname as dirname2 } from "path";
|
|
351
352
|
import { randomUUID } from "crypto";
|
|
353
|
+
async function writeJsonAtomic(filePath, data, opts) {
|
|
354
|
+
await mkdir2(dirname2(filePath), { recursive: true });
|
|
355
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
356
|
+
const text = JSON.stringify(data, null, 2);
|
|
357
|
+
try {
|
|
358
|
+
if (opts?.mode !== void 0) {
|
|
359
|
+
await writeFile2(tmp, text, { encoding: "utf8", mode: opts.mode });
|
|
360
|
+
} else {
|
|
361
|
+
await writeFile2(tmp, text, "utf8");
|
|
362
|
+
}
|
|
363
|
+
await rename(tmp, filePath);
|
|
364
|
+
} catch (err2) {
|
|
365
|
+
await rm2(tmp, { force: true }).catch(() => {
|
|
366
|
+
});
|
|
367
|
+
throw err2;
|
|
368
|
+
}
|
|
369
|
+
if (opts?.mode !== void 0) {
|
|
370
|
+
try {
|
|
371
|
+
await chmod(filePath, opts.mode);
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/storage/workspace-registry.ts
|
|
352
378
|
var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
|
|
353
379
|
var cache;
|
|
354
380
|
var writeChain = Promise.resolve();
|
|
@@ -373,8 +399,7 @@ async function load() {
|
|
|
373
399
|
}
|
|
374
400
|
async function save() {
|
|
375
401
|
if (!cache) return;
|
|
376
|
-
await
|
|
377
|
-
await writeFile2(REGISTRY_PATH, JSON.stringify(cache, null, 2), "utf8");
|
|
402
|
+
await writeJsonAtomic(REGISTRY_PATH, cache);
|
|
378
403
|
}
|
|
379
404
|
async function listWorkspaces() {
|
|
380
405
|
const r = await load();
|
|
@@ -394,7 +419,7 @@ async function addWorkspace(input) {
|
|
|
394
419
|
return;
|
|
395
420
|
}
|
|
396
421
|
const ws = {
|
|
397
|
-
id:
|
|
422
|
+
id: randomUUID2(),
|
|
398
423
|
name: input.name,
|
|
399
424
|
path: input.path,
|
|
400
425
|
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -417,6 +442,19 @@ async function removeWorkspace(id) {
|
|
|
417
442
|
});
|
|
418
443
|
return removed;
|
|
419
444
|
}
|
|
445
|
+
async function setWorkspaceTrustProjectAgents(id, trusted) {
|
|
446
|
+
let updated;
|
|
447
|
+
await serializedWrite(async () => {
|
|
448
|
+
const r = await load();
|
|
449
|
+
const ws = r.workspaces.find((w) => w.id === id);
|
|
450
|
+
if (!ws) return;
|
|
451
|
+
if (trusted) ws.trustProjectAgents = true;
|
|
452
|
+
else delete ws.trustProjectAgents;
|
|
453
|
+
await save();
|
|
454
|
+
updated = ws;
|
|
455
|
+
});
|
|
456
|
+
return updated;
|
|
457
|
+
}
|
|
420
458
|
async function reorderWorkspaces(ids) {
|
|
421
459
|
await serializedWrite(async () => {
|
|
422
460
|
const r = await load();
|
|
@@ -453,13 +491,14 @@ async function enrichWorkspace(ws) {
|
|
|
453
491
|
path: ws.path,
|
|
454
492
|
addedAt: ws.addedAt,
|
|
455
493
|
gitBranch: stats.gitBranch,
|
|
456
|
-
fileCount: stats.fileCount
|
|
494
|
+
fileCount: stats.fileCount,
|
|
495
|
+
trustProjectAgents: ws.trustProjectAgents === true
|
|
457
496
|
};
|
|
458
497
|
}
|
|
459
498
|
async function getStats(path) {
|
|
460
499
|
const now = Date.now();
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
500
|
+
const cached2 = cache2.get(path);
|
|
501
|
+
if (cached2 && cached2.expiresAt > now) return cached2;
|
|
463
502
|
const pending2 = inflight.get(path);
|
|
464
503
|
if (pending2) return pending2;
|
|
465
504
|
const probe = probeStats(path).then((stats) => {
|
|
@@ -529,15 +568,106 @@ async function runGit(cwd, args) {
|
|
|
529
568
|
|
|
530
569
|
// src/workspace-manager.ts
|
|
531
570
|
import { unlink as unlink2 } from "fs/promises";
|
|
532
|
-
import { isAbsolute as isAbsolute2, resolve as
|
|
571
|
+
import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
|
|
533
572
|
import {
|
|
534
573
|
createAgentSessionFromServices,
|
|
535
574
|
createAgentSessionRuntime,
|
|
536
575
|
createAgentSessionServices,
|
|
537
|
-
getAgentDir,
|
|
576
|
+
getAgentDir as getAgentDir2,
|
|
538
577
|
SessionManager
|
|
539
578
|
} from "@earendil-works/pi-coding-agent";
|
|
540
579
|
|
|
580
|
+
// src/storage/session-tool-prefs.ts
|
|
581
|
+
import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
|
|
582
|
+
import { dirname as dirname3, join as join4, resolve as resolve2 } from "path";
|
|
583
|
+
var PREFS_PATH = join4(config.dataDir, "session-tools.json");
|
|
584
|
+
var cache3 = { sessions: {} };
|
|
585
|
+
async function loadSessionToolPrefs() {
|
|
586
|
+
try {
|
|
587
|
+
const raw = await readFile3(PREFS_PATH, "utf8");
|
|
588
|
+
const parsed = JSON.parse(raw);
|
|
589
|
+
cache3 = { sessions: normalizeSessions(parsed.sessions) };
|
|
590
|
+
} catch (err2) {
|
|
591
|
+
cache3 = { sessions: {} };
|
|
592
|
+
if (err2.code !== "ENOENT") {
|
|
593
|
+
console.warn(`[session-tool-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function keyOf(workspaceId, session) {
|
|
598
|
+
return session.sessionFile ? resolve2(session.sessionFile) : `${workspaceId}:${session.sessionId}`;
|
|
599
|
+
}
|
|
600
|
+
function storedDisabled(workspaceId, session) {
|
|
601
|
+
return cache3.sessions[keyOf(workspaceId, session)]?.disabled ?? [];
|
|
602
|
+
}
|
|
603
|
+
async function persistActiveTools(workspaceId, session, activeNames) {
|
|
604
|
+
const registered = session.getAllTools().map((t) => t.name);
|
|
605
|
+
const registeredSet = new Set(registered);
|
|
606
|
+
const active = new Set(activeNames);
|
|
607
|
+
const disabled = /* @__PURE__ */ new Set();
|
|
608
|
+
for (const name of registered) {
|
|
609
|
+
if (!active.has(name)) disabled.add(name);
|
|
610
|
+
}
|
|
611
|
+
for (const name of storedDisabled(workspaceId, session)) {
|
|
612
|
+
if (!registeredSet.has(name)) disabled.add(name);
|
|
613
|
+
}
|
|
614
|
+
cache3 = {
|
|
615
|
+
sessions: {
|
|
616
|
+
...cache3.sessions,
|
|
617
|
+
[keyOf(workspaceId, session)]: {
|
|
618
|
+
disabled: sortUnique(disabled),
|
|
619
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
await save2();
|
|
624
|
+
session.setActiveToolsByName(registered.filter((name) => !disabled.has(name)));
|
|
625
|
+
}
|
|
626
|
+
function reapplyToolPrefs(workspaceId, session) {
|
|
627
|
+
const disabled = storedDisabled(workspaceId, session);
|
|
628
|
+
if (disabled.length === 0) return;
|
|
629
|
+
const disabledSet = new Set(disabled);
|
|
630
|
+
const registered = session.getAllTools().map((t) => t.name);
|
|
631
|
+
session.setActiveToolsByName(registered.filter((name) => !disabledSet.has(name)));
|
|
632
|
+
}
|
|
633
|
+
async function forgetSessionToolPrefs(sessionPath) {
|
|
634
|
+
const key = resolve2(sessionPath);
|
|
635
|
+
if (!(key in cache3.sessions)) return;
|
|
636
|
+
const next = { ...cache3.sessions };
|
|
637
|
+
delete next[key];
|
|
638
|
+
cache3 = { sessions: next };
|
|
639
|
+
await save2();
|
|
640
|
+
}
|
|
641
|
+
function sortUnique(values) {
|
|
642
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
643
|
+
}
|
|
644
|
+
function normalizeSessions(value) {
|
|
645
|
+
if (!value || typeof value !== "object") return {};
|
|
646
|
+
const out = {};
|
|
647
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
648
|
+
if (!entry || typeof entry !== "object") continue;
|
|
649
|
+
const disabled = entry.disabled;
|
|
650
|
+
if (!Array.isArray(disabled)) continue;
|
|
651
|
+
const updatedAt = entry.updatedAt;
|
|
652
|
+
out[key] = {
|
|
653
|
+
disabled: sortUnique(disabled.filter((n) => typeof n === "string")),
|
|
654
|
+
updatedAt: typeof updatedAt === "string" ? updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
var writeChain2 = Promise.resolve();
|
|
660
|
+
function save2() {
|
|
661
|
+
writeChain2 = writeChain2.catch(() => {
|
|
662
|
+
}).then(async () => {
|
|
663
|
+
await mkdir3(dirname3(PREFS_PATH), { recursive: true });
|
|
664
|
+
const tmp = `${PREFS_PATH}.tmp`;
|
|
665
|
+
await writeFile3(tmp, JSON.stringify(cache3, null, 2), "utf8");
|
|
666
|
+
await rename2(tmp, PREFS_PATH);
|
|
667
|
+
});
|
|
668
|
+
return writeChain2;
|
|
669
|
+
}
|
|
670
|
+
|
|
541
671
|
// src/extensions/todo/schema.ts
|
|
542
672
|
import { Type } from "typebox";
|
|
543
673
|
var EMPTY_STATE = { tasks: [], nextId: 1 };
|
|
@@ -877,7 +1007,7 @@ function waitForAnswer({
|
|
|
877
1007
|
sessionFile,
|
|
878
1008
|
signal
|
|
879
1009
|
}) {
|
|
880
|
-
return new Promise((
|
|
1010
|
+
return new Promise((resolve8, reject) => {
|
|
881
1011
|
let settled = false;
|
|
882
1012
|
let timeoutHandle;
|
|
883
1013
|
const cleanup = () => {
|
|
@@ -889,7 +1019,7 @@ function waitForAnswer({
|
|
|
889
1019
|
if (settled) return;
|
|
890
1020
|
settled = true;
|
|
891
1021
|
cleanup();
|
|
892
|
-
|
|
1022
|
+
resolve8(a);
|
|
893
1023
|
};
|
|
894
1024
|
const finishErr = (err2) => {
|
|
895
1025
|
if (settled) return;
|
|
@@ -958,110 +1088,1310 @@ function formatResult(params, answer, startedAt) {
|
|
|
958
1088
|
details
|
|
959
1089
|
};
|
|
960
1090
|
}
|
|
961
|
-
function descriptionSuffix(params, index) {
|
|
962
|
-
const desc = params.options[index]?.description;
|
|
963
|
-
return desc ? ` \u2014 ${desc}` : "";
|
|
1091
|
+
function descriptionSuffix(params, index) {
|
|
1092
|
+
const desc = params.options[index]?.description;
|
|
1093
|
+
return desc ? ` \u2014 ${desc}` : "";
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/extensions/artifact/schema.ts
|
|
1097
|
+
import { Type as Type3 } from "typebox";
|
|
1098
|
+
var TypeEnum = Type3.Union(
|
|
1099
|
+
[
|
|
1100
|
+
Type3.Literal("html"),
|
|
1101
|
+
Type3.Literal("svg"),
|
|
1102
|
+
Type3.Literal("markdown"),
|
|
1103
|
+
Type3.Literal("code")
|
|
1104
|
+
],
|
|
1105
|
+
{
|
|
1106
|
+
description: 'How to render the content: "html" (a self-contained HTML document or fragment, run in a sandboxed iframe), "svg" (SVG markup), "markdown" (rich text), or "code" (a source file shown with syntax highlighting).'
|
|
1107
|
+
}
|
|
1108
|
+
);
|
|
1109
|
+
var createArtifactParamsSchema = Type3.Object({
|
|
1110
|
+
id: Type3.Optional(
|
|
1111
|
+
Type3.String({
|
|
1112
|
+
description: 'Stable identifier. Omit on first creation. To REVISE an existing artifact, pass the same id you used before \u2014 that records a new version instead of a separate artifact. Use a short slug like "landing-page".'
|
|
1113
|
+
})
|
|
1114
|
+
),
|
|
1115
|
+
type: TypeEnum,
|
|
1116
|
+
title: Type3.String({
|
|
1117
|
+
description: "Short human-readable title shown in the artifact panel."
|
|
1118
|
+
}),
|
|
1119
|
+
content: Type3.String({
|
|
1120
|
+
description: "The full artifact content \u2014 the complete document, markup, or source."
|
|
1121
|
+
}),
|
|
1122
|
+
language: Type3.Optional(
|
|
1123
|
+
Type3.String({
|
|
1124
|
+
description: 'For type="code", the language id for syntax highlighting (e.g. "python", "typescript").'
|
|
1125
|
+
})
|
|
1126
|
+
)
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// src/extensions/artifact/factory.ts
|
|
1130
|
+
var TOOL_NAME2 = "create_artifact";
|
|
1131
|
+
var artifactExtensionFactory = (pi) => {
|
|
1132
|
+
pi.registerTool({
|
|
1133
|
+
name: TOOL_NAME2,
|
|
1134
|
+
label: "Create artifact",
|
|
1135
|
+
description: 'Publish a substantial, self-contained piece of content as an "artifact" the user can view and iterate on in a dedicated side panel: a web page (html), an SVG diagram (svg), a document (markdown), or a source file (code). Reuse the same `id` to revise an existing artifact (records a new version).',
|
|
1136
|
+
promptSnippet: "create_artifact: render substantial, self-contained content (web page / SVG / document / code file) in a side panel the user can view and iterate on.",
|
|
1137
|
+
promptGuidelines: [
|
|
1138
|
+
"Use create_artifact for substantial, self-contained, reusable content the user will want to view, keep, or iterate on \u2014 a runnable HTML page, an SVG diagram, a full document, or a standalone code file. Do NOT use it for short snippets, command output, or your normal conversational answer; a fenced code block in your reply is better for those.",
|
|
1139
|
+
"To revise an artifact, call create_artifact again with the SAME id and the full updated content \u2014 this records a new version the user can step through. Don't spawn a near-duplicate artifact under a new id.",
|
|
1140
|
+
'Put the entire content in `content`, give it a concise `title`, and pick the `type` that matches how it should render. For type="code", set `language`.',
|
|
1141
|
+
"After creating an artifact, keep your chat reply short \u2014 the content lives in the panel, so don't paste it again in prose."
|
|
1142
|
+
],
|
|
1143
|
+
parameters: createArtifactParamsSchema,
|
|
1144
|
+
execute: async (toolCallId, params) => {
|
|
1145
|
+
const id = params.id?.trim() || toolCallId;
|
|
1146
|
+
const details = {
|
|
1147
|
+
id,
|
|
1148
|
+
type: params.type,
|
|
1149
|
+
title: params.title
|
|
1150
|
+
};
|
|
1151
|
+
const text = `Artifact "${params.title}" (${params.type}) is now shown to the user in the Artifacts panel. Its id is "${id}" \u2014 pass that same id to create_artifact to revise it.`;
|
|
1152
|
+
return {
|
|
1153
|
+
content: [{ type: "text", text }],
|
|
1154
|
+
details
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
// src/extensions/subagent/agents.ts
|
|
1161
|
+
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
1162
|
+
import { join as join6 } from "path";
|
|
1163
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
1164
|
+
|
|
1165
|
+
// src/extensions/subagent/builtin-agents.ts
|
|
1166
|
+
var COMMON_RULES = `Hard rules:
|
|
1167
|
+
- You run headless as a subagent: there is NO user to talk to. Never ask questions, never wait for confirmation \u2014 decide and proceed.
|
|
1168
|
+
- NEVER run the \`pi\` CLI or spawn any other agent. No nesting.
|
|
1169
|
+
- End with ONE final message that is a complete, self-contained report \u2014 it is the ONLY thing returned to the agent that delegated to you. Keep it under ~8000 characters and reference code as \`path:line\`.`;
|
|
1170
|
+
var scout = {
|
|
1171
|
+
name: "scout",
|
|
1172
|
+
source: "builtin",
|
|
1173
|
+
description: "Fast read-only codebase recon: locate files, symbols, flows, conventions, and report compressed findings. Cheap to use; cannot edit anything.",
|
|
1174
|
+
tools: ["read", "grep", "find", "ls"],
|
|
1175
|
+
systemPrompt: `You are "scout", a read-only reconnaissance subagent.
|
|
1176
|
+
|
|
1177
|
+
${COMMON_RULES}
|
|
1178
|
+
|
|
1179
|
+
Method: start broad (find/ls/grep), then read only the spans that matter. Prefer reading slices over whole files. Stop as soon as you can answer confidently.
|
|
1180
|
+
|
|
1181
|
+
Final report shape: a short answer first, then the supporting map \u2014 relevant files with one-line roles, key symbols as \`path:line\`, and any conventions or gotchas the delegator should know. Say explicitly what you did NOT verify.`
|
|
1182
|
+
};
|
|
1183
|
+
var worker = {
|
|
1184
|
+
name: "worker",
|
|
1185
|
+
source: "builtin",
|
|
1186
|
+
description: "General-purpose implementer with the full default toolset (bash/edit/write). Use for a self-contained change with a precise brief; verifies its own work.",
|
|
1187
|
+
systemPrompt: `You are "worker", an implementation subagent.
|
|
1188
|
+
|
|
1189
|
+
${COMMON_RULES}
|
|
1190
|
+
|
|
1191
|
+
Method: read the relevant code before changing it; follow the surrounding style exactly; make the smallest change that satisfies the brief. Verify with the project's own commands (typecheck / tests / build) when available \u2014 report what you ran and its outcome honestly.
|
|
1192
|
+
|
|
1193
|
+
Final report shape: what changed (file by file, one line each), how it was verified (commands + results), and any caveats or follow-ups. If you could not finish, say precisely how far you got and what is left.`
|
|
1194
|
+
};
|
|
1195
|
+
var reviewer = {
|
|
1196
|
+
name: "reviewer",
|
|
1197
|
+
source: "builtin",
|
|
1198
|
+
description: "Code review of specific files or diffs: correctness, edge cases, convention drift. Read-mostly (bash for git diff / running tests). Returns prioritized findings.",
|
|
1199
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
1200
|
+
systemPrompt: `You are "reviewer", a code-review subagent. Your job is to FIND problems, not to fix them \u2014 do not edit any file.
|
|
1201
|
+
|
|
1202
|
+
${COMMON_RULES}
|
|
1203
|
+
|
|
1204
|
+
Method: read the target code fully before judging; use bash only for read-only inspection (git diff/log, running existing tests). Hunt real defects first \u2014 correctness, edge cases, lifecycle/cleanup holes \u2014 then convention drift. Verify each suspicion against the actual code before reporting it.
|
|
1205
|
+
|
|
1206
|
+
Final report shape: findings ordered by severity, each with \`path:line\`, what's wrong, why it matters, and a concrete suggested fix. End with what you checked and found clean, so silence isn't ambiguous.`
|
|
1207
|
+
};
|
|
1208
|
+
var BUILTIN_AGENTS = [scout, worker, reviewer];
|
|
1209
|
+
|
|
1210
|
+
// src/extensions/subagent/trust.ts
|
|
1211
|
+
import { readFileSync } from "fs";
|
|
1212
|
+
import { homedir as homedir2 } from "os";
|
|
1213
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
1214
|
+
function isProjectDirTrusted(projectDir) {
|
|
1215
|
+
const registryPath = join5(
|
|
1216
|
+
process.env.PI_PILOT_DATA_DIR ?? join5(homedir2(), ".pi", "webui"),
|
|
1217
|
+
"workspaces.json"
|
|
1218
|
+
);
|
|
1219
|
+
try {
|
|
1220
|
+
const raw = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
1221
|
+
if (!Array.isArray(raw.workspaces)) return false;
|
|
1222
|
+
const wanted = resolve3(projectDir);
|
|
1223
|
+
return raw.workspaces.some(
|
|
1224
|
+
(w) => typeof w?.path === "string" && resolve3(w.path) === wanted && w.trustProjectAgents === true
|
|
1225
|
+
);
|
|
1226
|
+
} catch {
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/extensions/subagent/agents.ts
|
|
1232
|
+
function userAgentsDir() {
|
|
1233
|
+
return join6(getAgentDir(), "agents");
|
|
1234
|
+
}
|
|
1235
|
+
function projectAgentsDir(projectDir) {
|
|
1236
|
+
return join6(projectDir, ".pi", "agents");
|
|
1237
|
+
}
|
|
1238
|
+
function discoverAgents(projectDir) {
|
|
1239
|
+
const roster = /* @__PURE__ */ new Map();
|
|
1240
|
+
for (const agent of BUILTIN_AGENTS) roster.set(agent.name, agent);
|
|
1241
|
+
mergeDir(roster, userAgentsDir(), "user");
|
|
1242
|
+
if (projectDir && isProjectDirTrusted(projectDir)) {
|
|
1243
|
+
mergeDir(roster, projectAgentsDir(projectDir), "project");
|
|
1244
|
+
}
|
|
1245
|
+
return roster;
|
|
1246
|
+
}
|
|
1247
|
+
function mergeDir(roster, dir, source) {
|
|
1248
|
+
let files;
|
|
1249
|
+
try {
|
|
1250
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
1251
|
+
} catch {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
for (const file of files) {
|
|
1255
|
+
const def = parseAgentFile(join6(dir, file), file, source);
|
|
1256
|
+
if (def) roster.set(def.name, def);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
function rosterSummary(roster) {
|
|
1260
|
+
return [...roster.values()].map((a) => `- ${a.name}: ${a.description || "(no description)"}`).join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
function parseAgentFile(path, filename, source) {
|
|
1263
|
+
try {
|
|
1264
|
+
const raw = readFileSync2(path, "utf8");
|
|
1265
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1266
|
+
const name = strField(frontmatter.name) ?? filename.replace(/\.md$/, "").trim();
|
|
1267
|
+
if (!name) return void 0;
|
|
1268
|
+
return {
|
|
1269
|
+
name,
|
|
1270
|
+
description: strField(frontmatter.description) ?? "",
|
|
1271
|
+
systemPrompt: body.trim(),
|
|
1272
|
+
tools: toolsField(frontmatter.tools),
|
|
1273
|
+
model: strField(frontmatter.model),
|
|
1274
|
+
source
|
|
1275
|
+
};
|
|
1276
|
+
} catch {
|
|
1277
|
+
return void 0;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function strField(value) {
|
|
1281
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1282
|
+
}
|
|
1283
|
+
function toolsField(value) {
|
|
1284
|
+
if (typeof value === "string") {
|
|
1285
|
+
const parts = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1286
|
+
return parts.length > 0 ? parts : void 0;
|
|
1287
|
+
}
|
|
1288
|
+
if (Array.isArray(value)) {
|
|
1289
|
+
const parts = value.filter((v) => typeof v === "string" && v.trim() !== "");
|
|
1290
|
+
return parts.length > 0 ? parts.map((s) => s.trim()) : void 0;
|
|
1291
|
+
}
|
|
1292
|
+
return void 0;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// src/extensions/subagent/child.ts
|
|
1296
|
+
import { spawn } from "child_process";
|
|
1297
|
+
import {
|
|
1298
|
+
createWriteStream,
|
|
1299
|
+
mkdirSync,
|
|
1300
|
+
mkdtempSync,
|
|
1301
|
+
writeFileSync
|
|
1302
|
+
} from "fs";
|
|
1303
|
+
import { rm as rm3 } from "fs/promises";
|
|
1304
|
+
import { tmpdir } from "os";
|
|
1305
|
+
import { join as join8 } from "path";
|
|
1306
|
+
|
|
1307
|
+
// src/extensions/subagent/schema.ts
|
|
1308
|
+
import { Type as Type4 } from "typebox";
|
|
1309
|
+
var MAX_TASKS_PER_CALL = 8;
|
|
1310
|
+
var taskBriefDescription = "Complete, self-contained task brief. The subagent sees NOTHING of this conversation \u2014 include all relevant paths, constraints, context, and the exact shape of the answer you want back.";
|
|
1311
|
+
var subagentParamsSchema = Type4.Object({
|
|
1312
|
+
agent: Type4.Optional(
|
|
1313
|
+
Type4.String({
|
|
1314
|
+
description: "Single mode: agent to delegate to, by name. The roster is listed in the tool description; an unknown name returns the available roster. Use together with `task`; omit when using `tasks`."
|
|
1315
|
+
})
|
|
1316
|
+
),
|
|
1317
|
+
task: Type4.Optional(Type4.String({ description: taskBriefDescription })),
|
|
1318
|
+
tasks: Type4.Optional(
|
|
1319
|
+
Type4.Array(
|
|
1320
|
+
Type4.Object({
|
|
1321
|
+
agent: Type4.String({ description: "Agent to delegate this task to, by name." }),
|
|
1322
|
+
task: Type4.String({ description: taskBriefDescription })
|
|
1323
|
+
}),
|
|
1324
|
+
{
|
|
1325
|
+
maxItems: MAX_TASKS_PER_CALL,
|
|
1326
|
+
description: `Parallel mode: up to ${MAX_TASKS_PER_CALL} INDEPENDENT task briefs, run concurrently. All results return together in one combined report. Only for tasks with no ordering dependency \u2014 sequence dependent steps as separate subagent calls instead. Omit when using \`agent\`/\`task\`.`
|
|
1327
|
+
}
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
});
|
|
1331
|
+
function emptyUsage() {
|
|
1332
|
+
return {
|
|
1333
|
+
input: 0,
|
|
1334
|
+
output: 0,
|
|
1335
|
+
cacheRead: 0,
|
|
1336
|
+
cacheWrite: 0,
|
|
1337
|
+
cost: 0,
|
|
1338
|
+
contextTokens: 0,
|
|
1339
|
+
turns: 0
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function isSubagentDetails(value) {
|
|
1343
|
+
if (!value || typeof value !== "object") return false;
|
|
1344
|
+
const v = value;
|
|
1345
|
+
return v.version === 1 && (v.mode === "single" || v.mode === "parallel") && Array.isArray(v.tasks);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// src/extensions/subagent/pi-bin.ts
|
|
1349
|
+
import { existsSync } from "fs";
|
|
1350
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1351
|
+
import { fileURLToPath } from "url";
|
|
1352
|
+
var cached;
|
|
1353
|
+
function resolvePinnedPiCli() {
|
|
1354
|
+
if (cached) return cached;
|
|
1355
|
+
try {
|
|
1356
|
+
const entry = fileURLToPath(
|
|
1357
|
+
import.meta.resolve("@earendil-works/pi-coding-agent")
|
|
1358
|
+
);
|
|
1359
|
+
const candidate = join7(dirname4(entry), "cli.js");
|
|
1360
|
+
if (existsSync(candidate)) {
|
|
1361
|
+
cached = candidate;
|
|
1362
|
+
return candidate;
|
|
1363
|
+
}
|
|
1364
|
+
} catch {
|
|
1365
|
+
}
|
|
1366
|
+
const fallback = fileURLToPath(
|
|
1367
|
+
new URL(
|
|
1368
|
+
"../../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
|
|
1369
|
+
import.meta.url
|
|
1370
|
+
)
|
|
1371
|
+
);
|
|
1372
|
+
if (existsSync(fallback)) {
|
|
1373
|
+
cached = fallback;
|
|
1374
|
+
return fallback;
|
|
1375
|
+
}
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
"subagent: cannot locate the pinned @earendil-works/pi-coding-agent CLI (tried import.meta.resolve and the package-local node_modules symlink)"
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/extensions/subagent/registry.ts
|
|
1382
|
+
var children = /* @__PURE__ */ new Map();
|
|
1383
|
+
function registerChild(toolCallId, handle2) {
|
|
1384
|
+
children.set(toolCallId, handle2);
|
|
1385
|
+
}
|
|
1386
|
+
function unregisterChild(toolCallId) {
|
|
1387
|
+
children.delete(toolCallId);
|
|
1388
|
+
}
|
|
1389
|
+
function killChildrenForSession(sessionFile) {
|
|
1390
|
+
let killed = 0;
|
|
1391
|
+
for (const [id, handle2] of children) {
|
|
1392
|
+
if (handle2.sessionFile !== sessionFile) continue;
|
|
1393
|
+
children.delete(id);
|
|
1394
|
+
handle2.kill();
|
|
1395
|
+
killed++;
|
|
1396
|
+
}
|
|
1397
|
+
return killed;
|
|
1398
|
+
}
|
|
1399
|
+
function killAllChildren() {
|
|
1400
|
+
let killed = 0;
|
|
1401
|
+
for (const [id, handle2] of children) {
|
|
1402
|
+
children.delete(id);
|
|
1403
|
+
handle2.kill();
|
|
1404
|
+
killed++;
|
|
1405
|
+
}
|
|
1406
|
+
return killed;
|
|
1407
|
+
}
|
|
1408
|
+
var MAX_CONCURRENT_CHILDREN = 8;
|
|
1409
|
+
var MAX_CONCURRENT_PER_SESSION = 4;
|
|
1410
|
+
var running = 0;
|
|
1411
|
+
var runningPerSession = /* @__PURE__ */ new Map();
|
|
1412
|
+
var waiters = [];
|
|
1413
|
+
function keyOf2(sessionFile) {
|
|
1414
|
+
return sessionFile ?? "<unpersisted>";
|
|
1415
|
+
}
|
|
1416
|
+
function hasCapacity(sessionKey) {
|
|
1417
|
+
return running < MAX_CONCURRENT_CHILDREN && (runningPerSession.get(sessionKey) ?? 0) < MAX_CONCURRENT_PER_SESSION;
|
|
1418
|
+
}
|
|
1419
|
+
function take(sessionKey) {
|
|
1420
|
+
running++;
|
|
1421
|
+
runningPerSession.set(sessionKey, (runningPerSession.get(sessionKey) ?? 0) + 1);
|
|
1422
|
+
}
|
|
1423
|
+
function acquireChildSlot(signal, sessionFile) {
|
|
1424
|
+
const sessionKey = keyOf2(sessionFile);
|
|
1425
|
+
return new Promise((resolve8, reject) => {
|
|
1426
|
+
if (signal?.aborted) {
|
|
1427
|
+
reject(new Error("Aborted by user"));
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (hasCapacity(sessionKey)) {
|
|
1431
|
+
take(sessionKey);
|
|
1432
|
+
resolve8(makeRelease(sessionKey));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const waiter = {
|
|
1436
|
+
grant: () => resolve8(makeRelease(sessionKey)),
|
|
1437
|
+
sessionKey,
|
|
1438
|
+
signal,
|
|
1439
|
+
onAbort: void 0
|
|
1440
|
+
};
|
|
1441
|
+
if (signal) {
|
|
1442
|
+
const onAbort = () => {
|
|
1443
|
+
const i = waiters.indexOf(waiter);
|
|
1444
|
+
if (i >= 0) waiters.splice(i, 1);
|
|
1445
|
+
reject(new Error("Aborted by user"));
|
|
1446
|
+
};
|
|
1447
|
+
waiter.onAbort = onAbort;
|
|
1448
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1449
|
+
}
|
|
1450
|
+
waiters.push(waiter);
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
function makeRelease(sessionKey) {
|
|
1454
|
+
let released = false;
|
|
1455
|
+
return () => {
|
|
1456
|
+
if (released) return;
|
|
1457
|
+
released = true;
|
|
1458
|
+
running--;
|
|
1459
|
+
const n = (runningPerSession.get(sessionKey) ?? 1) - 1;
|
|
1460
|
+
if (n <= 0) runningPerSession.delete(sessionKey);
|
|
1461
|
+
else runningPerSession.set(sessionKey, n);
|
|
1462
|
+
pump();
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function pump() {
|
|
1466
|
+
for (let i = 0; i < waiters.length && running < MAX_CONCURRENT_CHILDREN; ) {
|
|
1467
|
+
const waiter = waiters[i];
|
|
1468
|
+
if (waiter.signal?.aborted) {
|
|
1469
|
+
waiters.splice(i, 1);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (!hasCapacity(waiter.sessionKey)) {
|
|
1473
|
+
i++;
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
waiters.splice(i, 1);
|
|
1477
|
+
if (waiter.signal && waiter.onAbort) {
|
|
1478
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
1479
|
+
}
|
|
1480
|
+
take(waiter.sessionKey);
|
|
1481
|
+
waiter.grant();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/extensions/subagent/child.ts
|
|
1486
|
+
var PROMPT_DIR_PREFIX = "pi-pilot-subagent-";
|
|
1487
|
+
var TRANSCRIPTS_DIR = join8(tmpdir(), "pi-pilot-subagents", "transcripts");
|
|
1488
|
+
var ACTIVITY_MAX = 30;
|
|
1489
|
+
var LABEL_MAX = 160;
|
|
1490
|
+
var STDERR_TAIL_MAX = 2048;
|
|
1491
|
+
var FINAL_TEXT_MAX = 2e5;
|
|
1492
|
+
var SIGKILL_DELAY_MS = 5e3;
|
|
1493
|
+
async function runChild(opts) {
|
|
1494
|
+
const startedAt = Date.now();
|
|
1495
|
+
const cli = opts.cliPath ?? process.env.PI_PILOT_SUBAGENT_CLI ?? resolvePinnedPiCli();
|
|
1496
|
+
const promptDir = mkdtempSync(join8(tmpdir(), PROMPT_DIR_PREFIX));
|
|
1497
|
+
const promptPath = join8(promptDir, "prompt.md");
|
|
1498
|
+
writeFileSync(promptPath, opts.appendSystemPrompt, { mode: 384 });
|
|
1499
|
+
let transcriptPath;
|
|
1500
|
+
let tee;
|
|
1501
|
+
try {
|
|
1502
|
+
mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
|
|
1503
|
+
transcriptPath = join8(TRANSCRIPTS_DIR, `${sanitizeId(opts.toolCallId)}.ndjson`);
|
|
1504
|
+
tee = createWriteStream(transcriptPath, { flags: "w" });
|
|
1505
|
+
tee.on("error", () => {
|
|
1506
|
+
});
|
|
1507
|
+
} catch {
|
|
1508
|
+
transcriptPath = void 0;
|
|
1509
|
+
}
|
|
1510
|
+
const args = [cli, "--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
|
|
1511
|
+
const model = opts.agent.model ?? opts.inheritModel;
|
|
1512
|
+
if (model) args.push("--model", model);
|
|
1513
|
+
if (opts.agent.tools && opts.agent.tools.length > 0) {
|
|
1514
|
+
args.push("--tools", opts.agent.tools.join(","));
|
|
1515
|
+
}
|
|
1516
|
+
args.push("--append-system-prompt", promptPath);
|
|
1517
|
+
args.push(opts.task);
|
|
1518
|
+
const usage = emptyUsage();
|
|
1519
|
+
const activity = [];
|
|
1520
|
+
let modelSeen;
|
|
1521
|
+
let finalText = "";
|
|
1522
|
+
let stopReason;
|
|
1523
|
+
let errorMessage;
|
|
1524
|
+
let stderrAccum = "";
|
|
1525
|
+
let aborted = false;
|
|
1526
|
+
let timedOut = false;
|
|
1527
|
+
let costKilled = false;
|
|
1528
|
+
const child = spawn(process.execPath, args, {
|
|
1529
|
+
cwd: opts.cwd,
|
|
1530
|
+
env: { ...process.env, PI_PILOT_SUBAGENT: opts.toolCallId },
|
|
1531
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1532
|
+
shell: false
|
|
1533
|
+
});
|
|
1534
|
+
if (child.pid !== void 0) {
|
|
1535
|
+
try {
|
|
1536
|
+
writeFileSync(join8(promptDir, "pid"), `${child.pid}
|
|
1537
|
+
${process.pid}`);
|
|
1538
|
+
} catch {
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
let killed = false;
|
|
1542
|
+
let killTimer;
|
|
1543
|
+
const killGracefully = () => {
|
|
1544
|
+
if (killed) return;
|
|
1545
|
+
killed = true;
|
|
1546
|
+
try {
|
|
1547
|
+
child.kill("SIGTERM");
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
killTimer = setTimeout(() => {
|
|
1551
|
+
try {
|
|
1552
|
+
child.kill("SIGKILL");
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
}, SIGKILL_DELAY_MS);
|
|
1556
|
+
};
|
|
1557
|
+
registerChild(opts.toolCallId, {
|
|
1558
|
+
sessionFile: opts.sessionFile,
|
|
1559
|
+
agent: opts.agent.name,
|
|
1560
|
+
kill: killGracefully
|
|
1561
|
+
});
|
|
1562
|
+
const onAbort = () => {
|
|
1563
|
+
aborted = true;
|
|
1564
|
+
killGracefully();
|
|
1565
|
+
};
|
|
1566
|
+
if (opts.signal?.aborted) onAbort();
|
|
1567
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1568
|
+
let stalled = false;
|
|
1569
|
+
const timeoutTimer = setTimeout(() => {
|
|
1570
|
+
timedOut = true;
|
|
1571
|
+
killGracefully();
|
|
1572
|
+
}, opts.timeoutMs);
|
|
1573
|
+
let stallTimer;
|
|
1574
|
+
const armStallTimer = () => {
|
|
1575
|
+
if (killed) return;
|
|
1576
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
1577
|
+
stallTimer = setTimeout(() => {
|
|
1578
|
+
stalled = true;
|
|
1579
|
+
timedOut = true;
|
|
1580
|
+
killGracefully();
|
|
1581
|
+
}, opts.stallTimeoutMs);
|
|
1582
|
+
};
|
|
1583
|
+
armStallTimer();
|
|
1584
|
+
const emitProgress = () => {
|
|
1585
|
+
opts.onProgress({
|
|
1586
|
+
usage: { ...usage },
|
|
1587
|
+
model: modelSeen,
|
|
1588
|
+
activity: [...activity],
|
|
1589
|
+
lastLabel: activity[activity.length - 1]?.label
|
|
1590
|
+
});
|
|
1591
|
+
};
|
|
1592
|
+
const handleLine = (line) => {
|
|
1593
|
+
if (!line.trim()) return;
|
|
1594
|
+
tee?.write(line + "\n");
|
|
1595
|
+
let event;
|
|
1596
|
+
try {
|
|
1597
|
+
event = JSON.parse(line);
|
|
1598
|
+
} catch {
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const ev = event;
|
|
1602
|
+
if (ev.type !== "message_end" || !ev.message || typeof ev.message !== "object") return;
|
|
1603
|
+
const msg = ev.message;
|
|
1604
|
+
if (msg.role !== "assistant") return;
|
|
1605
|
+
usage.turns++;
|
|
1606
|
+
const u = msg.usage;
|
|
1607
|
+
if (u) {
|
|
1608
|
+
usage.input += u.input ?? 0;
|
|
1609
|
+
usage.output += u.output ?? 0;
|
|
1610
|
+
usage.cacheRead += u.cacheRead ?? 0;
|
|
1611
|
+
usage.cacheWrite += u.cacheWrite ?? 0;
|
|
1612
|
+
usage.cost += u.cost?.total ?? 0;
|
|
1613
|
+
usage.contextTokens = u.totalTokens ?? usage.contextTokens;
|
|
1614
|
+
}
|
|
1615
|
+
if (!modelSeen && typeof msg.model === "string") modelSeen = msg.model;
|
|
1616
|
+
if (typeof msg.stopReason === "string") stopReason = msg.stopReason;
|
|
1617
|
+
if (typeof msg.errorMessage === "string") errorMessage = msg.errorMessage;
|
|
1618
|
+
if (Array.isArray(msg.content)) {
|
|
1619
|
+
const textParts = [];
|
|
1620
|
+
for (const block of msg.content) {
|
|
1621
|
+
if (!block || typeof block !== "object") continue;
|
|
1622
|
+
const b = block;
|
|
1623
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
1624
|
+
textParts.push(b.text);
|
|
1625
|
+
} else if (b.type === "toolCall" && typeof b.name === "string") {
|
|
1626
|
+
pushActivity(activity, b.name, b.arguments);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
const text = textParts.join("").trim();
|
|
1630
|
+
if (text) finalText = text.slice(0, FINAL_TEXT_MAX);
|
|
1631
|
+
}
|
|
1632
|
+
if (usage.cost > opts.costCeilingUsd && !costKilled) {
|
|
1633
|
+
costKilled = true;
|
|
1634
|
+
killGracefully();
|
|
1635
|
+
}
|
|
1636
|
+
emitProgress();
|
|
1637
|
+
};
|
|
1638
|
+
let buf = "";
|
|
1639
|
+
child.stdout?.on("data", (chunk) => {
|
|
1640
|
+
armStallTimer();
|
|
1641
|
+
buf += chunk.toString("utf8");
|
|
1642
|
+
let nl;
|
|
1643
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1644
|
+
handleLine(buf.slice(0, nl));
|
|
1645
|
+
buf = buf.slice(nl + 1);
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
child.stderr?.on("data", (chunk) => {
|
|
1649
|
+
armStallTimer();
|
|
1650
|
+
stderrAccum = (stderrAccum + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX);
|
|
1651
|
+
});
|
|
1652
|
+
const exitCode = await new Promise((resolve8) => {
|
|
1653
|
+
child.on("error", (err2) => {
|
|
1654
|
+
errorMessage ??= err2 instanceof Error ? err2.message : String(err2);
|
|
1655
|
+
resolve8(-1);
|
|
1656
|
+
});
|
|
1657
|
+
child.on("close", (code) => resolve8(code ?? -1));
|
|
1658
|
+
});
|
|
1659
|
+
if (buf) handleLine(buf);
|
|
1660
|
+
clearTimeout(timeoutTimer);
|
|
1661
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
1662
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1663
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1664
|
+
unregisterChild(opts.toolCallId);
|
|
1665
|
+
tee?.end();
|
|
1666
|
+
await rm3(promptDir, { recursive: true, force: true }).catch(() => {
|
|
1667
|
+
});
|
|
1668
|
+
if (costKilled && !errorMessage) {
|
|
1669
|
+
errorMessage = `cost ceiling ($${opts.costCeilingUsd}) exceeded \u2014 child terminated`;
|
|
1670
|
+
}
|
|
1671
|
+
if (timedOut && !aborted && !errorMessage) {
|
|
1672
|
+
errorMessage = stalled ? `no output for ${Math.round(opts.stallTimeoutMs / 1e3)}s \u2014 presumed hung` : `wall-clock limit (${Math.round(opts.timeoutMs / 1e3)}s) reached while still active`;
|
|
1673
|
+
}
|
|
1674
|
+
const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" || costKilled;
|
|
1675
|
+
const status2 = aborted ? "aborted" : timedOut ? "timeout" : failed ? "failed" : "done";
|
|
1676
|
+
return {
|
|
1677
|
+
status: status2,
|
|
1678
|
+
finalText,
|
|
1679
|
+
usage,
|
|
1680
|
+
model: modelSeen,
|
|
1681
|
+
stopReason,
|
|
1682
|
+
errorMessage,
|
|
1683
|
+
stderrTail: stderrAccum.trim(),
|
|
1684
|
+
exitCode,
|
|
1685
|
+
durationMs: Date.now() - startedAt,
|
|
1686
|
+
transcriptPath,
|
|
1687
|
+
activity
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function sanitizeId(id) {
|
|
1691
|
+
return id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1692
|
+
}
|
|
1693
|
+
function pushActivity(activity, name, args) {
|
|
1694
|
+
const label = toolLabel(name, args).slice(0, LABEL_MAX);
|
|
1695
|
+
activity.push({ kind: "tool", label });
|
|
1696
|
+
if (activity.length > ACTIVITY_MAX) {
|
|
1697
|
+
activity.splice(0, activity.length - ACTIVITY_MAX);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function toolLabel(name, args) {
|
|
1701
|
+
const a = args && typeof args === "object" ? args : {};
|
|
1702
|
+
const pick = (...keys) => {
|
|
1703
|
+
for (const key of keys) {
|
|
1704
|
+
const v = a[key];
|
|
1705
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
1706
|
+
}
|
|
1707
|
+
return void 0;
|
|
1708
|
+
};
|
|
1709
|
+
let detail;
|
|
1710
|
+
switch (name) {
|
|
1711
|
+
case "bash":
|
|
1712
|
+
detail = pick("command");
|
|
1713
|
+
break;
|
|
1714
|
+
case "read":
|
|
1715
|
+
case "write":
|
|
1716
|
+
case "edit":
|
|
1717
|
+
detail = pick("path", "file_path");
|
|
1718
|
+
break;
|
|
1719
|
+
case "grep":
|
|
1720
|
+
detail = pick("pattern");
|
|
1721
|
+
break;
|
|
1722
|
+
case "find":
|
|
1723
|
+
detail = pick("pattern", "path");
|
|
1724
|
+
break;
|
|
1725
|
+
case "ls":
|
|
1726
|
+
detail = pick("path");
|
|
1727
|
+
break;
|
|
1728
|
+
default: {
|
|
1729
|
+
for (const v of Object.values(a)) {
|
|
1730
|
+
if (typeof v === "string" && v.trim()) {
|
|
1731
|
+
detail = v;
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
const clean = detail?.replace(/\s+/g, " ").trim();
|
|
1738
|
+
return clean ? `${name}: ${clean}` : name;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/extensions/subagent/factory.ts
|
|
1742
|
+
var TASK_OUTPUT_CAP = 12 * 1024;
|
|
1743
|
+
var AGGREGATE_OUTPUT_CAP = 48 * 1024;
|
|
1744
|
+
var PREVIEW_CAP = 8 * 1024;
|
|
1745
|
+
var STDERR_DETAILS_CAP = 1024;
|
|
1746
|
+
var UPDATE_THROTTLE_MS = 500;
|
|
1747
|
+
function tunable(envName, fallback) {
|
|
1748
|
+
const raw = process.env[envName];
|
|
1749
|
+
if (!raw) return fallback;
|
|
1750
|
+
const n = Number.parseFloat(raw);
|
|
1751
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1752
|
+
}
|
|
1753
|
+
var TASK_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_TIMEOUT_SEC", 3600) * 1e3;
|
|
1754
|
+
var STALL_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_STALL_SEC", 600) * 1e3;
|
|
1755
|
+
var COST_CEILING_USD = tunable("PI_PILOT_SUBAGENT_COST_USD", 20);
|
|
1756
|
+
var subagentExtensionFactory = (pi) => {
|
|
1757
|
+
const lastDetails = /* @__PURE__ */ new Map();
|
|
1758
|
+
const rosterAtRegistration = discoverAgents();
|
|
1759
|
+
pi.registerTool({
|
|
1760
|
+
name: "subagent",
|
|
1761
|
+
label: "Subagent",
|
|
1762
|
+
description: `Delegate self-contained tasks to subagents running in isolated contexts (separate pi processes that see nothing of this conversation). Returns only the subagents' final reports. Pass \`agent\` + \`task\` for one delegation, or \`tasks\` (up to ${MAX_TASKS_PER_CALL}) to run INDEPENDENT delegations in parallel. Available agents:
|
|
1763
|
+
` + rosterSummary(rosterAtRegistration) + `
|
|
1764
|
+
The roster is re-read on every call from ${userAgentsDir()}/*.md (builtin presets scout/worker/reviewer; a same-name user file overrides its preset; workspaces with project agents trusted also merge <cwd>/.pi/agents/*.md, which win over both).`,
|
|
1765
|
+
parameters: subagentParamsSchema,
|
|
1766
|
+
executionMode: "parallel",
|
|
1767
|
+
promptSnippet: "subagent: delegate self-contained tasks (recon, a bounded implementation step, a review pass) to isolated child agents \u2014 only their final reports return, keeping large searches and side work out of this context. Independent tasks can fan out in parallel via `tasks`.",
|
|
1768
|
+
promptGuidelines: [
|
|
1769
|
+
"Use subagent when a task is self-contained and would otherwise flood this context (broad codebase recon, a bounded implementation step, a review pass). Don't delegate trivial lookups \u2014 a single read/grep inline is faster and cheaper than a child agent.",
|
|
1770
|
+
"Write complete subagent briefs: the child sees NOTHING of this conversation. Include the relevant paths, constraints, acceptance criteria, and the exact shape of the report you want back.",
|
|
1771
|
+
"Pick the cheapest sufficient agent: scout for read-only recon, reviewer for read-mostly review, worker only when files must change.",
|
|
1772
|
+
`Use \`tasks\` (max ${MAX_TASKS_PER_CALL} per call) ONLY for independent work \u2014 no task's input may depend on another's output, and parallel workers must never edit the same files. All results return together; sequence dependent steps as separate calls instead.`,
|
|
1773
|
+
"Subagents cannot ask the user questions and cannot spawn further subagents. Resolve user decisions (ask_user) BEFORE delegating, and sequence dependent work yourself: delegate, read the report, then issue the next call with the context it needs."
|
|
1774
|
+
],
|
|
1775
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
1776
|
+
const roster = discoverAgents(ctx.cwd);
|
|
1777
|
+
const calls = normalizeCalls(params);
|
|
1778
|
+
if (typeof calls === "string") {
|
|
1779
|
+
return invalidCallResult(calls, params);
|
|
1780
|
+
}
|
|
1781
|
+
const { mode, briefs } = calls;
|
|
1782
|
+
const unknown = [...new Set(briefs.map((b) => b.agent.trim()))].filter(
|
|
1783
|
+
(name) => !roster.has(name)
|
|
1784
|
+
);
|
|
1785
|
+
if (unknown.length > 0) {
|
|
1786
|
+
return invalidCallResult(
|
|
1787
|
+
`Unknown agent${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")}. Available agents:
|
|
1788
|
+
` + rosterSummary(roster) + "\nCall subagent again with one of these names.",
|
|
1789
|
+
params
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
if (signal?.aborted) throw new Error("Aborted by user");
|
|
1793
|
+
const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
1794
|
+
const resolved = briefs.map((b) => ({
|
|
1795
|
+
agent: roster.get(b.agent.trim()),
|
|
1796
|
+
task: b.task
|
|
1797
|
+
}));
|
|
1798
|
+
const tasks = resolved.map((r) => ({
|
|
1799
|
+
agent: r.agent.name,
|
|
1800
|
+
agentSource: r.agent.source,
|
|
1801
|
+
task: r.task,
|
|
1802
|
+
status: "queued",
|
|
1803
|
+
activity: [],
|
|
1804
|
+
usage: emptyUsage()
|
|
1805
|
+
}));
|
|
1806
|
+
const details = { version: 1, mode, tasks };
|
|
1807
|
+
const emitter = makeThrottledEmitter(onUpdate, details);
|
|
1808
|
+
emitter.emit(true);
|
|
1809
|
+
const runOne = async (i) => {
|
|
1810
|
+
const task = tasks[i];
|
|
1811
|
+
const r = resolved[i];
|
|
1812
|
+
let release;
|
|
1813
|
+
try {
|
|
1814
|
+
release = await acquireChildSlot(signal, sessionFile);
|
|
1815
|
+
} catch (err2) {
|
|
1816
|
+
if (!signal?.aborted) throw err2;
|
|
1817
|
+
task.status = "aborted";
|
|
1818
|
+
emitter.emit(true);
|
|
1819
|
+
return void 0;
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
task.status = "running";
|
|
1823
|
+
emitter.emit(true);
|
|
1824
|
+
const outcome = await runChild({
|
|
1825
|
+
// Per-child id: registry entries, pidfiles and transcript
|
|
1826
|
+
// files must not collide across one call's siblings.
|
|
1827
|
+
toolCallId: mode === "single" ? toolCallId : `${toolCallId}.${i}`,
|
|
1828
|
+
agent: r.agent,
|
|
1829
|
+
task: r.task,
|
|
1830
|
+
cwd: ctx.cwd,
|
|
1831
|
+
sessionFile,
|
|
1832
|
+
appendSystemPrompt: composeChildPrompt(r.agent),
|
|
1833
|
+
inheritModel: r.agent.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : void 0),
|
|
1834
|
+
signal,
|
|
1835
|
+
timeoutMs: TASK_TIMEOUT_MS,
|
|
1836
|
+
stallTimeoutMs: STALL_TIMEOUT_MS,
|
|
1837
|
+
costCeilingUsd: COST_CEILING_USD,
|
|
1838
|
+
onProgress: (p) => {
|
|
1839
|
+
task.usage = p.usage;
|
|
1840
|
+
task.model = p.model ?? task.model;
|
|
1841
|
+
task.activity = p.activity;
|
|
1842
|
+
emitter.emit();
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
mergeOutcome(task, outcome);
|
|
1846
|
+
emitter.emit(true);
|
|
1847
|
+
return outcome;
|
|
1848
|
+
} finally {
|
|
1849
|
+
release();
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
try {
|
|
1853
|
+
const outcomes = await Promise.all(resolved.map((_, i) => runOne(i)));
|
|
1854
|
+
emitter.cancel();
|
|
1855
|
+
lastDetails.set(toolCallId, details);
|
|
1856
|
+
if (tasks.some((t) => t.status === "aborted")) {
|
|
1857
|
+
throw new Error("Aborted by user");
|
|
1858
|
+
}
|
|
1859
|
+
return {
|
|
1860
|
+
content: [{ type: "text", text: combinedParentText(mode, resolved, outcomes) }],
|
|
1861
|
+
details
|
|
1862
|
+
};
|
|
1863
|
+
} finally {
|
|
1864
|
+
emitter.cancel();
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
pi.on("tool_result", (ev) => {
|
|
1869
|
+
if (ev.toolName !== "subagent") return void 0;
|
|
1870
|
+
const fromMap = lastDetails.get(ev.toolCallId);
|
|
1871
|
+
lastDetails.delete(ev.toolCallId);
|
|
1872
|
+
const details = isSubagentDetails(ev.details) ? ev.details : fromMap;
|
|
1873
|
+
if (!details) return void 0;
|
|
1874
|
+
const failed = details.tasks.some(
|
|
1875
|
+
(t) => t.status === "failed" || t.status === "timeout"
|
|
1876
|
+
);
|
|
1877
|
+
const patch = {};
|
|
1878
|
+
if (failed && !ev.isError) patch.isError = true;
|
|
1879
|
+
if (ev.isError && !isSubagentDetails(ev.details) && fromMap) patch.details = fromMap;
|
|
1880
|
+
return patch.isError !== void 0 || patch.details !== void 0 ? patch : void 0;
|
|
1881
|
+
});
|
|
1882
|
+
};
|
|
1883
|
+
function normalizeCalls(params) {
|
|
1884
|
+
const hasSingle = params.agent !== void 0 || params.task !== void 0;
|
|
1885
|
+
const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
|
|
1886
|
+
if (hasSingle && hasTasks) {
|
|
1887
|
+
return "Pass EITHER `agent` + `task` (single delegation) OR `tasks` (parallel fan-out) \u2014 not both in one call.";
|
|
1888
|
+
}
|
|
1889
|
+
if (hasTasks) {
|
|
1890
|
+
const list = params.tasks;
|
|
1891
|
+
if (list.length > MAX_TASKS_PER_CALL) {
|
|
1892
|
+
return `tasks[] is capped at ${MAX_TASKS_PER_CALL} per call (got ${list.length}). Split the fan-out into multiple subagent calls.`;
|
|
1893
|
+
}
|
|
1894
|
+
if (list.some((t) => !t?.agent?.trim() || !t?.task?.trim())) {
|
|
1895
|
+
return "Every tasks[] entry needs a non-empty `agent` and `task`.";
|
|
1896
|
+
}
|
|
1897
|
+
return { mode: "parallel", briefs: list.map((t) => ({ agent: t.agent, task: t.task })) };
|
|
1898
|
+
}
|
|
1899
|
+
if (params.agent?.trim() && params.task?.trim()) {
|
|
1900
|
+
return { mode: "single", briefs: [{ agent: params.agent, task: params.task }] };
|
|
1901
|
+
}
|
|
1902
|
+
return "Provide either `agent` + `task` (single delegation) or `tasks` (parallel fan-out of independent briefs).";
|
|
1903
|
+
}
|
|
1904
|
+
function invalidCallResult(text, params) {
|
|
1905
|
+
return {
|
|
1906
|
+
content: [{ type: "text", text }],
|
|
1907
|
+
details: {
|
|
1908
|
+
version: 1,
|
|
1909
|
+
mode: Array.isArray(params.tasks) && params.tasks.length > 0 ? "parallel" : "single",
|
|
1910
|
+
tasks: []
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
function composeChildPrompt(agent) {
|
|
1915
|
+
const header = `You are running as the "${agent.name}" subagent, delegated one task by another agent via pi-pilot. Work only within the project's working directory.`;
|
|
1916
|
+
return agent.systemPrompt ? `${header}
|
|
1917
|
+
|
|
1918
|
+
${agent.systemPrompt}` : header;
|
|
1919
|
+
}
|
|
1920
|
+
function mergeOutcome(task, outcome) {
|
|
1921
|
+
task.status = outcome.status;
|
|
1922
|
+
task.usage = outcome.usage;
|
|
1923
|
+
task.activity = outcome.activity;
|
|
1924
|
+
task.model = outcome.model ?? task.model;
|
|
1925
|
+
task.stopReason = outcome.stopReason;
|
|
1926
|
+
task.errorMessage = outcome.errorMessage;
|
|
1927
|
+
task.stderrTail = outcome.stderrTail ? outcome.stderrTail.slice(-STDERR_DETAILS_CAP) : void 0;
|
|
1928
|
+
task.exitCode = outcome.exitCode;
|
|
1929
|
+
task.durationMs = outcome.durationMs;
|
|
1930
|
+
task.transcriptPath = outcome.transcriptPath;
|
|
1931
|
+
task.finalPreview = outcome.finalText ? tailCap(outcome.finalText, PREVIEW_CAP) : void 0;
|
|
1932
|
+
}
|
|
1933
|
+
function combinedParentText(mode, resolved, outcomes) {
|
|
1934
|
+
if (mode === "single") {
|
|
1935
|
+
return formatParentText(resolved[0].agent, outcomes[0], TASK_OUTPUT_CAP);
|
|
1936
|
+
}
|
|
1937
|
+
const n = outcomes.length;
|
|
1938
|
+
const perTaskCap = Math.min(TASK_OUTPUT_CAP, Math.floor(AGGREGATE_OUTPUT_CAP / n));
|
|
1939
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1940
|
+
for (const o of outcomes) {
|
|
1941
|
+
const s = o?.status ?? "aborted";
|
|
1942
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
1943
|
+
}
|
|
1944
|
+
const summary = [...counts.entries()].map(([s, c]) => `${c} ${s}`).join(", ");
|
|
1945
|
+
const sections = outcomes.map((o, i) => {
|
|
1946
|
+
const head = `=== task ${i + 1}/${n} ===`;
|
|
1947
|
+
const body = o ? formatParentText(resolved[i].agent, o, perTaskCap) : `[${resolved[i].agent.name}] never started (aborted while queued)`;
|
|
1948
|
+
return `${head}
|
|
1949
|
+
${body}`;
|
|
1950
|
+
});
|
|
1951
|
+
return [`[subagent] ${n} parallel tasks: ${summary}`, ...sections].join("\n\n");
|
|
1952
|
+
}
|
|
1953
|
+
function formatParentText(agent, outcome, outputCap) {
|
|
1954
|
+
const stats = `${Math.round(outcome.durationMs / 1e3)}s \xB7 ${outcome.usage.turns} turns \xB7 ${fmtTokens(outcome.usage.input + outcome.usage.output)} tokens \xB7 $${outcome.usage.cost.toFixed(3)}`;
|
|
1955
|
+
if (outcome.status === "done") {
|
|
1956
|
+
const body = outcome.finalText || "(the subagent produced no final text)";
|
|
1957
|
+
return `[${agent.name}] done in ${stats}
|
|
1958
|
+
|
|
1959
|
+
${tailCap(body, outputCap)}`;
|
|
1960
|
+
}
|
|
1961
|
+
const head = outcome.status === "timeout" ? `[${agent.name}] TIMED OUT after ${stats}` : `[${agent.name}] FAILED (stopReason=${outcome.stopReason ?? "?"}, exit ${outcome.exitCode}) after ${stats}`;
|
|
1962
|
+
const parts = [head];
|
|
1963
|
+
if (outcome.errorMessage) parts.push(`error: ${outcome.errorMessage}`);
|
|
1964
|
+
if (outcome.finalText) parts.push(`partial output:
|
|
1965
|
+
${tailCap(outcome.finalText, 2 * 1024)}`);
|
|
1966
|
+
if (outcome.stderrTail) parts.push(`stderr tail:
|
|
1967
|
+
${outcome.stderrTail.slice(-STDERR_DETAILS_CAP)}`);
|
|
1968
|
+
if (outcome.transcriptPath) parts.push(`full transcript: ${outcome.transcriptPath}`);
|
|
1969
|
+
if (outcome.status === "timeout") {
|
|
1970
|
+
parts.push(
|
|
1971
|
+
"note for the user: limits are env-tunable \u2014 PI_PILOT_SUBAGENT_STALL_SEC (silence detector) / PI_PILOT_SUBAGENT_TIMEOUT_SEC (total ceiling), server restart applies."
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
return parts.join("\n");
|
|
1975
|
+
}
|
|
1976
|
+
function tailCap(text, max) {
|
|
1977
|
+
if (text.length <= max) return text;
|
|
1978
|
+
const dropped = text.length - max;
|
|
1979
|
+
return `\u2026(${dropped} chars truncated \u2014 full transcript in details)
|
|
1980
|
+
${text.slice(-max)}`;
|
|
1981
|
+
}
|
|
1982
|
+
function fmtTokens(n) {
|
|
1983
|
+
return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
|
1984
|
+
}
|
|
1985
|
+
function makeThrottledEmitter(onUpdate, details) {
|
|
1986
|
+
let lastEmit = 0;
|
|
1987
|
+
let timer;
|
|
1988
|
+
let cancelled = false;
|
|
1989
|
+
const fire = () => {
|
|
1990
|
+
timer = void 0;
|
|
1991
|
+
lastEmit = Date.now();
|
|
1992
|
+
onUpdate?.({
|
|
1993
|
+
content: [{ type: "text", text: statusLine(details) }],
|
|
1994
|
+
details: structuredClone(details)
|
|
1995
|
+
});
|
|
1996
|
+
};
|
|
1997
|
+
return {
|
|
1998
|
+
emit: (force = false) => {
|
|
1999
|
+
if (cancelled || !onUpdate) return;
|
|
2000
|
+
const elapsed = Date.now() - lastEmit;
|
|
2001
|
+
if (force || elapsed >= UPDATE_THROTTLE_MS) {
|
|
2002
|
+
if (timer) {
|
|
2003
|
+
clearTimeout(timer);
|
|
2004
|
+
timer = void 0;
|
|
2005
|
+
}
|
|
2006
|
+
fire();
|
|
2007
|
+
} else if (!timer) {
|
|
2008
|
+
timer = setTimeout(fire, UPDATE_THROTTLE_MS - elapsed);
|
|
2009
|
+
}
|
|
2010
|
+
},
|
|
2011
|
+
cancel: () => {
|
|
2012
|
+
cancelled = true;
|
|
2013
|
+
if (timer) {
|
|
2014
|
+
clearTimeout(timer);
|
|
2015
|
+
timer = void 0;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
function statusLine(details) {
|
|
2021
|
+
if (details.tasks.length > 1) {
|
|
2022
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2023
|
+
let cost = 0;
|
|
2024
|
+
let turns = 0;
|
|
2025
|
+
for (const t2 of details.tasks) {
|
|
2026
|
+
counts.set(t2.status, (counts.get(t2.status) ?? 0) + 1);
|
|
2027
|
+
cost += t2.usage.cost;
|
|
2028
|
+
turns += t2.usage.turns;
|
|
2029
|
+
}
|
|
2030
|
+
const bits2 = [`${details.tasks.length} tasks`];
|
|
2031
|
+
for (const status2 of ["running", "queued", "done", "failed", "timeout", "aborted"]) {
|
|
2032
|
+
const c = counts.get(status2);
|
|
2033
|
+
if (c) bits2.push(`${c} ${status2}`);
|
|
2034
|
+
}
|
|
2035
|
+
if (turns > 0) bits2.push(`$${cost.toFixed(3)}`);
|
|
2036
|
+
return bits2.join(" \xB7 ");
|
|
2037
|
+
}
|
|
2038
|
+
const t = details.tasks[0];
|
|
2039
|
+
if (!t) return "subagent";
|
|
2040
|
+
const bits = [t.agent, t.status];
|
|
2041
|
+
if (t.usage.turns > 0) {
|
|
2042
|
+
bits.push(`${t.usage.turns} turns`);
|
|
2043
|
+
bits.push(`${fmtTokens(t.usage.input + t.usage.output)} tok`);
|
|
2044
|
+
bits.push(`$${t.usage.cost.toFixed(3)}`);
|
|
2045
|
+
}
|
|
2046
|
+
const last = t.activity[t.activity.length - 1];
|
|
2047
|
+
if (last && t.status === "running") bits.push(last.label);
|
|
2048
|
+
return bits.join(" \xB7 ");
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// src/extensions/web_search/schema.ts
|
|
2052
|
+
import { Type as Type5 } from "typebox";
|
|
2053
|
+
var webSearchParamsSchema = Type5.Object({
|
|
2054
|
+
query: Type5.String({
|
|
2055
|
+
description: "The search query. Phrase it like a search-engine query (keywords, entities), not a chat sentence."
|
|
2056
|
+
}),
|
|
2057
|
+
max_results: Type5.Optional(
|
|
2058
|
+
Type5.Number({
|
|
2059
|
+
description: "How many results to return (1\u201310). Default 5.",
|
|
2060
|
+
minimum: 1,
|
|
2061
|
+
maximum: 10
|
|
2062
|
+
})
|
|
2063
|
+
),
|
|
2064
|
+
topic: Type5.Optional(
|
|
2065
|
+
Type5.Union([Type5.Literal("general"), Type5.Literal("news")], {
|
|
2066
|
+
description: 'Search topic. Use "news" for recent/current events. Default "general".'
|
|
2067
|
+
})
|
|
2068
|
+
),
|
|
2069
|
+
search_depth: Type5.Optional(
|
|
2070
|
+
Type5.Union([Type5.Literal("basic"), Type5.Literal("advanced")], {
|
|
2071
|
+
description: '"advanced" digs deeper (slower, costs more credits); "basic" is usually enough. Default "basic".'
|
|
2072
|
+
})
|
|
2073
|
+
)
|
|
2074
|
+
});
|
|
2075
|
+
var webFetchParamsSchema = Type5.Object({
|
|
2076
|
+
urls: Type5.Array(
|
|
2077
|
+
Type5.String({ description: "An absolute http(s) URL." }),
|
|
2078
|
+
{
|
|
2079
|
+
description: "URLs to fetch and extract the main text from (1\u20135).",
|
|
2080
|
+
minItems: 1,
|
|
2081
|
+
maxItems: 5
|
|
2082
|
+
}
|
|
2083
|
+
)
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
// src/storage/web-search-prefs.ts
|
|
2087
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
2088
|
+
import { join as join9 } from "path";
|
|
2089
|
+
var PREFS_PATH2 = join9(config.dataDir, "web-search.json");
|
|
2090
|
+
var cache4 = {};
|
|
2091
|
+
async function loadWebSearchPrefs() {
|
|
2092
|
+
try {
|
|
2093
|
+
const raw = await readFile4(PREFS_PATH2, "utf8");
|
|
2094
|
+
const parsed = JSON.parse(raw);
|
|
2095
|
+
cache4 = { tavilyApiKey: typeof parsed.tavilyApiKey === "string" ? parsed.tavilyApiKey : void 0 };
|
|
2096
|
+
} catch (err2) {
|
|
2097
|
+
cache4 = {};
|
|
2098
|
+
if (err2.code !== "ENOENT") {
|
|
2099
|
+
console.warn(`[web-search-prefs] ignoring unreadable ${PREFS_PATH2}:`, err2);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
function getTavilyApiKey() {
|
|
2104
|
+
const fromSettings = cache4.tavilyApiKey?.trim();
|
|
2105
|
+
if (fromSettings) return fromSettings;
|
|
2106
|
+
const fromEnv = process.env.TAVILY_API_KEY?.trim();
|
|
2107
|
+
return fromEnv || void 0;
|
|
2108
|
+
}
|
|
2109
|
+
function getKeyStatus() {
|
|
2110
|
+
const fromSettings = cache4.tavilyApiKey?.trim();
|
|
2111
|
+
const fromEnv = process.env.TAVILY_API_KEY?.trim();
|
|
2112
|
+
const live = fromSettings || fromEnv || void 0;
|
|
2113
|
+
const source = fromSettings ? "settings" : fromEnv ? "env" : "none";
|
|
2114
|
+
return {
|
|
2115
|
+
configured: Boolean(live),
|
|
2116
|
+
source,
|
|
2117
|
+
...live ? { hint: maskKey(live) } : {}
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function maskKey(key) {
|
|
2121
|
+
return `\u2026${key.slice(-4)}`;
|
|
2122
|
+
}
|
|
2123
|
+
async function setTavilyApiKey(key) {
|
|
2124
|
+
const trimmed = key.trim();
|
|
2125
|
+
cache4 = trimmed ? { tavilyApiKey: trimmed } : {};
|
|
2126
|
+
await save3();
|
|
2127
|
+
}
|
|
2128
|
+
async function clearTavilyApiKey() {
|
|
2129
|
+
cache4 = {};
|
|
2130
|
+
await save3();
|
|
2131
|
+
}
|
|
2132
|
+
async function save3() {
|
|
2133
|
+
await writeJsonAtomic(PREFS_PATH2, cache4, { mode: 384 });
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/extensions/web_search/client.ts
|
|
2137
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
2138
|
+
function baseUrl() {
|
|
2139
|
+
return process.env.TAVILY_BASE_URL ?? "https://api.tavily.com";
|
|
2140
|
+
}
|
|
2141
|
+
var WebSearchError = class extends Error {
|
|
2142
|
+
constructor(message) {
|
|
2143
|
+
super(message);
|
|
2144
|
+
this.name = "WebSearchError";
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
function apiKey() {
|
|
2148
|
+
const key = getTavilyApiKey();
|
|
2149
|
+
if (!key) {
|
|
2150
|
+
throw new WebSearchError(
|
|
2151
|
+
"Web search is not configured: add a Tavily API key in Settings \u2192 Web search, or set the TAVILY_API_KEY environment variable. Get a free key at https://app.tavily.com."
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
return key;
|
|
2155
|
+
}
|
|
2156
|
+
async function postJson(path, body, signal) {
|
|
2157
|
+
const key = apiKey();
|
|
2158
|
+
const ctl = new AbortController();
|
|
2159
|
+
const onAbort = () => ctl.abort(signal?.reason);
|
|
2160
|
+
if (signal) {
|
|
2161
|
+
if (signal.aborted) ctl.abort(signal.reason);
|
|
2162
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2163
|
+
}
|
|
2164
|
+
const timer = setTimeout(
|
|
2165
|
+
() => ctl.abort(new WebSearchError(`Tavily request timed out after ${DEFAULT_TIMEOUT_MS / 1e3}s.`)),
|
|
2166
|
+
DEFAULT_TIMEOUT_MS
|
|
2167
|
+
);
|
|
2168
|
+
let res;
|
|
2169
|
+
try {
|
|
2170
|
+
res = await fetch(`${baseUrl()}${path}`, {
|
|
2171
|
+
method: "POST",
|
|
2172
|
+
headers: {
|
|
2173
|
+
"content-type": "application/json",
|
|
2174
|
+
authorization: `Bearer ${key}`
|
|
2175
|
+
},
|
|
2176
|
+
body: JSON.stringify(body),
|
|
2177
|
+
signal: ctl.signal
|
|
2178
|
+
});
|
|
2179
|
+
} catch (err2) {
|
|
2180
|
+
if (signal?.aborted) throw err2;
|
|
2181
|
+
const reason = ctl.signal.reason;
|
|
2182
|
+
if (reason instanceof WebSearchError) throw reason;
|
|
2183
|
+
throw new WebSearchError(`Tavily request failed: ${errMsg(err2)}`);
|
|
2184
|
+
} finally {
|
|
2185
|
+
clearTimeout(timer);
|
|
2186
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
2187
|
+
}
|
|
2188
|
+
if (!res.ok) {
|
|
2189
|
+
throw new WebSearchError(await describeHttpError(res));
|
|
2190
|
+
}
|
|
2191
|
+
try {
|
|
2192
|
+
return await res.json();
|
|
2193
|
+
} catch {
|
|
2194
|
+
throw new WebSearchError(
|
|
2195
|
+
`Tavily returned a non-JSON response (HTTP ${res.status}) \u2014 the service may be down or returning an error page.`
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
async function describeHttpError(res) {
|
|
2200
|
+
let detail = "";
|
|
2201
|
+
try {
|
|
2202
|
+
const data = await res.json();
|
|
2203
|
+
const d = data?.detail ?? data?.error;
|
|
2204
|
+
if (typeof d === "string") detail = d;
|
|
2205
|
+
else if (d && typeof d === "object" && typeof d.error === "string") {
|
|
2206
|
+
detail = d.error;
|
|
2207
|
+
}
|
|
2208
|
+
} catch {
|
|
2209
|
+
}
|
|
2210
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
2211
|
+
if (res.status === 401 || res.status === 403) {
|
|
2212
|
+
return `Tavily rejected the API key (HTTP ${res.status})${suffix}. Check TAVILY_API_KEY.`;
|
|
2213
|
+
}
|
|
2214
|
+
if (res.status === 429) {
|
|
2215
|
+
return `Tavily rate limit / quota exceeded (HTTP 429)${suffix}.`;
|
|
2216
|
+
}
|
|
2217
|
+
return `Tavily request failed (HTTP ${res.status})${suffix}.`;
|
|
2218
|
+
}
|
|
2219
|
+
function errMsg(err2) {
|
|
2220
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
2221
|
+
}
|
|
2222
|
+
function tavilySearch(opts, signal) {
|
|
2223
|
+
return postJson(
|
|
2224
|
+
"/search",
|
|
2225
|
+
{
|
|
2226
|
+
query: opts.query,
|
|
2227
|
+
max_results: opts.maxResults,
|
|
2228
|
+
topic: opts.topic,
|
|
2229
|
+
search_depth: opts.searchDepth,
|
|
2230
|
+
include_answer: true
|
|
2231
|
+
},
|
|
2232
|
+
signal
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
function tavilyExtract(urls, signal) {
|
|
2236
|
+
return postJson("/extract", { urls }, signal);
|
|
964
2237
|
}
|
|
965
2238
|
|
|
966
|
-
// src/extensions/
|
|
967
|
-
|
|
968
|
-
var
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
var
|
|
980
|
-
id: Type3.Optional(
|
|
981
|
-
Type3.String({
|
|
982
|
-
description: 'Stable identifier. Omit on first creation. To REVISE an existing artifact, pass the same id you used before \u2014 that records a new version instead of a separate artifact. Use a short slug like "landing-page".'
|
|
983
|
-
})
|
|
984
|
-
),
|
|
985
|
-
type: TypeEnum,
|
|
986
|
-
title: Type3.String({
|
|
987
|
-
description: "Short human-readable title shown in the artifact panel."
|
|
988
|
-
}),
|
|
989
|
-
content: Type3.String({
|
|
990
|
-
description: "The full artifact content \u2014 the complete document, markup, or source."
|
|
991
|
-
}),
|
|
992
|
-
language: Type3.Optional(
|
|
993
|
-
Type3.String({
|
|
994
|
-
description: 'For type="code", the language id for syntax highlighting (e.g. "python", "typescript").'
|
|
995
|
-
})
|
|
996
|
-
)
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
// src/extensions/artifact/factory.ts
|
|
1000
|
-
var TOOL_NAME2 = "create_artifact";
|
|
1001
|
-
var artifactExtensionFactory = (pi) => {
|
|
2239
|
+
// src/extensions/web_search/factory.ts
|
|
2240
|
+
var SNIPPET_CARD_CAP = 320;
|
|
2241
|
+
var SNIPPET_MODEL_CAP = 1024;
|
|
2242
|
+
var FETCH_PREVIEW_CAP = 2 * 1024;
|
|
2243
|
+
var FETCH_PER_URL_CAP = 8 * 1024;
|
|
2244
|
+
var FETCH_TOTAL_CAP = 24 * 1024;
|
|
2245
|
+
function clampInt(v, lo, hi, dflt) {
|
|
2246
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return dflt;
|
|
2247
|
+
return Math.max(lo, Math.min(hi, Math.round(v)));
|
|
2248
|
+
}
|
|
2249
|
+
function truncate(s, cap) {
|
|
2250
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}\u2026`;
|
|
2251
|
+
}
|
|
2252
|
+
var webSearchExtensionFactory = (pi) => {
|
|
1002
2253
|
pi.registerTool({
|
|
1003
|
-
name:
|
|
1004
|
-
label: "
|
|
1005
|
-
description:
|
|
1006
|
-
|
|
2254
|
+
name: "web_search",
|
|
2255
|
+
label: "Web search",
|
|
2256
|
+
description: "Search the web and get back a short answer plus ranked results (title, URL, snippet). Use it for current events, external documentation, or facts you're unsure of or that may have changed since your training cutoff. Follow up with `web_fetch` to read a result's full page when the snippet isn't enough.",
|
|
2257
|
+
parameters: webSearchParamsSchema,
|
|
2258
|
+
executionMode: "parallel",
|
|
2259
|
+
promptSnippet: "web_search: search the web (answer + ranked results) for current or uncertain facts; pair with web_fetch to read a page in full.",
|
|
1007
2260
|
promptGuidelines: [
|
|
1008
|
-
"
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
"After creating an artifact, keep your chat reply short \u2014 the content lives in the panel, so don't paste it again in prose."
|
|
2261
|
+
"Reach for web_search when the answer depends on current / post-cutoff information, external docs, or facts you can't verify from the repo or your own knowledge \u2014 not for things you already know.",
|
|
2262
|
+
'Phrase `query` like a search-engine query (keywords, entities), not a chat sentence. Set `topic: "news"` for recent events.',
|
|
2263
|
+
"Cite the URLs you relied on, and use web_fetch to read a page in full before trusting details beyond the snippet."
|
|
1012
2264
|
],
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
2265
|
+
async execute(_toolCallId, params, signal) {
|
|
2266
|
+
const query = params.query.trim();
|
|
2267
|
+
if (!query) throw new WebSearchError("web_search needs a non-empty query.");
|
|
2268
|
+
const maxResults = clampInt(params.max_results, 1, 10, 5);
|
|
2269
|
+
const topic = params.topic === "news" ? "news" : "general";
|
|
2270
|
+
const searchDepth = params.search_depth === "advanced" ? "advanced" : "basic";
|
|
2271
|
+
const data = await tavilySearch({ query, maxResults, topic, searchDepth }, signal);
|
|
2272
|
+
const raw = (data.results ?? []).filter(
|
|
2273
|
+
(r) => typeof r.url === "string"
|
|
2274
|
+
);
|
|
2275
|
+
const answer = typeof data.answer === "string" && data.answer.trim() ? data.answer.trim() : void 0;
|
|
2276
|
+
const results = raw.map((r) => ({
|
|
2277
|
+
title: (r.title ?? "").trim() || r.url,
|
|
2278
|
+
url: r.url,
|
|
2279
|
+
content: truncate((r.content ?? "").trim(), SNIPPET_CARD_CAP),
|
|
2280
|
+
score: typeof r.score === "number" ? r.score : void 0,
|
|
2281
|
+
publishedDate: typeof r.published_date === "string" ? r.published_date : void 0
|
|
2282
|
+
}));
|
|
2283
|
+
const details = { version: 1, kind: "search", query, answer, results };
|
|
2284
|
+
return { content: [{ type: "text", text: formatSearch(query, answer, raw) }], details };
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
pi.registerTool({
|
|
2288
|
+
name: "web_fetch",
|
|
2289
|
+
label: "Web fetch",
|
|
2290
|
+
description: "Fetch one or more web pages and extract their main text as clean, readable content (stripped of HTML/navigation/boilerplate). Use it to read a page in full \u2014 a URL returned by web_search, a documentation link, or a URL the user gave you.",
|
|
2291
|
+
parameters: webFetchParamsSchema,
|
|
2292
|
+
executionMode: "parallel",
|
|
2293
|
+
promptSnippet: "web_fetch: fetch URL(s) and extract the main page text \u2014 read a web_search result or a user-given link in full.",
|
|
2294
|
+
promptGuidelines: [
|
|
2295
|
+
"Use web_fetch to read the full text of a page when a web_search snippet isn't enough, or whenever the user hands you a URL.",
|
|
2296
|
+
"Pass up to 5 absolute http(s) URLs in one call to read them together."
|
|
2297
|
+
],
|
|
2298
|
+
async execute(_toolCallId, params, signal) {
|
|
2299
|
+
const urls = params.urls.map((u) => u.trim()).filter(Boolean);
|
|
2300
|
+
if (urls.length === 0) throw new WebSearchError("web_fetch needs at least one non-empty URL.");
|
|
2301
|
+
const data = await tavilyExtract(urls, signal);
|
|
2302
|
+
const results = (data.results ?? []).filter((r) => typeof r.url === "string").map((r) => {
|
|
2303
|
+
const full = (r.raw_content ?? "").trim();
|
|
2304
|
+
return { url: r.url, content: truncate(full, FETCH_PREVIEW_CAP), chars: full.length };
|
|
2305
|
+
});
|
|
2306
|
+
const failed = (data.failed_results ?? []).filter((f) => typeof f.url === "string").map((f) => ({ url: f.url, error: (f.error ?? "extraction failed").toString() }));
|
|
2307
|
+
const details = { version: 1, kind: "fetch", results, failed };
|
|
2308
|
+
return { content: [{ type: "text", text: formatFetch(data) }], details };
|
|
1026
2309
|
}
|
|
1027
2310
|
});
|
|
1028
2311
|
};
|
|
2312
|
+
function formatSearch(query, answer, results) {
|
|
2313
|
+
const lines = [];
|
|
2314
|
+
lines.push(`Search results for "${query}" (${results.length} result${results.length === 1 ? "" : "s"}):`);
|
|
2315
|
+
if (answer) {
|
|
2316
|
+
lines.push("");
|
|
2317
|
+
lines.push(`Answer: ${answer}`);
|
|
2318
|
+
}
|
|
2319
|
+
results.forEach((r, i) => {
|
|
2320
|
+
lines.push("");
|
|
2321
|
+
lines.push(`${i + 1}. ${(r.title ?? "").trim() || r.url}`);
|
|
2322
|
+
lines.push(` ${r.url}`);
|
|
2323
|
+
const snippet = (r.content ?? "").trim();
|
|
2324
|
+
if (snippet) lines.push(` ${truncate(snippet, SNIPPET_MODEL_CAP)}`);
|
|
2325
|
+
});
|
|
2326
|
+
if (results.length === 0) {
|
|
2327
|
+
lines.push("");
|
|
2328
|
+
lines.push("No results found.");
|
|
2329
|
+
}
|
|
2330
|
+
return lines.join("\n");
|
|
2331
|
+
}
|
|
2332
|
+
function formatFetch(data) {
|
|
2333
|
+
const results = (data.results ?? []).filter(
|
|
2334
|
+
(r) => typeof r.url === "string"
|
|
2335
|
+
);
|
|
2336
|
+
const failed = (data.failed_results ?? []).filter(
|
|
2337
|
+
(f) => typeof f.url === "string"
|
|
2338
|
+
);
|
|
2339
|
+
const lines = [];
|
|
2340
|
+
let budget = FETCH_TOTAL_CAP;
|
|
2341
|
+
for (const r of results) {
|
|
2342
|
+
const full = (r.raw_content ?? "").trim();
|
|
2343
|
+
const cap = Math.min(FETCH_PER_URL_CAP, budget);
|
|
2344
|
+
const slice = full.length > cap ? `${full.slice(0, cap)}\u2026` : full;
|
|
2345
|
+
budget -= Math.min(full.length, cap);
|
|
2346
|
+
lines.push(`## ${r.url}`);
|
|
2347
|
+
lines.push(slice || "(no extractable content)");
|
|
2348
|
+
lines.push("");
|
|
2349
|
+
if (budget <= 0) {
|
|
2350
|
+
lines.push("\u2026 (remaining pages omitted to fit the context budget)");
|
|
2351
|
+
break;
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
for (const f of failed) {
|
|
2355
|
+
lines.push(`Failed to fetch ${f.url}: ${f.error ?? "extraction failed"}`);
|
|
2356
|
+
}
|
|
2357
|
+
if (results.length === 0 && failed.length === 0) lines.push("No content extracted.");
|
|
2358
|
+
return lines.join("\n").trim();
|
|
2359
|
+
}
|
|
1029
2360
|
|
|
1030
2361
|
// src/storage/builtin-extension-prefs.ts
|
|
1031
|
-
import {
|
|
1032
|
-
import {
|
|
1033
|
-
var
|
|
1034
|
-
var
|
|
2362
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2363
|
+
import { join as join10 } from "path";
|
|
2364
|
+
var PREFS_PATH3 = join10(config.dataDir, "builtin-extensions.json");
|
|
2365
|
+
var cache5 = { disabled: [] };
|
|
1035
2366
|
async function loadBuiltinPrefs() {
|
|
1036
2367
|
try {
|
|
1037
|
-
const raw = await
|
|
2368
|
+
const raw = await readFile5(PREFS_PATH3, "utf8");
|
|
1038
2369
|
const parsed = JSON.parse(raw);
|
|
1039
|
-
|
|
2370
|
+
cache5 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
|
|
1040
2371
|
} catch (err2) {
|
|
1041
|
-
|
|
2372
|
+
cache5 = { disabled: [] };
|
|
1042
2373
|
if (err2.code !== "ENOENT") {
|
|
1043
|
-
console.warn(`[builtin-prefs] ignoring unreadable ${
|
|
2374
|
+
console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH3}:`, err2);
|
|
1044
2375
|
}
|
|
1045
2376
|
}
|
|
1046
2377
|
}
|
|
1047
2378
|
function isBuiltinDisabled(id) {
|
|
1048
|
-
if (
|
|
1049
|
-
if (id === "todo" &&
|
|
2379
|
+
if (cache5.disabled.includes(id)) return true;
|
|
2380
|
+
if (id === "todo" && cache5.disabled.includes("plan")) return true;
|
|
1050
2381
|
return false;
|
|
1051
2382
|
}
|
|
1052
2383
|
function getDisabledBuiltins() {
|
|
1053
|
-
return [...
|
|
2384
|
+
return [...cache5.disabled];
|
|
1054
2385
|
}
|
|
1055
2386
|
async function setBuiltinEnabled(id, enabled) {
|
|
1056
|
-
const next = new Set(
|
|
2387
|
+
const next = new Set(cache5.disabled);
|
|
1057
2388
|
if (enabled) next.delete(id);
|
|
1058
2389
|
else next.add(id);
|
|
1059
|
-
|
|
1060
|
-
await
|
|
2390
|
+
cache5 = { disabled: [...next] };
|
|
2391
|
+
await save4();
|
|
1061
2392
|
}
|
|
1062
|
-
async function
|
|
1063
|
-
await
|
|
1064
|
-
await writeFile3(PREFS_PATH, JSON.stringify(cache3, null, 2), "utf8");
|
|
2393
|
+
async function save4() {
|
|
2394
|
+
await writeJsonAtomic(PREFS_PATH3, cache5);
|
|
1065
2395
|
}
|
|
1066
2396
|
|
|
1067
2397
|
// src/extensions/index.ts
|
|
@@ -1089,6 +2419,22 @@ var BUILTIN_EXTENSIONS = [
|
|
|
1089
2419
|
tools: ["create_artifact"],
|
|
1090
2420
|
commands: [],
|
|
1091
2421
|
factory: artifactExtensionFactory
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
id: "subagent",
|
|
2425
|
+
name: "Subagent",
|
|
2426
|
+
description: "Lets the agent delegate self-contained tasks to isolated child agents (separate pinned-pi processes; only their final report returns). Builtin presets scout/worker/reviewer; user agents from ~/.pi/agent/agents/*.md. Adds the subagent tool.",
|
|
2427
|
+
tools: ["subagent"],
|
|
2428
|
+
commands: [],
|
|
2429
|
+
factory: subagentExtensionFactory
|
|
2430
|
+
},
|
|
2431
|
+
{
|
|
2432
|
+
id: "web",
|
|
2433
|
+
name: "Web search",
|
|
2434
|
+
description: "Lets the agent search the web and read pages \u2014 adds the web_search and web_fetch tools (backed by Tavily; needs the TAVILY_API_KEY environment variable).",
|
|
2435
|
+
tools: ["web_search", "web_fetch"],
|
|
2436
|
+
commands: [],
|
|
2437
|
+
factory: webSearchExtensionFactory
|
|
1092
2438
|
}
|
|
1093
2439
|
];
|
|
1094
2440
|
function gate(def) {
|
|
@@ -1154,6 +2500,116 @@ function reconcileAfterRestart(sessionManager) {
|
|
|
1154
2500
|
);
|
|
1155
2501
|
}
|
|
1156
2502
|
|
|
2503
|
+
// src/extensions/subagent/cleanup.ts
|
|
2504
|
+
import { execFile as execFile2 } from "child_process";
|
|
2505
|
+
import { readdir, readFile as readFile6, rm as rm4 } from "fs/promises";
|
|
2506
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2507
|
+
import { join as join11 } from "path";
|
|
2508
|
+
var CUSTOM_TYPE2 = "subagent-restart-cancelled";
|
|
2509
|
+
function reconcileAfterRestart2(sessionManager) {
|
|
2510
|
+
const branch = sessionManager.getBranch();
|
|
2511
|
+
if (branch.length === 0) return;
|
|
2512
|
+
const satisfied = /* @__PURE__ */ new Set();
|
|
2513
|
+
const danglingIds = [];
|
|
2514
|
+
const danglingAlreadyHandled = /* @__PURE__ */ new Set();
|
|
2515
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
2516
|
+
const entry = branch[i];
|
|
2517
|
+
if (entry.type === "custom_message") {
|
|
2518
|
+
const cm = entry;
|
|
2519
|
+
if (cm.customType === CUSTOM_TYPE2) {
|
|
2520
|
+
const ids = cm.details?.ids;
|
|
2521
|
+
if (Array.isArray(ids)) {
|
|
2522
|
+
for (const id of ids) {
|
|
2523
|
+
if (typeof id === "string") danglingAlreadyHandled.add(id);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
if (entry.type !== "message") continue;
|
|
2530
|
+
const msg = entry.message;
|
|
2531
|
+
if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
2532
|
+
satisfied.add(msg.toolCallId);
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
2536
|
+
for (const block of msg.content) {
|
|
2537
|
+
if (!block || typeof block !== "object") continue;
|
|
2538
|
+
const b = block;
|
|
2539
|
+
if (b.type !== "toolCall") continue;
|
|
2540
|
+
if (b.name !== "subagent") continue;
|
|
2541
|
+
if (typeof b.id !== "string") continue;
|
|
2542
|
+
if (satisfied.has(b.id)) continue;
|
|
2543
|
+
if (danglingAlreadyHandled.has(b.id)) continue;
|
|
2544
|
+
danglingIds.push(b.id);
|
|
2545
|
+
}
|
|
2546
|
+
break;
|
|
2547
|
+
}
|
|
2548
|
+
if (msg.role === "user") break;
|
|
2549
|
+
}
|
|
2550
|
+
if (danglingIds.length === 0) return;
|
|
2551
|
+
const idList = danglingIds.join(", ");
|
|
2552
|
+
const text = `[pi-pilot] Your previous subagent delegation(s) [${idList}] were cancelled because the server restarted mid-run. The child agent's work may be partially applied to the working tree \u2014 verify before assuming anything happened. Re-delegate if the task still matters.`;
|
|
2553
|
+
sessionManager.appendCustomMessageEntry(
|
|
2554
|
+
CUSTOM_TYPE2,
|
|
2555
|
+
text,
|
|
2556
|
+
true,
|
|
2557
|
+
// display flag — no-op in pi-pilot, harmless in the TUI
|
|
2558
|
+
{ ids: danglingIds }
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir2()) {
|
|
2562
|
+
let dirNames;
|
|
2563
|
+
try {
|
|
2564
|
+
dirNames = (await readdir(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
|
|
2565
|
+
} catch {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
let swept = 0;
|
|
2569
|
+
for (const name of dirNames) {
|
|
2570
|
+
const dir = join11(rootDir, name);
|
|
2571
|
+
const pidRaw = await readFile6(join11(dir, "pid"), "utf8").catch(() => "");
|
|
2572
|
+
const [childLine, ownerLine] = pidRaw.split("\n");
|
|
2573
|
+
const childPid = Number.parseInt((childLine ?? "").trim(), 10);
|
|
2574
|
+
const ownerPid = Number.parseInt((ownerLine ?? "").trim(), 10);
|
|
2575
|
+
if (Number.isInteger(ownerPid) && ownerPid > 1 && await isLiveNodeProcess(ownerPid)) {
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
try {
|
|
2579
|
+
if (Number.isInteger(childPid) && childPid > 1 && await isLiveNodeProcess(childPid)) {
|
|
2580
|
+
try {
|
|
2581
|
+
process.kill(childPid, "SIGTERM");
|
|
2582
|
+
swept++;
|
|
2583
|
+
} catch {
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
} finally {
|
|
2587
|
+
await rm4(dir, { recursive: true, force: true }).catch(() => {
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (swept > 0) {
|
|
2592
|
+
console.warn(`[subagent] swept ${swept} orphaned child(ren) from a previous run`);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function isLiveNodeProcess(pid) {
|
|
2596
|
+
try {
|
|
2597
|
+
process.kill(pid, 0);
|
|
2598
|
+
} catch {
|
|
2599
|
+
return Promise.resolve(false);
|
|
2600
|
+
}
|
|
2601
|
+
return new Promise((resolve8) => {
|
|
2602
|
+
execFile2("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
|
|
2603
|
+
if (err2) {
|
|
2604
|
+
resolve8(false);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
const name = stdout.trim().toLowerCase();
|
|
2608
|
+
resolve8(name === "node" || name === "pi");
|
|
2609
|
+
});
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
1157
2613
|
// src/ws/bridge.ts
|
|
1158
2614
|
function translatePiEvent(ev) {
|
|
1159
2615
|
switch (ev.type) {
|
|
@@ -1195,13 +2651,16 @@ function translatePiEvent(ev) {
|
|
|
1195
2651
|
toolName: ev.toolName,
|
|
1196
2652
|
args: ev.args
|
|
1197
2653
|
};
|
|
1198
|
-
case "tool_execution_update":
|
|
2654
|
+
case "tool_execution_update": {
|
|
2655
|
+
const updateDetails = shouldForwardDetails(ev.toolName) ? ev.partialResult?.details : void 0;
|
|
1199
2656
|
return {
|
|
1200
2657
|
kind: "tool_execution_update",
|
|
1201
2658
|
toolCallId: ev.toolCallId,
|
|
1202
2659
|
toolName: ev.toolName,
|
|
1203
|
-
partialText: extractText(ev.partialResult)
|
|
2660
|
+
partialText: extractText(ev.partialResult),
|
|
2661
|
+
...updateDetails !== void 0 ? { details: updateDetails } : {}
|
|
1204
2662
|
};
|
|
2663
|
+
}
|
|
1205
2664
|
case "tool_execution_end": {
|
|
1206
2665
|
const details = shouldForwardDetails(ev.toolName) ? ev.result?.details : void 0;
|
|
1207
2666
|
return {
|
|
@@ -1354,7 +2813,13 @@ function inFlightToolCallsSnapshot(sessionFile) {
|
|
|
1354
2813
|
args: p.args
|
|
1355
2814
|
}));
|
|
1356
2815
|
}
|
|
1357
|
-
var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
|
|
2816
|
+
var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
|
|
2817
|
+
"ask_user",
|
|
2818
|
+
"todo",
|
|
2819
|
+
"subagent",
|
|
2820
|
+
"web_search",
|
|
2821
|
+
"web_fetch"
|
|
2822
|
+
]);
|
|
1358
2823
|
function shouldForwardDetails(toolName) {
|
|
1359
2824
|
return DETAILS_FORWARD_WHITELIST.has(toolName);
|
|
1360
2825
|
}
|
|
@@ -1503,7 +2968,7 @@ var SessionRuntimeManager = class {
|
|
|
1503
2968
|
/** `runtimeKey` for a built runtime, from its session file (or sessionId). */
|
|
1504
2969
|
keyForRuntime(workspaceId, runtime) {
|
|
1505
2970
|
const file = runtime.session.sessionFile;
|
|
1506
|
-
return this.keyOf(workspaceId, file ?
|
|
2971
|
+
return this.keyOf(workspaceId, file ? resolve4(file) : runtime.session.sessionId);
|
|
1507
2972
|
}
|
|
1508
2973
|
/** Public so the WS hub derives the exact same key for a returned runtime. */
|
|
1509
2974
|
runtimeKeyFor(workspaceId, runtime) {
|
|
@@ -1526,17 +2991,18 @@ var SessionRuntimeManager = class {
|
|
|
1526
2991
|
async buildState(workspaceId, cwd, makeSessionManager) {
|
|
1527
2992
|
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
1528
2993
|
cwd,
|
|
1529
|
-
agentDir:
|
|
2994
|
+
agentDir: getAgentDir2(),
|
|
1530
2995
|
sessionManager: makeSessionManager()
|
|
1531
2996
|
});
|
|
1532
2997
|
const bridge = new ExtensionUIBridge();
|
|
1533
2998
|
await this.bindExtensions(workspaceId, runtime, bridge);
|
|
1534
|
-
|
|
2999
|
+
reapplyToolPrefs(workspaceId, runtime.session);
|
|
3000
|
+
safeReconcileBuiltins(workspaceId, runtime.session.sessionManager);
|
|
1535
3001
|
return {
|
|
1536
3002
|
runtime,
|
|
1537
3003
|
bridge,
|
|
1538
3004
|
workspaceId,
|
|
1539
|
-
sessionPath: runtime.session.sessionFile ?
|
|
3005
|
+
sessionPath: runtime.session.sessionFile ? resolve4(runtime.session.sessionFile) : null,
|
|
1540
3006
|
touchedAt: ++this.touchSeq
|
|
1541
3007
|
};
|
|
1542
3008
|
}
|
|
@@ -1586,7 +3052,7 @@ ${err2.stack}` : "")
|
|
|
1586
3052
|
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
1587
3053
|
if (sessionPath) {
|
|
1588
3054
|
if (!isAbsolute2(sessionPath)) throw new Error("Session path must be absolute");
|
|
1589
|
-
const resolved =
|
|
3055
|
+
const resolved = resolve4(sessionPath);
|
|
1590
3056
|
const key = this.keyOf(workspaceId, resolved);
|
|
1591
3057
|
const existing2 = this.runtimes.get(key);
|
|
1592
3058
|
if (existing2) {
|
|
@@ -1649,7 +3115,7 @@ ${err2.stack}` : "")
|
|
|
1649
3115
|
const state = await this.buildState(
|
|
1650
3116
|
workspaceId,
|
|
1651
3117
|
ws.path,
|
|
1652
|
-
() => SessionManager.open(
|
|
3118
|
+
() => SessionManager.open(resolve4(sourceSessionPath), void 0, ws.path)
|
|
1653
3119
|
);
|
|
1654
3120
|
let result;
|
|
1655
3121
|
try {
|
|
@@ -1663,8 +3129,8 @@ ${err2.stack}` : "")
|
|
|
1663
3129
|
return { cancelled: true };
|
|
1664
3130
|
}
|
|
1665
3131
|
await this.bindExtensions(workspaceId, state.runtime, state.bridge);
|
|
1666
|
-
|
|
1667
|
-
state.sessionPath = state.runtime.session.sessionFile ?
|
|
3132
|
+
safeReconcileBuiltins(workspaceId, state.runtime.session.sessionManager);
|
|
3133
|
+
state.sessionPath = state.runtime.session.sessionFile ? resolve4(state.runtime.session.sessionFile) : null;
|
|
1668
3134
|
const winner = await this.adopt(state);
|
|
1669
3135
|
return { cancelled: false, runtime: winner.runtime };
|
|
1670
3136
|
}
|
|
@@ -1683,7 +3149,7 @@ ${err2.stack}` : "")
|
|
|
1683
3149
|
}
|
|
1684
3150
|
/** The runtime bound to a specific (workspace, session), if live. */
|
|
1685
3151
|
getForSession(workspaceId, sessionPath) {
|
|
1686
|
-
return this.runtimes.get(this.keyOf(workspaceId,
|
|
3152
|
+
return this.runtimes.get(this.keyOf(workspaceId, resolve4(sessionPath)))?.runtime;
|
|
1687
3153
|
}
|
|
1688
3154
|
/** Mark `runtime` as the active session for its workspace (hub on primary
|
|
1689
3155
|
* bind), so per-workspace routes resolve to it. */
|
|
@@ -1724,8 +3190,8 @@ ${err2.stack}` : "")
|
|
|
1724
3190
|
const ws = await getWorkspace(workspaceId);
|
|
1725
3191
|
if (!ws) return `workspace not found: ${workspaceId}`;
|
|
1726
3192
|
const sessions = await SessionManager.list(ws.path);
|
|
1727
|
-
const resolved =
|
|
1728
|
-
const found = sessions.some((s) =>
|
|
3193
|
+
const resolved = resolve4(sessionPath);
|
|
3194
|
+
const found = sessions.some((s) => resolve4(s.path) === resolved);
|
|
1729
3195
|
if (!found) return `session not found in workspace: ${sessionPath}`;
|
|
1730
3196
|
return null;
|
|
1731
3197
|
}
|
|
@@ -1740,7 +3206,7 @@ ${err2.stack}` : "")
|
|
|
1740
3206
|
}
|
|
1741
3207
|
}
|
|
1742
3208
|
const sessions = await SessionManager.list(ws.path);
|
|
1743
|
-
return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(
|
|
3209
|
+
return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(resolve4(info.path))));
|
|
1744
3210
|
}
|
|
1745
3211
|
getSessionHistory(workspaceId, sessionPath) {
|
|
1746
3212
|
const runtime = sessionPath ? this.getForSession(workspaceId, sessionPath) : this.get(workspaceId);
|
|
@@ -1808,8 +3274,8 @@ ${err2.stack}` : "")
|
|
|
1808
3274
|
throw new HttpError(400, "Session path must be absolute");
|
|
1809
3275
|
}
|
|
1810
3276
|
const sessions = await SessionManager.list(ws.path);
|
|
1811
|
-
const resolved =
|
|
1812
|
-
const target = sessions.find((session) =>
|
|
3277
|
+
const resolved = resolve4(sessionPath);
|
|
3278
|
+
const target = sessions.find((session) => resolve4(session.path) === resolved);
|
|
1813
3279
|
if (!target) {
|
|
1814
3280
|
throw new HttpError(404, `Session not found: ${sessionPath}`);
|
|
1815
3281
|
}
|
|
@@ -1831,10 +3297,12 @@ ${err2.stack}` : "")
|
|
|
1831
3297
|
console.warn(
|
|
1832
3298
|
`[wm] deleteSession: ${resolved} was already gone at unlink time`
|
|
1833
3299
|
);
|
|
3300
|
+
await forgetSessionToolPrefs(resolved);
|
|
1834
3301
|
return;
|
|
1835
3302
|
}
|
|
1836
3303
|
throw err2;
|
|
1837
3304
|
}
|
|
3305
|
+
await forgetSessionToolPrefs(resolved);
|
|
1838
3306
|
}
|
|
1839
3307
|
/** Dispose every runtime for a workspace (e.g. when it's removed). */
|
|
1840
3308
|
async dispose(workspaceId) {
|
|
@@ -1871,6 +3339,12 @@ ${err2.stack}` : "")
|
|
|
1871
3339
|
} catch (e) {
|
|
1872
3340
|
console.error(`[wm] dispose ${state.workspaceId} failed:`, e);
|
|
1873
3341
|
}
|
|
3342
|
+
const sweptChildren = killChildrenForSession(state.runtime.session.sessionFile ?? null);
|
|
3343
|
+
if (sweptChildren > 0) {
|
|
3344
|
+
console.warn(
|
|
3345
|
+
`[wm] killed ${sweptChildren} lingering subagent child(ren) for ${state.workspaceId}`
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
1874
3348
|
}
|
|
1875
3349
|
/**
|
|
1876
3350
|
* Keep the live-runtime count under `MAX_LIVE_RUNTIMES` by disposing the
|
|
@@ -1898,21 +3372,26 @@ ${err2.stack}` : "")
|
|
|
1898
3372
|
}
|
|
1899
3373
|
}
|
|
1900
3374
|
};
|
|
1901
|
-
function
|
|
3375
|
+
function safeReconcileBuiltins(workspaceId, sm) {
|
|
1902
3376
|
try {
|
|
1903
3377
|
reconcileAfterRestart(sm);
|
|
1904
3378
|
} catch (e) {
|
|
1905
3379
|
console.error(`[wm] ask_user cleanup for ${workspaceId} failed:`, e);
|
|
1906
3380
|
}
|
|
3381
|
+
try {
|
|
3382
|
+
reconcileAfterRestart2(sm);
|
|
3383
|
+
} catch (e) {
|
|
3384
|
+
console.error(`[wm] subagent cleanup for ${workspaceId} failed:`, e);
|
|
3385
|
+
}
|
|
1907
3386
|
}
|
|
1908
|
-
function toSessionSummary(info,
|
|
3387
|
+
function toSessionSummary(info, running2) {
|
|
1909
3388
|
const preview = info.firstMessage.replace(/\s+/g, " ").trim();
|
|
1910
3389
|
return {
|
|
1911
3390
|
path: info.path,
|
|
1912
3391
|
name: info.name,
|
|
1913
3392
|
updatedAt: info.modified.toISOString(),
|
|
1914
3393
|
preview: preview ? preview.slice(0, 160) : void 0,
|
|
1915
|
-
...
|
|
3394
|
+
...running2 ? { running: true } : {}
|
|
1916
3395
|
};
|
|
1917
3396
|
}
|
|
1918
3397
|
function extractUserText2(msg) {
|
|
@@ -1961,6 +3440,9 @@ function broadcastTo(subscribers, msg) {
|
|
|
1961
3440
|
}
|
|
1962
3441
|
|
|
1963
3442
|
// src/api/config.ts
|
|
3443
|
+
var BUILTIN_TOOL_SOURCE = new Map(
|
|
3444
|
+
BUILTIN_EXTENSIONS.flatMap((d) => d.tools.map((tool) => [tool, d.name]))
|
|
3445
|
+
);
|
|
1964
3446
|
function buildConfigResponse(workspaceId) {
|
|
1965
3447
|
const runtime = workspaceManager.get(workspaceId);
|
|
1966
3448
|
if (!runtime) throw new Error("runtime not initialized");
|
|
@@ -1978,10 +3460,14 @@ function buildConfigResponse(workspaceId) {
|
|
|
1978
3460
|
name: m.name,
|
|
1979
3461
|
reasoning: m.reasoning
|
|
1980
3462
|
}));
|
|
1981
|
-
const allTools = session.getAllTools().map((t) =>
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
3463
|
+
const allTools = session.getAllTools().map((t) => {
|
|
3464
|
+
const builtinExtension = BUILTIN_TOOL_SOURCE.get(t.name);
|
|
3465
|
+
return {
|
|
3466
|
+
name: t.name,
|
|
3467
|
+
description: t.description,
|
|
3468
|
+
...builtinExtension ? { builtinExtension } : {}
|
|
3469
|
+
};
|
|
3470
|
+
});
|
|
1985
3471
|
return {
|
|
1986
3472
|
currentModel,
|
|
1987
3473
|
thinkingLevel: session.thinkingLevel,
|
|
@@ -2105,7 +3591,7 @@ function mountConfigRoutes(app2) {
|
|
|
2105
3591
|
if (!runtime) {
|
|
2106
3592
|
return c.json({ ok: false, error: "runtime not initialized" }, 500);
|
|
2107
3593
|
}
|
|
2108
|
-
runtime.session
|
|
3594
|
+
await persistActiveTools(id, runtime.session, body.tools);
|
|
2109
3595
|
return c.json(buildConfigResponse(id));
|
|
2110
3596
|
} catch (err2) {
|
|
2111
3597
|
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -2144,11 +3630,11 @@ function mountConfigRoutes(app2) {
|
|
|
2144
3630
|
}
|
|
2145
3631
|
|
|
2146
3632
|
// src/api/files.ts
|
|
2147
|
-
import { execFile as
|
|
2148
|
-
import { readdir } from "fs/promises";
|
|
2149
|
-
import { join as
|
|
3633
|
+
import { execFile as execFile3 } from "child_process";
|
|
3634
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
3635
|
+
import { join as join12, relative, sep as sep2 } from "path";
|
|
2150
3636
|
import { promisify as promisify2 } from "util";
|
|
2151
|
-
var exec2 = promisify2(
|
|
3637
|
+
var exec2 = promisify2(execFile3);
|
|
2152
3638
|
var LIST_TTL_MS = 1e4;
|
|
2153
3639
|
var MAX_CACHED_WORKSPACES = 16;
|
|
2154
3640
|
var MAX_FILES_TRACKED = 2e4;
|
|
@@ -2176,8 +3662,8 @@ var listCache = /* @__PURE__ */ new Map();
|
|
|
2176
3662
|
var inflight2 = /* @__PURE__ */ new Map();
|
|
2177
3663
|
async function getFileList(workspacePath) {
|
|
2178
3664
|
const now = Date.now();
|
|
2179
|
-
const
|
|
2180
|
-
if (
|
|
3665
|
+
const cached2 = listCache.get(workspacePath);
|
|
3666
|
+
if (cached2 && cached2.expiresAt > now) return cached2.files;
|
|
2181
3667
|
const pending2 = inflight2.get(workspacePath);
|
|
2182
3668
|
if (pending2) return (await pending2).files;
|
|
2183
3669
|
const probe = probeFileList(workspacePath).then((files) => {
|
|
@@ -2228,14 +3714,14 @@ async function walkDir(root, dir, depth, out) {
|
|
|
2228
3714
|
if (depth > WALK_MAX_DEPTH) return;
|
|
2229
3715
|
let dirents;
|
|
2230
3716
|
try {
|
|
2231
|
-
dirents = await
|
|
3717
|
+
dirents = await readdir2(dir, { withFileTypes: true });
|
|
2232
3718
|
} catch {
|
|
2233
3719
|
return;
|
|
2234
3720
|
}
|
|
2235
3721
|
for (const d of dirents) {
|
|
2236
3722
|
if (out.length >= MAX_FILES_TRACKED) return;
|
|
2237
3723
|
if (WALK_IGNORES.has(d.name)) continue;
|
|
2238
|
-
const abs =
|
|
3724
|
+
const abs = join12(dir, d.name);
|
|
2239
3725
|
if (d.isDirectory()) {
|
|
2240
3726
|
await walkDir(root, abs, depth + 1, out);
|
|
2241
3727
|
} else if (d.isFile()) {
|
|
@@ -2275,7 +3761,7 @@ function mountFilesRoute(app2) {
|
|
|
2275
3761
|
if (!qRaw) {
|
|
2276
3762
|
const slice = all.slice(0, limit);
|
|
2277
3763
|
entries = slice.map((relPath) => ({
|
|
2278
|
-
path:
|
|
3764
|
+
path: join12(workspacePath, relPath),
|
|
2279
3765
|
relPath
|
|
2280
3766
|
}));
|
|
2281
3767
|
truncated = all.length > limit;
|
|
@@ -2292,7 +3778,7 @@ function mountFilesRoute(app2) {
|
|
|
2292
3778
|
scored.sort((a, b) => b.score - a.score);
|
|
2293
3779
|
const top = scored.slice(0, limit);
|
|
2294
3780
|
entries = top.map((e) => ({
|
|
2295
|
-
path:
|
|
3781
|
+
path: join12(workspacePath, e.relPath),
|
|
2296
3782
|
relPath: e.relPath
|
|
2297
3783
|
}));
|
|
2298
3784
|
truncated = matchCount > limit;
|
|
@@ -2308,9 +3794,9 @@ function mountFilesRoute(app2) {
|
|
|
2308
3794
|
}
|
|
2309
3795
|
|
|
2310
3796
|
// src/api/resources.ts
|
|
2311
|
-
import { readdir as
|
|
2312
|
-
import { join as
|
|
2313
|
-
import { getAgentDir as
|
|
3797
|
+
import { readdir as readdir3 } from "fs/promises";
|
|
3798
|
+
import { join as join13 } from "path";
|
|
3799
|
+
import { getAgentDir as getAgentDir3 } from "@earendil-works/pi-coding-agent";
|
|
2314
3800
|
function toResourceSource(info) {
|
|
2315
3801
|
return {
|
|
2316
3802
|
scope: info.scope,
|
|
@@ -2319,16 +3805,16 @@ function toResourceSource(info) {
|
|
|
2319
3805
|
};
|
|
2320
3806
|
}
|
|
2321
3807
|
async function scanExtensionDirs(workspaceCwd) {
|
|
2322
|
-
const dirs = [
|
|
3808
|
+
const dirs = [join13(getAgentDir3(), "extensions"), join13(workspaceCwd, ".pi", "extensions")];
|
|
2323
3809
|
const found = [];
|
|
2324
3810
|
for (const dir of dirs) {
|
|
2325
3811
|
try {
|
|
2326
|
-
const entries = await
|
|
3812
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
2327
3813
|
for (const entry of entries) {
|
|
2328
3814
|
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
2329
|
-
found.push(
|
|
3815
|
+
found.push(join13(dir, entry.name));
|
|
2330
3816
|
} else if (entry.isDirectory()) {
|
|
2331
|
-
found.push(
|
|
3817
|
+
found.push(join13(dir, entry.name));
|
|
2332
3818
|
}
|
|
2333
3819
|
}
|
|
2334
3820
|
} catch {
|
|
@@ -2410,7 +3896,7 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
|
|
|
2410
3896
|
async function rootsFor(workspaceId) {
|
|
2411
3897
|
const ws = await getWorkspace(workspaceId);
|
|
2412
3898
|
if (!ws) throw new HttpError(404, "workspace not found");
|
|
2413
|
-
const roots = resolveResourceRoots({ agentDir:
|
|
3899
|
+
const roots = resolveResourceRoots({ agentDir: getAgentDir3(), workspaceCwd: ws.path });
|
|
2414
3900
|
return { roots, workspaceCwd: ws.path };
|
|
2415
3901
|
}
|
|
2416
3902
|
function respondError(c, err2) {
|
|
@@ -2657,6 +4143,7 @@ function mountResourcesRoute(app2) {
|
|
|
2657
4143
|
}
|
|
2658
4144
|
await setBuiltinEnabled(body.id, body.enabled);
|
|
2659
4145
|
await runtime.session.reload();
|
|
4146
|
+
reapplyToolPrefs(id, runtime.session);
|
|
2660
4147
|
const { roots, workspaceCwd } = await rootsFor(id);
|
|
2661
4148
|
return c.json(await snapshot(id, roots, workspaceCwd));
|
|
2662
4149
|
} catch (err2) {
|
|
@@ -2775,9 +4262,9 @@ function extractPreview(entry) {
|
|
|
2775
4262
|
case "message":
|
|
2776
4263
|
return extractMessagePreview(entry["message"]);
|
|
2777
4264
|
case "compaction":
|
|
2778
|
-
return
|
|
4265
|
+
return truncate2(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
|
|
2779
4266
|
case "branch_summary":
|
|
2780
|
-
return
|
|
4267
|
+
return truncate2(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
|
|
2781
4268
|
case "model_change":
|
|
2782
4269
|
return `${entry["provider"] ?? ""}/${entry["modelId"] ?? ""}`;
|
|
2783
4270
|
case "thinking_level_change":
|
|
@@ -2785,7 +4272,7 @@ function extractPreview(entry) {
|
|
|
2785
4272
|
case "session_info":
|
|
2786
4273
|
return entry["name"] ? `Name: ${entry["name"]}` : "Session info";
|
|
2787
4274
|
case "custom_message":
|
|
2788
|
-
return
|
|
4275
|
+
return truncate2(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
|
|
2789
4276
|
case "custom":
|
|
2790
4277
|
return `Custom: ${entry["customType"] ?? ""}`;
|
|
2791
4278
|
case "label":
|
|
@@ -2798,9 +4285,9 @@ function extractMessagePreview(msg) {
|
|
|
2798
4285
|
if (!msg || typeof msg !== "object") return "";
|
|
2799
4286
|
const m = msg;
|
|
2800
4287
|
if (m.role === "bashExecution") {
|
|
2801
|
-
return
|
|
4288
|
+
return truncate2(`$ ${m.command ?? ""}`, PREVIEW_MAX);
|
|
2802
4289
|
}
|
|
2803
|
-
return
|
|
4290
|
+
return truncate2(extractContentText2(m.content), PREVIEW_MAX);
|
|
2804
4291
|
}
|
|
2805
4292
|
function extractContentText2(content) {
|
|
2806
4293
|
if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
|
|
@@ -2817,7 +4304,7 @@ function extractContentText2(content) {
|
|
|
2817
4304
|
}
|
|
2818
4305
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
2819
4306
|
}
|
|
2820
|
-
function
|
|
4307
|
+
function truncate2(text, max) {
|
|
2821
4308
|
if (text.length <= max) return text;
|
|
2822
4309
|
return text.slice(0, max) + "\u2026";
|
|
2823
4310
|
}
|
|
@@ -2913,7 +4400,7 @@ workspacesRoute.get("/:id/export", async (c) => {
|
|
|
2913
4400
|
}
|
|
2914
4401
|
const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
|
|
2915
4402
|
const outputPath = await runtime.session.exportToHtml();
|
|
2916
|
-
const html = await
|
|
4403
|
+
const html = await readFile7(outputPath, "utf-8");
|
|
2917
4404
|
const filename = basename2(outputPath);
|
|
2918
4405
|
const body = { html, filename };
|
|
2919
4406
|
return c.json(body);
|
|
@@ -2946,7 +4433,7 @@ workspacesRoute.post("/", async (c) => {
|
|
|
2946
4433
|
if (!isAbsolute3(body.path)) {
|
|
2947
4434
|
return c.json({ ok: false, error: "path must be absolute" }, 400);
|
|
2948
4435
|
}
|
|
2949
|
-
const resolved =
|
|
4436
|
+
const resolved = resolve5(body.path);
|
|
2950
4437
|
try {
|
|
2951
4438
|
const st = await stat2(resolved);
|
|
2952
4439
|
if (!st.isDirectory()) {
|
|
@@ -2972,24 +4459,35 @@ workspacesRoute.delete("/:id", async (c) => {
|
|
|
2972
4459
|
const body = { ok: true };
|
|
2973
4460
|
return c.json(body);
|
|
2974
4461
|
});
|
|
4462
|
+
workspacesRoute.patch("/:id", async (c) => {
|
|
4463
|
+
const id = c.req.param("id");
|
|
4464
|
+
const body = await c.req.json();
|
|
4465
|
+
if (typeof body?.trustProjectAgents !== "boolean") {
|
|
4466
|
+
return c.json({ ok: false, error: "trustProjectAgents must be a boolean" }, 400);
|
|
4467
|
+
}
|
|
4468
|
+
const updated = await setWorkspaceTrustProjectAgents(id, body.trustProjectAgents);
|
|
4469
|
+
if (!updated) return c.json({ ok: false, error: "not found" }, 404);
|
|
4470
|
+
const res = { workspace: await enrichWorkspace(updated) };
|
|
4471
|
+
return c.json(res);
|
|
4472
|
+
});
|
|
2975
4473
|
mountConfigRoutes(workspacesRoute);
|
|
2976
4474
|
mountResourcesRoute(workspacesRoute);
|
|
2977
4475
|
mountFilesRoute(workspacesRoute);
|
|
2978
4476
|
workspacesRoute.route("/:id/tree", treeRoute);
|
|
2979
4477
|
|
|
2980
4478
|
// src/api/fs.ts
|
|
2981
|
-
import { readdir as
|
|
2982
|
-
import { homedir as
|
|
2983
|
-
import { dirname as
|
|
4479
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
4480
|
+
import { homedir as homedir3 } from "os";
|
|
4481
|
+
import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "path";
|
|
2984
4482
|
import { Hono as Hono3 } from "hono";
|
|
2985
4483
|
var fsRoute = new Hono3();
|
|
2986
4484
|
fsRoute.get("/browse", async (c) => {
|
|
2987
4485
|
const rawPath = c.req.query("path");
|
|
2988
4486
|
const showHidden = c.req.query("showHidden") === "1";
|
|
2989
|
-
const target = rawPath && isAbsolute4(rawPath) ?
|
|
4487
|
+
const target = rawPath && isAbsolute4(rawPath) ? resolve6(rawPath) : homedir3();
|
|
2990
4488
|
let dirents;
|
|
2991
4489
|
try {
|
|
2992
|
-
dirents = await
|
|
4490
|
+
dirents = await readdir4(target, { withFileTypes: true });
|
|
2993
4491
|
} catch (err2) {
|
|
2994
4492
|
const code = err2.code;
|
|
2995
4493
|
const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
|
|
@@ -2997,11 +4495,11 @@ fsRoute.get("/browse", async (c) => {
|
|
|
2997
4495
|
}
|
|
2998
4496
|
const entries = dirents.filter((d) => d.isDirectory()).filter((d) => showHidden || !d.name.startsWith(".")).map((d) => ({
|
|
2999
4497
|
name: d.name,
|
|
3000
|
-
path:
|
|
4498
|
+
path: join14(target, d.name),
|
|
3001
4499
|
type: "dir"
|
|
3002
4500
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
3003
4501
|
const parent = (() => {
|
|
3004
|
-
const p =
|
|
4502
|
+
const p = dirname5(target);
|
|
3005
4503
|
return p === target ? null : p;
|
|
3006
4504
|
})();
|
|
3007
4505
|
const body = { path: target, parent, entries };
|
|
@@ -3009,12 +4507,48 @@ fsRoute.get("/browse", async (c) => {
|
|
|
3009
4507
|
});
|
|
3010
4508
|
|
|
3011
4509
|
// src/api/model-configs.ts
|
|
3012
|
-
import { readFile as
|
|
3013
|
-
import {
|
|
4510
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
4511
|
+
import { join as join15 } from "path";
|
|
3014
4512
|
import { Hono as Hono4 } from "hono";
|
|
3015
4513
|
import {
|
|
3016
|
-
getAgentDir as
|
|
4514
|
+
getAgentDir as getAgentDir4
|
|
3017
4515
|
} from "@earendil-works/pi-coding-agent";
|
|
4516
|
+
|
|
4517
|
+
// src/api/model-config-keys.ts
|
|
4518
|
+
var MASKED_KEY_RE = /^….{1,8}$/;
|
|
4519
|
+
function maskApiKey(key) {
|
|
4520
|
+
return `\u2026${key.slice(-4)}`;
|
|
4521
|
+
}
|
|
4522
|
+
function isPreservedApiKey(key) {
|
|
4523
|
+
return key === "" || MASKED_KEY_RE.test(key);
|
|
4524
|
+
}
|
|
4525
|
+
function maskConfigForResponse(config2) {
|
|
4526
|
+
const providers = {};
|
|
4527
|
+
for (const [name, provider] of Object.entries(config2.providers)) {
|
|
4528
|
+
providers[name] = {
|
|
4529
|
+
...provider,
|
|
4530
|
+
apiKey: provider.apiKey ? maskApiKey(provider.apiKey) : ""
|
|
4531
|
+
};
|
|
4532
|
+
}
|
|
4533
|
+
return { providers };
|
|
4534
|
+
}
|
|
4535
|
+
function preserveApiKeys(incoming, existing) {
|
|
4536
|
+
const providers = { ...incoming.providers };
|
|
4537
|
+
for (const [name, provider] of Object.entries(providers)) {
|
|
4538
|
+
if (!isPreservedApiKey(provider.apiKey)) continue;
|
|
4539
|
+
const prev = existing.providers[name]?.apiKey;
|
|
4540
|
+
if (prev) {
|
|
4541
|
+
providers[name] = { ...provider, apiKey: prev };
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
return { providers };
|
|
4545
|
+
}
|
|
4546
|
+
function resolveUpsertApiKey(incomingKey, existingKey) {
|
|
4547
|
+
if (!isPreservedApiKey(incomingKey)) return incomingKey;
|
|
4548
|
+
return existingKey || void 0;
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
// src/api/model-configs.ts
|
|
3018
4552
|
var modelConfigsRoute = new Hono4();
|
|
3019
4553
|
var writeLock = Promise.resolve();
|
|
3020
4554
|
function withWriteLock(fn) {
|
|
@@ -3025,11 +4559,11 @@ function withWriteLock(fn) {
|
|
|
3025
4559
|
return next;
|
|
3026
4560
|
}
|
|
3027
4561
|
function modelsPath() {
|
|
3028
|
-
return
|
|
4562
|
+
return join15(getAgentDir4(), "models.json");
|
|
3029
4563
|
}
|
|
3030
4564
|
async function readModelsJson() {
|
|
3031
4565
|
try {
|
|
3032
|
-
const raw = await
|
|
4566
|
+
const raw = await readFile8(modelsPath(), "utf-8");
|
|
3033
4567
|
return JSON.parse(raw);
|
|
3034
4568
|
} catch (err2) {
|
|
3035
4569
|
if (err2?.code === "ENOENT") {
|
|
@@ -3039,14 +4573,12 @@ async function readModelsJson() {
|
|
|
3039
4573
|
}
|
|
3040
4574
|
}
|
|
3041
4575
|
async function writeModelsJson(config2) {
|
|
3042
|
-
|
|
3043
|
-
await mkdir4(dirname5(p), { recursive: true });
|
|
3044
|
-
await writeFile4(p, JSON.stringify(config2, null, 2), "utf-8");
|
|
4576
|
+
await writeJsonAtomic(modelsPath(), config2, { mode: 384 });
|
|
3045
4577
|
}
|
|
3046
4578
|
var ValidationError = class extends Error {
|
|
3047
|
-
constructor(message,
|
|
4579
|
+
constructor(message, status2) {
|
|
3048
4580
|
super(message);
|
|
3049
|
-
this.status =
|
|
4581
|
+
this.status = status2;
|
|
3050
4582
|
}
|
|
3051
4583
|
status;
|
|
3052
4584
|
};
|
|
@@ -3064,7 +4596,7 @@ function refreshRegistry(workspaceId) {
|
|
|
3064
4596
|
modelConfigsRoute.get("/", async (c) => {
|
|
3065
4597
|
try {
|
|
3066
4598
|
const config2 = await readModelsJson();
|
|
3067
|
-
const body = { config: config2 };
|
|
4599
|
+
const body = { config: maskConfigForResponse(config2) };
|
|
3068
4600
|
return c.json(body);
|
|
3069
4601
|
} catch (err2) {
|
|
3070
4602
|
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -3077,12 +4609,15 @@ modelConfigsRoute.put("/", async (c) => {
|
|
|
3077
4609
|
return c.json({ ok: false, error: "config.providers is required" }, 400);
|
|
3078
4610
|
}
|
|
3079
4611
|
try {
|
|
3080
|
-
await withWriteLock(async () => {
|
|
3081
|
-
await
|
|
4612
|
+
const config2 = await withWriteLock(async () => {
|
|
4613
|
+
const existing = await readModelsJson();
|
|
4614
|
+
const merged = preserveApiKeys(body.config, existing);
|
|
4615
|
+
await writeModelsJson(merged);
|
|
4616
|
+
return merged;
|
|
3082
4617
|
});
|
|
3083
4618
|
const workspaceId = c.req.query("workspaceId");
|
|
3084
4619
|
refreshRegistry(workspaceId ?? void 0);
|
|
3085
|
-
const resp = { config:
|
|
4620
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3086
4621
|
return c.json(resp);
|
|
3087
4622
|
} catch (err2) {
|
|
3088
4623
|
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -3094,8 +4629,8 @@ modelConfigsRoute.post("/providers", async (c) => {
|
|
|
3094
4629
|
if (!body?.name || !body?.provider) {
|
|
3095
4630
|
return c.json({ ok: false, error: "name and provider are required" }, 400);
|
|
3096
4631
|
}
|
|
3097
|
-
if (!body.provider.baseUrl || !body.provider.api
|
|
3098
|
-
return c.json({ ok: false, error: "provider must have baseUrl
|
|
4632
|
+
if (!body.provider.baseUrl || !body.provider.api) {
|
|
4633
|
+
return c.json({ ok: false, error: "provider must have baseUrl and api" }, 400);
|
|
3099
4634
|
}
|
|
3100
4635
|
if (!Array.isArray(body.provider.models)) {
|
|
3101
4636
|
return c.json({ ok: false, error: "provider.models must be an array" }, 400);
|
|
@@ -3103,15 +4638,22 @@ modelConfigsRoute.post("/providers", async (c) => {
|
|
|
3103
4638
|
try {
|
|
3104
4639
|
const config2 = await withWriteLock(async () => {
|
|
3105
4640
|
const cfg = await readModelsJson();
|
|
3106
|
-
cfg.providers[body.name]
|
|
4641
|
+
const apiKey2 = resolveUpsertApiKey(body.provider.apiKey, cfg.providers[body.name]?.apiKey);
|
|
4642
|
+
if (!apiKey2) {
|
|
4643
|
+
throw new ValidationError("apiKey is required for a new provider", 400);
|
|
4644
|
+
}
|
|
4645
|
+
cfg.providers[body.name] = { ...body.provider, apiKey: apiKey2 };
|
|
3107
4646
|
await writeModelsJson(cfg);
|
|
3108
4647
|
return cfg;
|
|
3109
4648
|
});
|
|
3110
4649
|
const workspaceId = c.req.query("workspaceId");
|
|
3111
4650
|
refreshRegistry(workspaceId ?? void 0);
|
|
3112
|
-
const resp = { config: config2 };
|
|
4651
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3113
4652
|
return c.json(resp);
|
|
3114
4653
|
} catch (err2) {
|
|
4654
|
+
if (err2 instanceof ValidationError) {
|
|
4655
|
+
return c.json({ ok: false, error: err2.message }, err2.status);
|
|
4656
|
+
}
|
|
3115
4657
|
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
3116
4658
|
return c.json({ ok: false, error: message }, 500);
|
|
3117
4659
|
}
|
|
@@ -3133,7 +4675,7 @@ modelConfigsRoute.delete("/providers", async (c) => {
|
|
|
3133
4675
|
});
|
|
3134
4676
|
const workspaceId = c.req.query("workspaceId");
|
|
3135
4677
|
refreshRegistry(workspaceId ?? void 0);
|
|
3136
|
-
const resp = { config: config2 };
|
|
4678
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3137
4679
|
return c.json(resp);
|
|
3138
4680
|
} catch (err2) {
|
|
3139
4681
|
if (err2 instanceof ValidationError) {
|
|
@@ -3165,7 +4707,7 @@ modelConfigsRoute.post("/providers/:provider/models", async (c) => {
|
|
|
3165
4707
|
});
|
|
3166
4708
|
const workspaceId = c.req.query("workspaceId");
|
|
3167
4709
|
refreshRegistry(workspaceId ?? void 0);
|
|
3168
|
-
const resp = { config: config2 };
|
|
4710
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3169
4711
|
return c.json(resp);
|
|
3170
4712
|
} catch (err2) {
|
|
3171
4713
|
if (err2 instanceof ValidationError) {
|
|
@@ -3201,7 +4743,7 @@ modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
|
|
|
3201
4743
|
});
|
|
3202
4744
|
const workspaceId = c.req.query("workspaceId");
|
|
3203
4745
|
refreshRegistry(workspaceId ?? void 0);
|
|
3204
|
-
const resp = { config: config2 };
|
|
4746
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3205
4747
|
return c.json(resp);
|
|
3206
4748
|
} catch (err2) {
|
|
3207
4749
|
if (err2 instanceof ValidationError) {
|
|
@@ -3230,7 +4772,7 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
|
|
|
3230
4772
|
});
|
|
3231
4773
|
const workspaceId = c.req.query("workspaceId");
|
|
3232
4774
|
refreshRegistry(workspaceId ?? void 0);
|
|
3233
|
-
const resp = { config: config2 };
|
|
4775
|
+
const resp = { config: maskConfigForResponse(config2) };
|
|
3234
4776
|
return c.json(resp);
|
|
3235
4777
|
} catch (err2) {
|
|
3236
4778
|
if (err2 instanceof ValidationError) {
|
|
@@ -3241,8 +4783,71 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
|
|
|
3241
4783
|
}
|
|
3242
4784
|
});
|
|
3243
4785
|
|
|
4786
|
+
// src/api/web-search.ts
|
|
4787
|
+
import { Hono as Hono5 } from "hono";
|
|
4788
|
+
var webSearchRoute = new Hono5();
|
|
4789
|
+
function status() {
|
|
4790
|
+
return getKeyStatus();
|
|
4791
|
+
}
|
|
4792
|
+
webSearchRoute.get("/", (c) => c.json(status()));
|
|
4793
|
+
webSearchRoute.put("/", async (c) => {
|
|
4794
|
+
const body = await c.req.json().catch(() => null);
|
|
4795
|
+
if (!body || typeof body.apiKey !== "string") {
|
|
4796
|
+
return c.json({ ok: false, error: "apiKey (string) is required" }, 400);
|
|
4797
|
+
}
|
|
4798
|
+
try {
|
|
4799
|
+
await setTavilyApiKey(body.apiKey);
|
|
4800
|
+
return c.json(status());
|
|
4801
|
+
} catch (err2) {
|
|
4802
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4803
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4804
|
+
}
|
|
4805
|
+
});
|
|
4806
|
+
webSearchRoute.delete("/", async (c) => {
|
|
4807
|
+
try {
|
|
4808
|
+
await clearTavilyApiKey();
|
|
4809
|
+
return c.json(status());
|
|
4810
|
+
} catch (err2) {
|
|
4811
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4812
|
+
return c.json({ ok: false, error: message }, 500);
|
|
4813
|
+
}
|
|
4814
|
+
});
|
|
4815
|
+
|
|
3244
4816
|
// src/ws/hub.ts
|
|
3245
4817
|
import { WebSocketServer } from "ws";
|
|
4818
|
+
|
|
4819
|
+
// src/security.ts
|
|
4820
|
+
var LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
4821
|
+
function buildAllowedHosts() {
|
|
4822
|
+
const ports = /* @__PURE__ */ new Set([config.port, 5173]);
|
|
4823
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
4824
|
+
for (const name of LOOPBACK_HOSTNAMES) {
|
|
4825
|
+
for (const port of ports) {
|
|
4826
|
+
hosts.add(`${name}:${port}`);
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
return hosts;
|
|
4830
|
+
}
|
|
4831
|
+
function buildAllowedWsOrigins() {
|
|
4832
|
+
const origins = /* @__PURE__ */ new Set([config.corsOrigin]);
|
|
4833
|
+
for (const name of LOOPBACK_HOSTNAMES) {
|
|
4834
|
+
origins.add(`http://${name}:${config.port}`);
|
|
4835
|
+
origins.add(`http://${name}:5173`);
|
|
4836
|
+
}
|
|
4837
|
+
return origins;
|
|
4838
|
+
}
|
|
4839
|
+
var allowedHosts = buildAllowedHosts();
|
|
4840
|
+
var allowedWsOrigins = buildAllowedWsOrigins();
|
|
4841
|
+
function isAllowedHost(host) {
|
|
4842
|
+
if (!host) return false;
|
|
4843
|
+
return allowedHosts.has(host.toLowerCase());
|
|
4844
|
+
}
|
|
4845
|
+
function isAllowedWsOrigin(origin) {
|
|
4846
|
+
if (!origin) return false;
|
|
4847
|
+
return allowedWsOrigins.has(origin);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// src/ws/hub.ts
|
|
3246
4851
|
var BACKGROUND_CAP = 4;
|
|
3247
4852
|
var replacementLocks = /* @__PURE__ */ new Map();
|
|
3248
4853
|
function withReplacementLock(workspaceId, fn) {
|
|
@@ -3258,23 +4863,40 @@ function withReplacementLock(workspaceId, fn) {
|
|
|
3258
4863
|
return next;
|
|
3259
4864
|
}
|
|
3260
4865
|
function attachWsHub(httpServer) {
|
|
3261
|
-
const wss = new WebSocketServer({
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
send(ws, { type: "error", message: "invalid JSON" });
|
|
4866
|
+
const wss = new WebSocketServer({
|
|
4867
|
+
server: httpServer,
|
|
4868
|
+
path: "/ws",
|
|
4869
|
+
verifyClient: (info, cb) => {
|
|
4870
|
+
const host = info.req.headers.host;
|
|
4871
|
+
const origin = info.origin;
|
|
4872
|
+
if (!isAllowedHost(host) || !isAllowedWsOrigin(origin)) {
|
|
4873
|
+
cb(false, 403, "Forbidden");
|
|
3270
4874
|
return;
|
|
3271
4875
|
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
4876
|
+
cb(true);
|
|
4877
|
+
}
|
|
4878
|
+
});
|
|
4879
|
+
wss.on("connection", (ws) => {
|
|
4880
|
+
const state = { background: /* @__PURE__ */ new Map() };
|
|
4881
|
+
let inbound = Promise.resolve();
|
|
4882
|
+
ws.on("message", (raw) => {
|
|
4883
|
+
inbound = inbound.then(async () => {
|
|
4884
|
+
let msg;
|
|
4885
|
+
try {
|
|
4886
|
+
msg = JSON.parse(raw.toString());
|
|
4887
|
+
} catch {
|
|
4888
|
+
send(ws, { type: "error", message: "invalid JSON" });
|
|
4889
|
+
return;
|
|
4890
|
+
}
|
|
4891
|
+
try {
|
|
4892
|
+
await handle(ws, state, msg);
|
|
4893
|
+
} catch (err2) {
|
|
4894
|
+
const message = err2 instanceof Error ? err2.message : String(err2);
|
|
4895
|
+
send(ws, { type: "error", message, command: msg.type });
|
|
4896
|
+
}
|
|
4897
|
+
}).catch((err2) => {
|
|
4898
|
+
console.error("[ws] inbound chain error:", err2);
|
|
4899
|
+
});
|
|
3278
4900
|
});
|
|
3279
4901
|
ws.on("close", () => {
|
|
3280
4902
|
detach(state, ws);
|
|
@@ -3599,10 +5221,10 @@ function send(ws, msg) {
|
|
|
3599
5221
|
|
|
3600
5222
|
// src/index.ts
|
|
3601
5223
|
configureHttpProxy();
|
|
3602
|
-
var app = new
|
|
3603
|
-
var distDir = dirname6(
|
|
3604
|
-
var webRoot =
|
|
3605
|
-
var webIndexPath =
|
|
5224
|
+
var app = new Hono6();
|
|
5225
|
+
var distDir = dirname6(fileURLToPath2(import.meta.url));
|
|
5226
|
+
var webRoot = resolve7(process.env.PI_PILOT_WEB_ROOT ?? join16(distDir, "..", "public"));
|
|
5227
|
+
var webIndexPath = join16(webRoot, "index.html");
|
|
3606
5228
|
var mimeTypes = {
|
|
3607
5229
|
".css": "text/css; charset=utf-8",
|
|
3608
5230
|
".html": "text/html; charset=utf-8",
|
|
@@ -3628,7 +5250,7 @@ function safeResolveWebPath(pathname) {
|
|
|
3628
5250
|
return void 0;
|
|
3629
5251
|
}
|
|
3630
5252
|
const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
3631
|
-
const candidate =
|
|
5253
|
+
const candidate = resolve7(webRoot, relativePath);
|
|
3632
5254
|
if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
|
|
3633
5255
|
return void 0;
|
|
3634
5256
|
}
|
|
@@ -3636,7 +5258,7 @@ function safeResolveWebPath(pathname) {
|
|
|
3636
5258
|
}
|
|
3637
5259
|
async function readWebFile(path) {
|
|
3638
5260
|
try {
|
|
3639
|
-
return await
|
|
5261
|
+
return await readFile9(path);
|
|
3640
5262
|
} catch (err2) {
|
|
3641
5263
|
const code = err2.code;
|
|
3642
5264
|
if (code === "ENOENT" || code === "EISDIR") return void 0;
|
|
@@ -3649,7 +5271,7 @@ async function serveWeb(c) {
|
|
|
3649
5271
|
const assetPath = safeResolveWebPath(pathname);
|
|
3650
5272
|
if (!assetPath) return c.text("invalid asset path", 400);
|
|
3651
5273
|
const asset = await readWebFile(assetPath);
|
|
3652
|
-
const body = asset ?? await
|
|
5274
|
+
const body = asset ?? await readFile9(webIndexPath);
|
|
3653
5275
|
const filePath = asset ? assetPath : webIndexPath;
|
|
3654
5276
|
const headers = {
|
|
3655
5277
|
"Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",
|
|
@@ -3657,6 +5279,12 @@ async function serveWeb(c) {
|
|
|
3657
5279
|
};
|
|
3658
5280
|
return new Response(body, { headers });
|
|
3659
5281
|
}
|
|
5282
|
+
app.use("*", async (c, next) => {
|
|
5283
|
+
if (!isAllowedHost(c.req.header("host"))) {
|
|
5284
|
+
return c.text("Forbidden", 403);
|
|
5285
|
+
}
|
|
5286
|
+
await next();
|
|
5287
|
+
});
|
|
3660
5288
|
app.use(
|
|
3661
5289
|
"/api/*",
|
|
3662
5290
|
cors({
|
|
@@ -3668,7 +5296,8 @@ app.get("/api/health", (c) => c.json({ ok: true }));
|
|
|
3668
5296
|
app.route("/api/workspaces", workspacesRoute);
|
|
3669
5297
|
app.route("/api/fs", fsRoute);
|
|
3670
5298
|
app.route("/api/model-configs", modelConfigsRoute);
|
|
3671
|
-
|
|
5299
|
+
app.route("/api/web-search", webSearchRoute);
|
|
5300
|
+
if (existsSync2(webIndexPath)) {
|
|
3672
5301
|
app.get("*", serveWeb);
|
|
3673
5302
|
} else {
|
|
3674
5303
|
app.get(
|
|
@@ -3680,6 +5309,9 @@ if (existsSync(webIndexPath)) {
|
|
|
3680
5309
|
);
|
|
3681
5310
|
}
|
|
3682
5311
|
await loadBuiltinPrefs();
|
|
5312
|
+
await loadSessionToolPrefs();
|
|
5313
|
+
await loadWebSearchPrefs();
|
|
5314
|
+
await sweepOrphanedChildrenOnBoot();
|
|
3683
5315
|
var server = serve(
|
|
3684
5316
|
{
|
|
3685
5317
|
fetch: app.fetch,
|
|
@@ -3698,9 +5330,20 @@ async function shutdown(reason) {
|
|
|
3698
5330
|
} catch (e) {
|
|
3699
5331
|
console.error("[pi-pilot] disposeAll error:", e);
|
|
3700
5332
|
}
|
|
5333
|
+
const sweptChildren = killAllChildren();
|
|
5334
|
+
if (sweptChildren > 0) {
|
|
5335
|
+
console.warn(`[pi-pilot] killed ${sweptChildren} lingering subagent child(ren)`);
|
|
5336
|
+
}
|
|
3701
5337
|
server.close(() => process.exit(0));
|
|
3702
5338
|
setTimeout(() => process.exit(1), 3e3).unref();
|
|
3703
5339
|
}
|
|
5340
|
+
process.on("unhandledRejection", (reason) => {
|
|
5341
|
+
console.error("[pi-pilot] unhandled rejection (process kept alive):", reason);
|
|
5342
|
+
});
|
|
5343
|
+
process.on("uncaughtException", (err2) => {
|
|
5344
|
+
console.error("[pi-pilot] uncaught exception:", err2);
|
|
5345
|
+
void shutdown("uncaughtException");
|
|
5346
|
+
});
|
|
3704
5347
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3705
5348
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
3706
5349
|
//# sourceMappingURL=index.js.map
|