@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 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 readFile6 } from "fs/promises";
6
- import { dirname as dirname6, extname, join as join9, resolve as resolve5, sep as sep3 } from "path";
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 Hono5 } from "hono";
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 readFile4, stat as stat2 } from "fs/promises";
45
- import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
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).replace(/\.md$/, "");
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(status, message) {
337
+ constructor(status2, message) {
342
338
  super(message);
343
- this.status = status;
339
+ this.status = status2;
344
340
  }
345
341
  status;
346
342
  };
347
343
 
348
344
  // src/storage/workspace-registry.ts
349
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
350
- import { dirname as dirname2, join as join3 } from "path";
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 mkdir2(dirname2(REGISTRY_PATH), { recursive: true });
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: randomUUID(),
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 cached = cache2.get(path);
462
- if (cached && cached.expiresAt > now) return cached;
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 resolve2 } from "path";
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((resolve6, reject) => {
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
- resolve6(a);
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/artifact/schema.ts
967
- import { Type as Type3 } from "typebox";
968
- var TypeEnum = Type3.Union(
969
- [
970
- Type3.Literal("html"),
971
- Type3.Literal("svg"),
972
- Type3.Literal("markdown"),
973
- Type3.Literal("code")
974
- ],
975
- {
976
- 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).'
977
- }
978
- );
979
- var createArtifactParamsSchema = Type3.Object({
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: TOOL_NAME2,
1004
- label: "Create artifact",
1005
- 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).',
1006
- 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.",
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
- "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.",
1009
- "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.",
1010
- '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`.',
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
- parameters: createArtifactParamsSchema,
1014
- execute: async (toolCallId, params) => {
1015
- const id = params.id?.trim() || toolCallId;
1016
- const details = {
1017
- id,
1018
- type: params.type,
1019
- title: params.title
1020
- };
1021
- 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.`;
1022
- return {
1023
- content: [{ type: "text", text }],
1024
- details
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 { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1032
- import { dirname as dirname3, join as join4 } from "path";
1033
- var PREFS_PATH = join4(config.dataDir, "builtin-extensions.json");
1034
- var cache3 = { disabled: [] };
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 readFile3(PREFS_PATH, "utf8");
2368
+ const raw = await readFile5(PREFS_PATH3, "utf8");
1038
2369
  const parsed = JSON.parse(raw);
1039
- cache3 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
2370
+ cache5 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
1040
2371
  } catch (err2) {
1041
- cache3 = { disabled: [] };
2372
+ cache5 = { disabled: [] };
1042
2373
  if (err2.code !== "ENOENT") {
1043
- console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
2374
+ console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH3}:`, err2);
1044
2375
  }
1045
2376
  }
1046
2377
  }
1047
2378
  function isBuiltinDisabled(id) {
1048
- if (cache3.disabled.includes(id)) return true;
1049
- if (id === "todo" && cache3.disabled.includes("plan")) return true;
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 [...cache3.disabled];
2384
+ return [...cache5.disabled];
1054
2385
  }
1055
2386
  async function setBuiltinEnabled(id, enabled) {
1056
- const next = new Set(cache3.disabled);
2387
+ const next = new Set(cache5.disabled);
1057
2388
  if (enabled) next.delete(id);
1058
2389
  else next.add(id);
1059
- cache3 = { disabled: [...next] };
1060
- await save2();
2390
+ cache5 = { disabled: [...next] };
2391
+ await save4();
1061
2392
  }
1062
- async function save2() {
1063
- await mkdir3(dirname3(PREFS_PATH), { recursive: true });
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(["ask_user", "todo"]);
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 ? resolve2(file) : runtime.session.sessionId);
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: getAgentDir(),
2994
+ agentDir: getAgentDir2(),
1530
2995
  sessionManager: makeSessionManager()
1531
2996
  });
1532
2997
  const bridge = new ExtensionUIBridge();
1533
2998
  await this.bindExtensions(workspaceId, runtime, bridge);
1534
- safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
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 ? resolve2(runtime.session.sessionFile) : null,
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 = resolve2(sessionPath);
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(resolve2(sourceSessionPath), void 0, ws.path)
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
- safeReconcileAskUser(workspaceId, state.runtime.session.sessionManager);
1667
- state.sessionPath = state.runtime.session.sessionFile ? resolve2(state.runtime.session.sessionFile) : null;
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, resolve2(sessionPath)))?.runtime;
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 = resolve2(sessionPath);
1728
- const found = sessions.some((s) => resolve2(s.path) === resolved);
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(resolve2(info.path))));
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 = resolve2(sessionPath);
1812
- const target = sessions.find((session) => resolve2(session.path) === resolved);
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 safeReconcileAskUser(workspaceId, sm) {
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, running) {
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
- ...running ? { running: true } : {}
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
- name: t.name,
1983
- description: t.description
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.setActiveToolsByName(body.tools);
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 execFile2 } from "child_process";
2148
- import { readdir } from "fs/promises";
2149
- import { join as join5, relative, sep as sep2 } from "path";
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(execFile2);
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 cached = listCache.get(workspacePath);
2180
- if (cached && cached.expiresAt > now) return cached.files;
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 readdir(dir, { withFileTypes: true });
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 = join5(dir, d.name);
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: join5(workspacePath, relPath),
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: join5(workspacePath, e.relPath),
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 readdir2 } from "fs/promises";
2312
- import { join as join6 } from "path";
2313
- import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
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 = [join6(getAgentDir2(), "extensions"), join6(workspaceCwd, ".pi", "extensions")];
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 readdir2(dir, { withFileTypes: true });
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(join6(dir, entry.name));
3815
+ found.push(join13(dir, entry.name));
2330
3816
  } else if (entry.isDirectory()) {
2331
- found.push(join6(dir, entry.name));
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: getAgentDir2(), workspaceCwd: ws.path });
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 truncate(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
4265
+ return truncate2(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
2779
4266
  case "branch_summary":
2780
- return truncate(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
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 truncate(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
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 truncate(`$ ${m.command ?? ""}`, PREVIEW_MAX);
4288
+ return truncate2(`$ ${m.command ?? ""}`, PREVIEW_MAX);
2802
4289
  }
2803
- return truncate(extractContentText2(m.content), PREVIEW_MAX);
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 truncate(text, max) {
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 readFile4(outputPath, "utf-8");
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 = resolve3(body.path);
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 readdir3 } from "fs/promises";
2982
- import { homedir as homedir2 } from "os";
2983
- import { dirname as dirname4, isAbsolute as isAbsolute4, join as join7, resolve as resolve4 } from "path";
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) ? resolve4(rawPath) : homedir2();
4487
+ const target = rawPath && isAbsolute4(rawPath) ? resolve6(rawPath) : homedir3();
2990
4488
  let dirents;
2991
4489
  try {
2992
- dirents = await readdir3(target, { withFileTypes: true });
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: join7(target, d.name),
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 = dirname4(target);
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 readFile5, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
3013
- import { dirname as dirname5, join as join8 } from "path";
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 getAgentDir3
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 join8(getAgentDir3(), "models.json");
4562
+ return join15(getAgentDir4(), "models.json");
3029
4563
  }
3030
4564
  async function readModelsJson() {
3031
4565
  try {
3032
- const raw = await readFile5(modelsPath(), "utf-8");
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
- const p = modelsPath();
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, status) {
4579
+ constructor(message, status2) {
3048
4580
  super(message);
3049
- this.status = 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 writeModelsJson(body.config);
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: body.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 || !body.provider.apiKey) {
3098
- return c.json({ ok: false, error: "provider must have baseUrl, api, and apiKey" }, 400);
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] = body.provider;
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({ server: httpServer, path: "/ws" });
3262
- wss.on("connection", (ws) => {
3263
- const state = { background: /* @__PURE__ */ new Map() };
3264
- ws.on("message", async (raw) => {
3265
- let msg;
3266
- try {
3267
- msg = JSON.parse(raw.toString());
3268
- } catch {
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
- try {
3273
- await handle(ws, state, msg);
3274
- } catch (err2) {
3275
- const message = err2 instanceof Error ? err2.message : String(err2);
3276
- send(ws, { type: "error", message, command: msg.type });
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 Hono5();
3603
- var distDir = dirname6(fileURLToPath(import.meta.url));
3604
- var webRoot = resolve5(process.env.PI_PILOT_WEB_ROOT ?? join9(distDir, "..", "public"));
3605
- var webIndexPath = join9(webRoot, "index.html");
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 = resolve5(webRoot, relativePath);
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 readFile6(path);
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 readFile6(webIndexPath);
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
- if (existsSync(webIndexPath)) {
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