@jyork0828/pi-pilot 0.0.6 → 0.1.0

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 readFile10 } from "fs/promises";
6
+ import { dirname as dirname6, extname, join as join17, resolve as resolve8, 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,20 +41,31 @@ 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 readFile8, stat as stat3 } from "fs/promises";
45
+ import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve6 } from "path";
46
46
  import { Hono as Hono2 } from "hono";
47
47
 
48
48
  // src/storage/resource-writer.ts
49
+ import { execFile } from "child_process";
50
+ import { createWriteStream } from "fs";
49
51
  import {
52
+ cp,
50
53
  mkdir,
54
+ mkdtemp,
55
+ readdir,
51
56
  readFile,
52
57
  rm,
53
58
  stat,
54
59
  unlink,
55
60
  writeFile
56
61
  } from "fs/promises";
57
- import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
62
+ import { tmpdir } from "os";
63
+ import { basename, dirname, isAbsolute, join as join2, resolve, sep } from "path";
64
+ import { Readable, Transform } from "stream";
65
+ import { pipeline } from "stream/promises";
66
+ import { promisify } from "util";
67
+ import { createGunzip } from "zlib";
68
+ var exec = promisify(execFile);
58
69
  var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
59
70
  var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
60
71
  function ensureSkillName(name) {
@@ -231,6 +242,156 @@ async function deleteSkill(filePath, roots) {
231
242
  assertUnder(dir, [roots.userSkills, roots.projectSkills]);
232
243
  await rm(dir, { recursive: true, force: true });
233
244
  }
245
+ var MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
246
+ var MAX_ENTRIES = 5e3;
247
+ async function installSkillFromZip(opts) {
248
+ const base = opts.scope === "user" ? opts.roots.userSkills : opts.roots.projectSkills;
249
+ const maxBytes = opts.limits?.maxUncompressedBytes ?? MAX_UNCOMPRESSED_BYTES;
250
+ const maxEntries = opts.limits?.maxEntries ?? MAX_ENTRIES;
251
+ const work = await mkdtemp(join2(tmpdir(), "pi-pilot-skill-"));
252
+ try {
253
+ const extractDir = join2(work, "x");
254
+ await mkdir(extractDir, { recursive: true });
255
+ await extractArchive(opts.zip, work, extractDir, { maxBytes, maxEntries });
256
+ const srcDir = await locateSkillDir(extractDir);
257
+ await assertNoSymlinks(srcDir);
258
+ const name = await resolveInstalledSkillName(srcDir, extractDir);
259
+ const dest = join2(base, name);
260
+ assertUnder(dest, [opts.roots.userSkills, opts.roots.projectSkills]);
261
+ if (await exists(dest)) {
262
+ if (!opts.overwrite) {
263
+ throw new HttpError(409, `a skill named "${name}" already exists \u2014 delete it first or overwrite`);
264
+ }
265
+ await rm(dest, { recursive: true, force: true });
266
+ }
267
+ await mkdir(base, { recursive: true });
268
+ await cp(srcDir, dest, {
269
+ recursive: true,
270
+ // Strip macOS archive cruft so the installed dir stays clean. (The
271
+ // tree is already symlink-free — see assertNoSymlinks above.)
272
+ filter: (src) => {
273
+ const b = basename(src);
274
+ return b !== "__MACOSX" && b !== ".DS_Store";
275
+ }
276
+ });
277
+ return { name, filePath: join2(dest, "SKILL.md") };
278
+ } finally {
279
+ await rm(work, { recursive: true, force: true });
280
+ }
281
+ }
282
+ async function extractArchive(zip, work, extractDir, caps) {
283
+ const isZip = zip.length >= 4 && zip[0] === 80 && zip[1] === 75;
284
+ const isGzip = zip.length >= 3 && zip[0] === 31 && zip[1] === 139 && zip[2] === 8;
285
+ if (!isZip && !isGzip) {
286
+ throw new HttpError(400, "uploaded file is not a .zip or .tar.gz archive");
287
+ }
288
+ try {
289
+ if (isZip) {
290
+ const zipPath = join2(work, "skill.zip");
291
+ await writeFile(zipPath, zip);
292
+ const { stdout } = await exec("unzip", ["-Z", "-t", zipPath]);
293
+ const m = stdout.match(/(\d+)\s+files?,\s+(\d+)\s+bytes uncompressed/);
294
+ if (!m) throw new HttpError(400, "could not read the archive's contents");
295
+ assertEntryCount(Number(m[1]), caps.maxEntries);
296
+ assertUncompressedSize(Number(m[2]), caps.maxBytes);
297
+ await exec("unzip", ["-o", "-q", zipPath, "-d", extractDir]);
298
+ } else {
299
+ const tarPath = join2(work, "skill.tar");
300
+ await gunzipToFileCapped(zip, tarPath, caps.maxBytes);
301
+ const { stdout } = await exec("tar", ["-tf", tarPath], { maxBuffer: 16 * 1024 * 1024 });
302
+ assertEntryCount(stdout.split("\n").filter((l) => l.length > 0).length, caps.maxEntries);
303
+ await exec("tar", ["-xf", tarPath, "-C", extractDir]);
304
+ }
305
+ } catch (err2) {
306
+ if (err2 instanceof HttpError) throw err2;
307
+ const e = err2;
308
+ if (e.code === "ENOENT") {
309
+ throw new HttpError(
310
+ 500,
311
+ `\`${e.path ?? "unzip/tar"}\` is not available on the server host \u2014 install it or place the skill folder under your skills directory manually`
312
+ );
313
+ }
314
+ throw new HttpError(400, `could not extract archive: ${e.stderr?.trim() || e.message}`);
315
+ }
316
+ }
317
+ async function gunzipToFileCapped(gz, destPath, maxBytes) {
318
+ let total = 0;
319
+ const cap = new Transform({
320
+ transform(chunk, _enc, cb) {
321
+ total += chunk.length;
322
+ if (total > maxBytes) {
323
+ cb(
324
+ new HttpError(
325
+ 400,
326
+ `archive expands beyond the ${Math.round(maxBytes / 1024 / 1024)} MB uncompressed cap`
327
+ )
328
+ );
329
+ return;
330
+ }
331
+ cb(null, chunk);
332
+ }
333
+ });
334
+ await pipeline(Readable.from([gz]), createGunzip(), cap, createWriteStream(destPath));
335
+ }
336
+ function assertEntryCount(entries, max) {
337
+ if (entries > max) {
338
+ throw new HttpError(400, `archive has too many files (${entries} > ${max})`);
339
+ }
340
+ }
341
+ function assertUncompressedSize(uncompressed, maxBytes) {
342
+ if (uncompressed > maxBytes) {
343
+ throw new HttpError(
344
+ 400,
345
+ `archive expands to ${Math.round(uncompressed / 1024 / 1024)} MB uncompressed (max ${Math.round(maxBytes / 1024 / 1024)} MB)`
346
+ );
347
+ }
348
+ }
349
+ async function locateSkillDir(extractDir) {
350
+ if (await exists(join2(extractDir, "SKILL.md"))) return extractDir;
351
+ const dirs = (await readdir(extractDir, { withFileTypes: true })).filter(
352
+ (e) => e.isDirectory() && e.name !== "__MACOSX"
353
+ );
354
+ const withSkill = [];
355
+ for (const e of dirs) {
356
+ const dir = join2(extractDir, e.name);
357
+ if (await exists(join2(dir, "SKILL.md"))) withSkill.push(dir);
358
+ }
359
+ if (withSkill.length > 1) {
360
+ throw new HttpError(400, "archive contains multiple skill folders \u2014 install one skill per .zip");
361
+ }
362
+ if (withSkill.length === 1) return withSkill[0];
363
+ throw new HttpError(400, "archive has no SKILL.md (at the root or one level down)");
364
+ }
365
+ async function assertNoSymlinks(dir) {
366
+ const entries = await readdir(dir, { withFileTypes: true });
367
+ for (const e of entries) {
368
+ if (e.isSymbolicLink()) {
369
+ throw new HttpError(
370
+ 400,
371
+ "archive contains a symbolic link, which is not allowed in a skill package"
372
+ );
373
+ }
374
+ if (e.isDirectory()) await assertNoSymlinks(join2(dir, e.name));
375
+ }
376
+ }
377
+ async function resolveInstalledSkillName(srcDir, extractDir) {
378
+ const text = await readFile(join2(srcDir, "SKILL.md"), "utf8");
379
+ const fmName = parseFile(text).frontmatter.name;
380
+ if (typeof fmName === "string" && fmName.trim()) {
381
+ const n2 = fmName.trim();
382
+ ensureSkillName(n2);
383
+ return n2;
384
+ }
385
+ if (resolve(srcDir) === resolve(extractDir)) {
386
+ throw new HttpError(
387
+ 400,
388
+ "SKILL.md has no `name:` field and the archive isn't wrapped in a folder \u2014 add a `name:` to the skill's frontmatter"
389
+ );
390
+ }
391
+ const n = basename(srcDir);
392
+ ensureSkillName(n);
393
+ return n;
394
+ }
234
395
  async function createPrompt(opts) {
235
396
  const file = promptFileFor(opts.scope, opts.name, opts.roots);
236
397
  assertUnder(file, [opts.roots.userPrompts, opts.roots.projectPrompts]);
@@ -311,7 +472,7 @@ async function readPromptFile(filePath, roots) {
311
472
  assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
312
473
  const text = await readFile(filePath, "utf8");
313
474
  const { frontmatter, body } = parseFile(text);
314
- const stem = basename(filePath).replace(/\.md$/, "");
475
+ const stem = basename(filePath, ".md");
315
476
  return {
316
477
  body,
317
478
  name: stem,
@@ -319,10 +480,6 @@ async function readPromptFile(filePath, roots) {
319
480
  argumentHint: stringOr(frontmatter["argument-hint"], void 0)
320
481
  };
321
482
  }
322
- function basename(p) {
323
- const parts = p.split(sep);
324
- return parts.at(-1) || p;
325
- }
326
483
  function stringOr(value, fallback) {
327
484
  return typeof value === "string" ? value : fallback;
328
485
  }
@@ -338,17 +495,47 @@ async function exists(p) {
338
495
  }
339
496
  }
340
497
  var HttpError = class extends Error {
341
- constructor(status, message) {
498
+ constructor(status2, message) {
342
499
  super(message);
343
- this.status = status;
500
+ this.status = status2;
344
501
  }
345
502
  status;
346
503
  };
347
504
 
348
505
  // 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";
506
+ import { readFile as readFile2 } from "fs/promises";
507
+ import { join as join3 } from "path";
508
+ import { randomUUID as randomUUID2 } from "crypto";
509
+
510
+ // src/storage/atomic-json.ts
511
+ import { chmod, mkdir as mkdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
512
+ import { dirname as dirname2 } from "path";
351
513
  import { randomUUID } from "crypto";
514
+ async function writeJsonAtomic(filePath, data, opts) {
515
+ await mkdir2(dirname2(filePath), { recursive: true });
516
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
517
+ const text = JSON.stringify(data, null, 2);
518
+ try {
519
+ if (opts?.mode !== void 0) {
520
+ await writeFile2(tmp, text, { encoding: "utf8", mode: opts.mode });
521
+ } else {
522
+ await writeFile2(tmp, text, "utf8");
523
+ }
524
+ await rename(tmp, filePath);
525
+ } catch (err2) {
526
+ await rm2(tmp, { force: true }).catch(() => {
527
+ });
528
+ throw err2;
529
+ }
530
+ if (opts?.mode !== void 0) {
531
+ try {
532
+ await chmod(filePath, opts.mode);
533
+ } catch {
534
+ }
535
+ }
536
+ }
537
+
538
+ // src/storage/workspace-registry.ts
352
539
  var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
353
540
  var cache;
354
541
  var writeChain = Promise.resolve();
@@ -373,8 +560,7 @@ async function load() {
373
560
  }
374
561
  async function save() {
375
562
  if (!cache) return;
376
- await mkdir2(dirname2(REGISTRY_PATH), { recursive: true });
377
- await writeFile2(REGISTRY_PATH, JSON.stringify(cache, null, 2), "utf8");
563
+ await writeJsonAtomic(REGISTRY_PATH, cache);
378
564
  }
379
565
  async function listWorkspaces() {
380
566
  const r = await load();
@@ -394,7 +580,7 @@ async function addWorkspace(input) {
394
580
  return;
395
581
  }
396
582
  const ws = {
397
- id: randomUUID(),
583
+ id: randomUUID2(),
398
584
  name: input.name,
399
585
  path: input.path,
400
586
  addedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -417,6 +603,19 @@ async function removeWorkspace(id) {
417
603
  });
418
604
  return removed;
419
605
  }
606
+ async function setWorkspaceTrustProjectAgents(id, trusted) {
607
+ let updated;
608
+ await serializedWrite(async () => {
609
+ const r = await load();
610
+ const ws = r.workspaces.find((w) => w.id === id);
611
+ if (!ws) return;
612
+ if (trusted) ws.trustProjectAgents = true;
613
+ else delete ws.trustProjectAgents;
614
+ await save();
615
+ updated = ws;
616
+ });
617
+ return updated;
618
+ }
420
619
  async function reorderWorkspaces(ids) {
421
620
  await serializedWrite(async () => {
422
621
  const r = await load();
@@ -439,9 +638,9 @@ async function reorderWorkspaces(ids) {
439
638
  }
440
639
 
441
640
  // src/storage/workspace-stats.ts
442
- import { execFile } from "child_process";
443
- import { promisify } from "util";
444
- var exec = promisify(execFile);
641
+ import { execFile as execFile2 } from "child_process";
642
+ import { promisify as promisify2 } from "util";
643
+ var exec2 = promisify2(execFile2);
445
644
  var CACHE_TTL_MS = 3e4;
446
645
  var cache2 = /* @__PURE__ */ new Map();
447
646
  var inflight = /* @__PURE__ */ new Map();
@@ -453,13 +652,14 @@ async function enrichWorkspace(ws) {
453
652
  path: ws.path,
454
653
  addedAt: ws.addedAt,
455
654
  gitBranch: stats.gitBranch,
456
- fileCount: stats.fileCount
655
+ fileCount: stats.fileCount,
656
+ trustProjectAgents: ws.trustProjectAgents === true
457
657
  };
458
658
  }
459
659
  async function getStats(path) {
460
660
  const now = Date.now();
461
- const cached = cache2.get(path);
462
- if (cached && cached.expiresAt > now) return cached;
661
+ const cached2 = cache2.get(path);
662
+ if (cached2 && cached2.expiresAt > now) return cached2;
463
663
  const pending2 = inflight.get(path);
464
664
  if (pending2) return pending2;
465
665
  const probe = probeStats(path).then((stats) => {
@@ -516,7 +716,7 @@ async function probeStats(path) {
516
716
  return { gitBranch, fileCount };
517
717
  }
518
718
  async function runGit(cwd, args) {
519
- const { stdout } = await exec("git", args, {
719
+ const { stdout } = await exec2("git", args, {
520
720
  cwd,
521
721
  timeout: 2e3,
522
722
  maxBuffer: 5 * 1024 * 1024,
@@ -529,15 +729,106 @@ async function runGit(cwd, args) {
529
729
 
530
730
  // src/workspace-manager.ts
531
731
  import { unlink as unlink2 } from "fs/promises";
532
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
732
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
533
733
  import {
534
734
  createAgentSessionFromServices,
535
735
  createAgentSessionRuntime,
536
736
  createAgentSessionServices,
537
- getAgentDir,
737
+ getAgentDir as getAgentDir2,
538
738
  SessionManager
539
739
  } from "@earendil-works/pi-coding-agent";
540
740
 
741
+ // src/storage/session-tool-prefs.ts
742
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
743
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "path";
744
+ var PREFS_PATH = join4(config.dataDir, "session-tools.json");
745
+ var cache3 = { sessions: {} };
746
+ async function loadSessionToolPrefs() {
747
+ try {
748
+ const raw = await readFile3(PREFS_PATH, "utf8");
749
+ const parsed = JSON.parse(raw);
750
+ cache3 = { sessions: normalizeSessions(parsed.sessions) };
751
+ } catch (err2) {
752
+ cache3 = { sessions: {} };
753
+ if (err2.code !== "ENOENT") {
754
+ console.warn(`[session-tool-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
755
+ }
756
+ }
757
+ }
758
+ function keyOf(workspaceId, session) {
759
+ return session.sessionFile ? resolve2(session.sessionFile) : `${workspaceId}:${session.sessionId}`;
760
+ }
761
+ function storedDisabled(workspaceId, session) {
762
+ return cache3.sessions[keyOf(workspaceId, session)]?.disabled ?? [];
763
+ }
764
+ async function persistActiveTools(workspaceId, session, activeNames) {
765
+ const registered = session.getAllTools().map((t) => t.name);
766
+ const registeredSet = new Set(registered);
767
+ const active = new Set(activeNames);
768
+ const disabled = /* @__PURE__ */ new Set();
769
+ for (const name of registered) {
770
+ if (!active.has(name)) disabled.add(name);
771
+ }
772
+ for (const name of storedDisabled(workspaceId, session)) {
773
+ if (!registeredSet.has(name)) disabled.add(name);
774
+ }
775
+ cache3 = {
776
+ sessions: {
777
+ ...cache3.sessions,
778
+ [keyOf(workspaceId, session)]: {
779
+ disabled: sortUnique(disabled),
780
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
781
+ }
782
+ }
783
+ };
784
+ await save2();
785
+ session.setActiveToolsByName(registered.filter((name) => !disabled.has(name)));
786
+ }
787
+ function reapplyToolPrefs(workspaceId, session) {
788
+ const disabled = storedDisabled(workspaceId, session);
789
+ if (disabled.length === 0) return;
790
+ const disabledSet = new Set(disabled);
791
+ const registered = session.getAllTools().map((t) => t.name);
792
+ session.setActiveToolsByName(registered.filter((name) => !disabledSet.has(name)));
793
+ }
794
+ async function forgetSessionToolPrefs(sessionPath) {
795
+ const key = resolve2(sessionPath);
796
+ if (!(key in cache3.sessions)) return;
797
+ const next = { ...cache3.sessions };
798
+ delete next[key];
799
+ cache3 = { sessions: next };
800
+ await save2();
801
+ }
802
+ function sortUnique(values) {
803
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
804
+ }
805
+ function normalizeSessions(value) {
806
+ if (!value || typeof value !== "object") return {};
807
+ const out = {};
808
+ for (const [key, entry] of Object.entries(value)) {
809
+ if (!entry || typeof entry !== "object") continue;
810
+ const disabled = entry.disabled;
811
+ if (!Array.isArray(disabled)) continue;
812
+ const updatedAt = entry.updatedAt;
813
+ out[key] = {
814
+ disabled: sortUnique(disabled.filter((n) => typeof n === "string")),
815
+ updatedAt: typeof updatedAt === "string" ? updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
816
+ };
817
+ }
818
+ return out;
819
+ }
820
+ var writeChain2 = Promise.resolve();
821
+ function save2() {
822
+ writeChain2 = writeChain2.catch(() => {
823
+ }).then(async () => {
824
+ await mkdir3(dirname3(PREFS_PATH), { recursive: true });
825
+ const tmp = `${PREFS_PATH}.tmp`;
826
+ await writeFile3(tmp, JSON.stringify(cache3, null, 2), "utf8");
827
+ await rename2(tmp, PREFS_PATH);
828
+ });
829
+ return writeChain2;
830
+ }
831
+
541
832
  // src/extensions/todo/schema.ts
542
833
  import { Type } from "typebox";
543
834
  var EMPTY_STATE = { tasks: [], nextId: 1 };
@@ -877,7 +1168,7 @@ function waitForAnswer({
877
1168
  sessionFile,
878
1169
  signal
879
1170
  }) {
880
- return new Promise((resolve6, reject) => {
1171
+ return new Promise((resolve9, reject) => {
881
1172
  let settled = false;
882
1173
  let timeoutHandle;
883
1174
  const cleanup = () => {
@@ -889,7 +1180,7 @@ function waitForAnswer({
889
1180
  if (settled) return;
890
1181
  settled = true;
891
1182
  cleanup();
892
- resolve6(a);
1183
+ resolve9(a);
893
1184
  };
894
1185
  const finishErr = (err2) => {
895
1186
  if (settled) return;
@@ -1027,140 +1318,1468 @@ var artifactExtensionFactory = (pi) => {
1027
1318
  });
1028
1319
  };
1029
1320
 
1030
- // 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: [] };
1035
- async function loadBuiltinPrefs() {
1321
+ // src/extensions/subagent/agents.ts
1322
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
1323
+ import { join as join6 } from "path";
1324
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
1325
+
1326
+ // src/extensions/subagent/builtin-agents.ts
1327
+ var COMMON_RULES = `Hard rules:
1328
+ - You run headless as a subagent: there is NO user to talk to. Never ask questions, never wait for confirmation \u2014 decide and proceed.
1329
+ - NEVER run the \`pi\` CLI or spawn any other agent. No nesting.
1330
+ - 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\`.`;
1331
+ var scout = {
1332
+ name: "scout",
1333
+ source: "builtin",
1334
+ description: "Fast read-only codebase recon: locate files, symbols, flows, conventions, and report compressed findings. Cheap to use; cannot edit anything.",
1335
+ tools: ["read", "grep", "find", "ls"],
1336
+ systemPrompt: `You are "scout", a read-only reconnaissance subagent.
1337
+
1338
+ ${COMMON_RULES}
1339
+
1340
+ 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.
1341
+
1342
+ 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.`
1343
+ };
1344
+ var worker = {
1345
+ name: "worker",
1346
+ source: "builtin",
1347
+ 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.",
1348
+ systemPrompt: `You are "worker", an implementation subagent.
1349
+
1350
+ ${COMMON_RULES}
1351
+
1352
+ 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.
1353
+
1354
+ 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.`
1355
+ };
1356
+ var reviewer = {
1357
+ name: "reviewer",
1358
+ source: "builtin",
1359
+ description: "Code review of specific files or diffs: correctness, edge cases, convention drift. Read-mostly (bash for git diff / running tests). Returns prioritized findings.",
1360
+ tools: ["read", "grep", "find", "ls", "bash"],
1361
+ systemPrompt: `You are "reviewer", a code-review subagent. Your job is to FIND problems, not to fix them \u2014 do not edit any file.
1362
+
1363
+ ${COMMON_RULES}
1364
+
1365
+ 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.
1366
+
1367
+ 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.`
1368
+ };
1369
+ var BUILTIN_AGENTS = [scout, worker, reviewer];
1370
+
1371
+ // src/extensions/subagent/trust.ts
1372
+ import { readFileSync } from "fs";
1373
+ import { homedir as homedir2 } from "os";
1374
+ import { join as join5, resolve as resolve3 } from "path";
1375
+ function isProjectDirTrusted(projectDir) {
1376
+ const registryPath = join5(
1377
+ process.env.PI_PILOT_DATA_DIR ?? join5(homedir2(), ".pi", "webui"),
1378
+ "workspaces.json"
1379
+ );
1036
1380
  try {
1037
- const raw = await readFile3(PREFS_PATH, "utf8");
1038
- const parsed = JSON.parse(raw);
1039
- cache3 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
1040
- } catch (err2) {
1041
- cache3 = { disabled: [] };
1042
- if (err2.code !== "ENOENT") {
1043
- console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
1044
- }
1381
+ const raw = JSON.parse(readFileSync(registryPath, "utf8"));
1382
+ if (!Array.isArray(raw.workspaces)) return false;
1383
+ const wanted = resolve3(projectDir);
1384
+ return raw.workspaces.some(
1385
+ (w) => typeof w?.path === "string" && resolve3(w.path) === wanted && w.trustProjectAgents === true
1386
+ );
1387
+ } catch {
1388
+ return false;
1045
1389
  }
1046
1390
  }
1047
- function isBuiltinDisabled(id) {
1048
- if (cache3.disabled.includes(id)) return true;
1049
- if (id === "todo" && cache3.disabled.includes("plan")) return true;
1050
- return false;
1391
+
1392
+ // src/extensions/subagent/agents.ts
1393
+ function userAgentsDir() {
1394
+ return join6(getAgentDir(), "agents");
1051
1395
  }
1052
- function getDisabledBuiltins() {
1053
- return [...cache3.disabled];
1396
+ function projectAgentsDir(projectDir) {
1397
+ return join6(projectDir, ".pi", "agents");
1054
1398
  }
1055
- async function setBuiltinEnabled(id, enabled) {
1056
- const next = new Set(cache3.disabled);
1057
- if (enabled) next.delete(id);
1058
- else next.add(id);
1059
- cache3 = { disabled: [...next] };
1060
- await save2();
1399
+ function discoverAgents(projectDir) {
1400
+ const roster = /* @__PURE__ */ new Map();
1401
+ for (const agent of BUILTIN_AGENTS) roster.set(agent.name, agent);
1402
+ mergeDir(roster, userAgentsDir(), "user");
1403
+ if (projectDir && isProjectDirTrusted(projectDir)) {
1404
+ mergeDir(roster, projectAgentsDir(projectDir), "project");
1405
+ }
1406
+ return roster;
1061
1407
  }
1062
- async function save2() {
1063
- await mkdir3(dirname3(PREFS_PATH), { recursive: true });
1064
- await writeFile3(PREFS_PATH, JSON.stringify(cache3, null, 2), "utf8");
1408
+ function mergeDir(roster, dir, source) {
1409
+ let files;
1410
+ try {
1411
+ files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
1412
+ } catch {
1413
+ return;
1414
+ }
1415
+ for (const file of files) {
1416
+ const def = parseAgentFile(join6(dir, file), file, source);
1417
+ if (def) roster.set(def.name, def);
1418
+ }
1065
1419
  }
1066
-
1067
- // src/extensions/index.ts
1068
- var BUILTIN_EXTENSIONS = [
1069
- {
1070
- id: "todo",
1071
- name: "Todo",
1072
- description: "A CRUD task list for tracking multi-step work \u2014 adds the todo tool and the /todos command.",
1073
- tools: ["todo"],
1074
- commands: ["todos"],
1075
- factory: todoExtensionFactory
1076
- },
1077
- {
1078
- id: "ask_user",
1079
- name: "Ask user",
1080
- description: "Lets the agent pause and ask you a structured multiple-choice question \u2014 adds the ask_user tool.",
1081
- tools: ["ask_user"],
1082
- commands: [],
1083
- factory: askUserExtensionFactory
1084
- },
1085
- {
1086
- id: "artifact",
1087
- name: "Artifacts",
1088
- description: "Lets the agent publish substantial, self-contained content \u2014 web pages, SVG diagrams, documents, code files \u2014 as versioned artifacts rendered in a side panel. Adds the create_artifact tool.",
1089
- tools: ["create_artifact"],
1090
- commands: [],
1091
- factory: artifactExtensionFactory
1420
+ function rosterSummary(roster) {
1421
+ return [...roster.values()].map((a) => `- ${a.name}: ${a.description || "(no description)"}`).join("\n");
1422
+ }
1423
+ function parseAgentFile(path, filename, source) {
1424
+ try {
1425
+ const raw = readFileSync2(path, "utf8");
1426
+ const { frontmatter, body } = parseFrontmatter(raw);
1427
+ const name = strField(frontmatter.name) ?? filename.replace(/\.md$/, "").trim();
1428
+ if (!name) return void 0;
1429
+ return {
1430
+ name,
1431
+ description: strField(frontmatter.description) ?? "",
1432
+ systemPrompt: body.trim(),
1433
+ tools: toolsField(frontmatter.tools),
1434
+ model: strField(frontmatter.model),
1435
+ source
1436
+ };
1437
+ } catch {
1438
+ return void 0;
1092
1439
  }
1093
- ];
1094
- function gate(def) {
1095
- return (pi) => {
1096
- if (isBuiltinDisabled(def.id)) return;
1097
- return def.factory(pi);
1098
- };
1099
1440
  }
1100
- var builtinExtensionFactories = BUILTIN_EXTENSIONS.map(gate);
1441
+ function strField(value) {
1442
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1443
+ }
1444
+ function toolsField(value) {
1445
+ if (typeof value === "string") {
1446
+ const parts = value.split(",").map((s) => s.trim()).filter(Boolean);
1447
+ return parts.length > 0 ? parts : void 0;
1448
+ }
1449
+ if (Array.isArray(value)) {
1450
+ const parts = value.filter((v) => typeof v === "string" && v.trim() !== "");
1451
+ return parts.length > 0 ? parts.map((s) => s.trim()) : void 0;
1452
+ }
1453
+ return void 0;
1454
+ }
1101
1455
 
1102
- // src/extensions/ask_user/cleanup.ts
1103
- var CUSTOM_TYPE = "ask_user-restart-cancelled";
1104
- function reconcileAfterRestart(sessionManager) {
1105
- const branch = sessionManager.getBranch();
1106
- if (branch.length === 0) return;
1107
- const satisfied = /* @__PURE__ */ new Set();
1108
- const danglingIds = [];
1109
- const danglingAlreadyHandled = /* @__PURE__ */ new Set();
1110
- for (let i = branch.length - 1; i >= 0; i--) {
1111
- const entry = branch[i];
1112
- if (entry.type === "custom_message") {
1113
- const cm = entry;
1114
- if (cm.customType === CUSTOM_TYPE) {
1115
- const ids = cm.details?.ids;
1116
- if (Array.isArray(ids)) {
1117
- for (const id of ids) {
1118
- if (typeof id === "string") danglingAlreadyHandled.add(id);
1119
- }
1120
- }
1121
- }
1122
- continue;
1123
- }
1124
- if (entry.type !== "message") continue;
1125
- const msg = entry.message;
1126
- if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
1127
- satisfied.add(msg.toolCallId);
1128
- continue;
1129
- }
1130
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
1131
- for (const block of msg.content) {
1132
- if (!block || typeof block !== "object") continue;
1133
- const b = block;
1134
- if (b.type !== "toolCall") continue;
1135
- if (b.name !== "ask_user") continue;
1136
- if (typeof b.id !== "string") continue;
1137
- if (satisfied.has(b.id)) continue;
1138
- if (danglingAlreadyHandled.has(b.id)) continue;
1139
- danglingIds.push(b.id);
1456
+ // src/extensions/subagent/child.ts
1457
+ import { spawn } from "child_process";
1458
+ import {
1459
+ createWriteStream as createWriteStream2,
1460
+ mkdirSync,
1461
+ mkdtempSync,
1462
+ writeFileSync
1463
+ } from "fs";
1464
+ import { rm as rm3 } from "fs/promises";
1465
+ import { tmpdir as tmpdir2 } from "os";
1466
+ import { join as join8 } from "path";
1467
+
1468
+ // src/extensions/subagent/schema.ts
1469
+ import { Type as Type4 } from "typebox";
1470
+ var MAX_TASKS_PER_CALL = 8;
1471
+ 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.";
1472
+ var subagentParamsSchema = Type4.Object({
1473
+ agent: Type4.Optional(
1474
+ Type4.String({
1475
+ 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`."
1476
+ })
1477
+ ),
1478
+ task: Type4.Optional(Type4.String({ description: taskBriefDescription })),
1479
+ tasks: Type4.Optional(
1480
+ Type4.Array(
1481
+ Type4.Object({
1482
+ agent: Type4.String({ description: "Agent to delegate this task to, by name." }),
1483
+ task: Type4.String({ description: taskBriefDescription })
1484
+ }),
1485
+ {
1486
+ maxItems: MAX_TASKS_PER_CALL,
1487
+ 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\`.`
1140
1488
  }
1141
- break;
1489
+ )
1490
+ )
1491
+ });
1492
+ function emptyUsage() {
1493
+ return {
1494
+ input: 0,
1495
+ output: 0,
1496
+ cacheRead: 0,
1497
+ cacheWrite: 0,
1498
+ cost: 0,
1499
+ contextTokens: 0,
1500
+ turns: 0
1501
+ };
1502
+ }
1503
+ function isSubagentDetails(value) {
1504
+ if (!value || typeof value !== "object") return false;
1505
+ const v = value;
1506
+ return v.version === 1 && (v.mode === "single" || v.mode === "parallel") && Array.isArray(v.tasks);
1507
+ }
1508
+
1509
+ // src/extensions/subagent/pi-bin.ts
1510
+ import { existsSync } from "fs";
1511
+ import { dirname as dirname4, join as join7 } from "path";
1512
+ import { fileURLToPath } from "url";
1513
+ var cached;
1514
+ function resolvePinnedPiCli() {
1515
+ if (cached) return cached;
1516
+ try {
1517
+ const entry = fileURLToPath(
1518
+ import.meta.resolve("@earendil-works/pi-coding-agent")
1519
+ );
1520
+ const candidate = join7(dirname4(entry), "cli.js");
1521
+ if (existsSync(candidate)) {
1522
+ cached = candidate;
1523
+ return candidate;
1142
1524
  }
1143
- if (msg.role === "user") break;
1525
+ } catch {
1144
1526
  }
1145
- if (danglingIds.length === 0) return;
1146
- const idList = danglingIds.join(", ");
1147
- const text = `[pi-pilot] Your previous ask_user call(s) [${idList}] were cancelled because the server restarted before the user answered. Use your best judgement and proceed; you may re-call ask_user if the decision still matters.`;
1148
- sessionManager.appendCustomMessageEntry(
1149
- CUSTOM_TYPE,
1150
- text,
1151
- true,
1152
- // display in TUI too (no-op in pi-pilot, but harmless)
1153
- { ids: danglingIds }
1527
+ const fallback = fileURLToPath(
1528
+ new URL(
1529
+ "../../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
1530
+ import.meta.url
1531
+ )
1532
+ );
1533
+ if (existsSync(fallback)) {
1534
+ cached = fallback;
1535
+ return fallback;
1536
+ }
1537
+ throw new Error(
1538
+ "subagent: cannot locate the pinned @earendil-works/pi-coding-agent CLI (tried import.meta.resolve and the package-local node_modules symlink)"
1154
1539
  );
1155
1540
  }
1156
1541
 
1157
- // src/ws/bridge.ts
1158
- function translatePiEvent(ev) {
1159
- switch (ev.type) {
1160
- case "agent_start":
1161
- return { kind: "agent_start" };
1162
- case "agent_end":
1163
- return { kind: "agent_end", willRetry: ev.willRetry };
1542
+ // src/extensions/subagent/registry.ts
1543
+ var children = /* @__PURE__ */ new Map();
1544
+ function registerChild(toolCallId, handle2) {
1545
+ children.set(toolCallId, handle2);
1546
+ }
1547
+ function unregisterChild(toolCallId) {
1548
+ children.delete(toolCallId);
1549
+ }
1550
+ function killChildrenForSession(sessionFile) {
1551
+ let killed = 0;
1552
+ for (const [id, handle2] of children) {
1553
+ if (handle2.sessionFile !== sessionFile) continue;
1554
+ children.delete(id);
1555
+ handle2.kill();
1556
+ killed++;
1557
+ }
1558
+ return killed;
1559
+ }
1560
+ function killAllChildren() {
1561
+ let killed = 0;
1562
+ for (const [id, handle2] of children) {
1563
+ children.delete(id);
1564
+ handle2.kill();
1565
+ killed++;
1566
+ }
1567
+ return killed;
1568
+ }
1569
+ var MAX_CONCURRENT_CHILDREN = 8;
1570
+ var MAX_CONCURRENT_PER_SESSION = 4;
1571
+ var running = 0;
1572
+ var runningPerSession = /* @__PURE__ */ new Map();
1573
+ var waiters = [];
1574
+ function keyOf2(sessionFile) {
1575
+ return sessionFile ?? "<unpersisted>";
1576
+ }
1577
+ function hasCapacity(sessionKey) {
1578
+ return running < MAX_CONCURRENT_CHILDREN && (runningPerSession.get(sessionKey) ?? 0) < MAX_CONCURRENT_PER_SESSION;
1579
+ }
1580
+ function take(sessionKey) {
1581
+ running++;
1582
+ runningPerSession.set(sessionKey, (runningPerSession.get(sessionKey) ?? 0) + 1);
1583
+ }
1584
+ function acquireChildSlot(signal, sessionFile) {
1585
+ const sessionKey = keyOf2(sessionFile);
1586
+ return new Promise((resolve9, reject) => {
1587
+ if (signal?.aborted) {
1588
+ reject(new Error("Aborted by user"));
1589
+ return;
1590
+ }
1591
+ if (hasCapacity(sessionKey)) {
1592
+ take(sessionKey);
1593
+ resolve9(makeRelease(sessionKey));
1594
+ return;
1595
+ }
1596
+ const waiter = {
1597
+ grant: () => resolve9(makeRelease(sessionKey)),
1598
+ sessionKey,
1599
+ signal,
1600
+ onAbort: void 0
1601
+ };
1602
+ if (signal) {
1603
+ const onAbort = () => {
1604
+ const i = waiters.indexOf(waiter);
1605
+ if (i >= 0) waiters.splice(i, 1);
1606
+ reject(new Error("Aborted by user"));
1607
+ };
1608
+ waiter.onAbort = onAbort;
1609
+ signal.addEventListener("abort", onAbort, { once: true });
1610
+ }
1611
+ waiters.push(waiter);
1612
+ });
1613
+ }
1614
+ function makeRelease(sessionKey) {
1615
+ let released = false;
1616
+ return () => {
1617
+ if (released) return;
1618
+ released = true;
1619
+ running--;
1620
+ const n = (runningPerSession.get(sessionKey) ?? 1) - 1;
1621
+ if (n <= 0) runningPerSession.delete(sessionKey);
1622
+ else runningPerSession.set(sessionKey, n);
1623
+ pump();
1624
+ };
1625
+ }
1626
+ function pump() {
1627
+ for (let i = 0; i < waiters.length && running < MAX_CONCURRENT_CHILDREN; ) {
1628
+ const waiter = waiters[i];
1629
+ if (waiter.signal?.aborted) {
1630
+ waiters.splice(i, 1);
1631
+ continue;
1632
+ }
1633
+ if (!hasCapacity(waiter.sessionKey)) {
1634
+ i++;
1635
+ continue;
1636
+ }
1637
+ waiters.splice(i, 1);
1638
+ if (waiter.signal && waiter.onAbort) {
1639
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
1640
+ }
1641
+ take(waiter.sessionKey);
1642
+ waiter.grant();
1643
+ }
1644
+ }
1645
+
1646
+ // src/extensions/subagent/child.ts
1647
+ var PROMPT_DIR_PREFIX = "pi-pilot-subagent-";
1648
+ var TRANSCRIPTS_DIR = join8(tmpdir2(), "pi-pilot-subagents", "transcripts");
1649
+ var ACTIVITY_MAX = 30;
1650
+ var LABEL_MAX = 160;
1651
+ var STDERR_TAIL_MAX = 2048;
1652
+ var FINAL_TEXT_MAX = 2e5;
1653
+ var SIGKILL_DELAY_MS = 5e3;
1654
+ async function runChild(opts) {
1655
+ const startedAt = Date.now();
1656
+ const cli = opts.cliPath ?? process.env.PI_PILOT_SUBAGENT_CLI ?? resolvePinnedPiCli();
1657
+ const promptDir = mkdtempSync(join8(tmpdir2(), PROMPT_DIR_PREFIX));
1658
+ const promptPath = join8(promptDir, "prompt.md");
1659
+ writeFileSync(promptPath, opts.appendSystemPrompt, { mode: 384 });
1660
+ let transcriptPath;
1661
+ let tee;
1662
+ try {
1663
+ mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
1664
+ transcriptPath = join8(TRANSCRIPTS_DIR, `${sanitizeId(opts.toolCallId)}.ndjson`);
1665
+ tee = createWriteStream2(transcriptPath, { flags: "w" });
1666
+ tee.on("error", () => {
1667
+ });
1668
+ } catch {
1669
+ transcriptPath = void 0;
1670
+ }
1671
+ const args = [cli, "--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
1672
+ const model = opts.agent.model ?? opts.inheritModel;
1673
+ if (model) args.push("--model", model);
1674
+ if (opts.agent.tools && opts.agent.tools.length > 0) {
1675
+ args.push("--tools", opts.agent.tools.join(","));
1676
+ }
1677
+ args.push("--append-system-prompt", promptPath);
1678
+ args.push(opts.task);
1679
+ const usage = emptyUsage();
1680
+ const activity = [];
1681
+ let modelSeen;
1682
+ let finalText = "";
1683
+ let stopReason;
1684
+ let errorMessage;
1685
+ let stderrAccum = "";
1686
+ let aborted = false;
1687
+ let timedOut = false;
1688
+ let costKilled = false;
1689
+ const child = spawn(process.execPath, args, {
1690
+ cwd: opts.cwd,
1691
+ env: { ...process.env, PI_PILOT_SUBAGENT: opts.toolCallId },
1692
+ stdio: ["ignore", "pipe", "pipe"],
1693
+ shell: false
1694
+ });
1695
+ if (child.pid !== void 0) {
1696
+ try {
1697
+ writeFileSync(join8(promptDir, "pid"), `${child.pid}
1698
+ ${process.pid}`);
1699
+ } catch {
1700
+ }
1701
+ }
1702
+ let killed = false;
1703
+ let killTimer;
1704
+ const killGracefully = () => {
1705
+ if (killed) return;
1706
+ killed = true;
1707
+ try {
1708
+ child.kill("SIGTERM");
1709
+ } catch {
1710
+ }
1711
+ killTimer = setTimeout(() => {
1712
+ try {
1713
+ child.kill("SIGKILL");
1714
+ } catch {
1715
+ }
1716
+ }, SIGKILL_DELAY_MS);
1717
+ };
1718
+ registerChild(opts.toolCallId, {
1719
+ sessionFile: opts.sessionFile,
1720
+ agent: opts.agent.name,
1721
+ kill: killGracefully
1722
+ });
1723
+ const onAbort = () => {
1724
+ aborted = true;
1725
+ killGracefully();
1726
+ };
1727
+ if (opts.signal?.aborted) onAbort();
1728
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
1729
+ let stalled = false;
1730
+ const timeoutTimer = setTimeout(() => {
1731
+ timedOut = true;
1732
+ killGracefully();
1733
+ }, opts.timeoutMs);
1734
+ let stallTimer;
1735
+ const armStallTimer = () => {
1736
+ if (killed) return;
1737
+ if (stallTimer) clearTimeout(stallTimer);
1738
+ stallTimer = setTimeout(() => {
1739
+ stalled = true;
1740
+ timedOut = true;
1741
+ killGracefully();
1742
+ }, opts.stallTimeoutMs);
1743
+ };
1744
+ armStallTimer();
1745
+ const emitProgress = () => {
1746
+ opts.onProgress({
1747
+ usage: { ...usage },
1748
+ model: modelSeen,
1749
+ activity: [...activity],
1750
+ lastLabel: activity[activity.length - 1]?.label
1751
+ });
1752
+ };
1753
+ const handleLine = (line) => {
1754
+ if (!line.trim()) return;
1755
+ tee?.write(line + "\n");
1756
+ let event;
1757
+ try {
1758
+ event = JSON.parse(line);
1759
+ } catch {
1760
+ return;
1761
+ }
1762
+ const ev = event;
1763
+ if (ev.type !== "message_end" || !ev.message || typeof ev.message !== "object") return;
1764
+ const msg = ev.message;
1765
+ if (msg.role !== "assistant") return;
1766
+ usage.turns++;
1767
+ const u = msg.usage;
1768
+ if (u) {
1769
+ usage.input += u.input ?? 0;
1770
+ usage.output += u.output ?? 0;
1771
+ usage.cacheRead += u.cacheRead ?? 0;
1772
+ usage.cacheWrite += u.cacheWrite ?? 0;
1773
+ usage.cost += u.cost?.total ?? 0;
1774
+ usage.contextTokens = u.totalTokens ?? usage.contextTokens;
1775
+ }
1776
+ if (!modelSeen && typeof msg.model === "string") modelSeen = msg.model;
1777
+ if (typeof msg.stopReason === "string") stopReason = msg.stopReason;
1778
+ if (typeof msg.errorMessage === "string") errorMessage = msg.errorMessage;
1779
+ if (Array.isArray(msg.content)) {
1780
+ const textParts = [];
1781
+ for (const block of msg.content) {
1782
+ if (!block || typeof block !== "object") continue;
1783
+ const b = block;
1784
+ if (b.type === "text" && typeof b.text === "string") {
1785
+ textParts.push(b.text);
1786
+ } else if (b.type === "toolCall" && typeof b.name === "string") {
1787
+ pushActivity(activity, b.name, b.arguments);
1788
+ }
1789
+ }
1790
+ const text = textParts.join("").trim();
1791
+ if (text) finalText = text.slice(0, FINAL_TEXT_MAX);
1792
+ }
1793
+ if (usage.cost > opts.costCeilingUsd && !costKilled) {
1794
+ costKilled = true;
1795
+ killGracefully();
1796
+ }
1797
+ emitProgress();
1798
+ };
1799
+ let buf = "";
1800
+ child.stdout?.on("data", (chunk) => {
1801
+ armStallTimer();
1802
+ buf += chunk.toString("utf8");
1803
+ let nl;
1804
+ while ((nl = buf.indexOf("\n")) >= 0) {
1805
+ handleLine(buf.slice(0, nl));
1806
+ buf = buf.slice(nl + 1);
1807
+ }
1808
+ });
1809
+ child.stderr?.on("data", (chunk) => {
1810
+ armStallTimer();
1811
+ stderrAccum = (stderrAccum + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX);
1812
+ });
1813
+ const exitCode = await new Promise((resolve9) => {
1814
+ child.on("error", (err2) => {
1815
+ errorMessage ??= err2 instanceof Error ? err2.message : String(err2);
1816
+ resolve9(-1);
1817
+ });
1818
+ child.on("close", (code) => resolve9(code ?? -1));
1819
+ });
1820
+ if (buf) handleLine(buf);
1821
+ clearTimeout(timeoutTimer);
1822
+ if (stallTimer) clearTimeout(stallTimer);
1823
+ if (killTimer) clearTimeout(killTimer);
1824
+ opts.signal?.removeEventListener("abort", onAbort);
1825
+ unregisterChild(opts.toolCallId);
1826
+ tee?.end();
1827
+ await rm3(promptDir, { recursive: true, force: true }).catch(() => {
1828
+ });
1829
+ if (costKilled && !errorMessage) {
1830
+ errorMessage = `cost ceiling ($${opts.costCeilingUsd}) exceeded \u2014 child terminated`;
1831
+ }
1832
+ if (timedOut && !aborted && !errorMessage) {
1833
+ 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`;
1834
+ }
1835
+ const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" || costKilled;
1836
+ const status2 = aborted ? "aborted" : timedOut ? "timeout" : failed ? "failed" : "done";
1837
+ return {
1838
+ status: status2,
1839
+ finalText,
1840
+ usage,
1841
+ model: modelSeen,
1842
+ stopReason,
1843
+ errorMessage,
1844
+ stderrTail: stderrAccum.trim(),
1845
+ exitCode,
1846
+ durationMs: Date.now() - startedAt,
1847
+ transcriptPath,
1848
+ activity
1849
+ };
1850
+ }
1851
+ function sanitizeId(id) {
1852
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
1853
+ }
1854
+ function pushActivity(activity, name, args) {
1855
+ const label = toolLabel(name, args).slice(0, LABEL_MAX);
1856
+ activity.push({ kind: "tool", label });
1857
+ if (activity.length > ACTIVITY_MAX) {
1858
+ activity.splice(0, activity.length - ACTIVITY_MAX);
1859
+ }
1860
+ }
1861
+ function toolLabel(name, args) {
1862
+ const a = args && typeof args === "object" ? args : {};
1863
+ const pick = (...keys) => {
1864
+ for (const key of keys) {
1865
+ const v = a[key];
1866
+ if (typeof v === "string" && v.trim()) return v;
1867
+ }
1868
+ return void 0;
1869
+ };
1870
+ let detail;
1871
+ switch (name) {
1872
+ case "bash":
1873
+ detail = pick("command");
1874
+ break;
1875
+ case "read":
1876
+ case "write":
1877
+ case "edit":
1878
+ detail = pick("path", "file_path");
1879
+ break;
1880
+ case "grep":
1881
+ detail = pick("pattern");
1882
+ break;
1883
+ case "find":
1884
+ detail = pick("pattern", "path");
1885
+ break;
1886
+ case "ls":
1887
+ detail = pick("path");
1888
+ break;
1889
+ default: {
1890
+ for (const v of Object.values(a)) {
1891
+ if (typeof v === "string" && v.trim()) {
1892
+ detail = v;
1893
+ break;
1894
+ }
1895
+ }
1896
+ }
1897
+ }
1898
+ const clean = detail?.replace(/\s+/g, " ").trim();
1899
+ return clean ? `${name}: ${clean}` : name;
1900
+ }
1901
+
1902
+ // src/extensions/subagent/factory.ts
1903
+ var TASK_OUTPUT_CAP = 12 * 1024;
1904
+ var AGGREGATE_OUTPUT_CAP = 48 * 1024;
1905
+ var PREVIEW_CAP = 8 * 1024;
1906
+ var STDERR_DETAILS_CAP = 1024;
1907
+ var UPDATE_THROTTLE_MS = 500;
1908
+ function tunable(envName, fallback) {
1909
+ const raw = process.env[envName];
1910
+ if (!raw) return fallback;
1911
+ const n = Number.parseFloat(raw);
1912
+ return Number.isFinite(n) && n > 0 ? n : fallback;
1913
+ }
1914
+ var TASK_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_TIMEOUT_SEC", 3600) * 1e3;
1915
+ var STALL_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_STALL_SEC", 600) * 1e3;
1916
+ var COST_CEILING_USD = tunable("PI_PILOT_SUBAGENT_COST_USD", 20);
1917
+ var subagentExtensionFactory = (pi) => {
1918
+ const lastDetails = /* @__PURE__ */ new Map();
1919
+ const rosterAtRegistration = discoverAgents();
1920
+ pi.registerTool({
1921
+ name: "subagent",
1922
+ label: "Subagent",
1923
+ 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:
1924
+ ` + rosterSummary(rosterAtRegistration) + `
1925
+ 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).`,
1926
+ parameters: subagentParamsSchema,
1927
+ executionMode: "parallel",
1928
+ 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`.",
1929
+ promptGuidelines: [
1930
+ "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.",
1931
+ "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.",
1932
+ "Pick the cheapest sufficient agent: scout for read-only recon, reviewer for read-mostly review, worker only when files must change.",
1933
+ `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.`,
1934
+ "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."
1935
+ ],
1936
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1937
+ const roster = discoverAgents(ctx.cwd);
1938
+ const calls = normalizeCalls(params);
1939
+ if (typeof calls === "string") {
1940
+ return invalidCallResult(calls, params);
1941
+ }
1942
+ const { mode, briefs } = calls;
1943
+ const unknown = [...new Set(briefs.map((b) => b.agent.trim()))].filter(
1944
+ (name) => !roster.has(name)
1945
+ );
1946
+ if (unknown.length > 0) {
1947
+ return invalidCallResult(
1948
+ `Unknown agent${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")}. Available agents:
1949
+ ` + rosterSummary(roster) + "\nCall subagent again with one of these names.",
1950
+ params
1951
+ );
1952
+ }
1953
+ if (signal?.aborted) throw new Error("Aborted by user");
1954
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
1955
+ const resolved = briefs.map((b) => ({
1956
+ agent: roster.get(b.agent.trim()),
1957
+ task: b.task
1958
+ }));
1959
+ const tasks = resolved.map((r) => ({
1960
+ agent: r.agent.name,
1961
+ agentSource: r.agent.source,
1962
+ task: r.task,
1963
+ status: "queued",
1964
+ activity: [],
1965
+ usage: emptyUsage()
1966
+ }));
1967
+ const details = { version: 1, mode, tasks };
1968
+ const emitter = makeThrottledEmitter(onUpdate, details);
1969
+ emitter.emit(true);
1970
+ const runOne = async (i) => {
1971
+ const task = tasks[i];
1972
+ const r = resolved[i];
1973
+ let release;
1974
+ try {
1975
+ release = await acquireChildSlot(signal, sessionFile);
1976
+ } catch (err2) {
1977
+ if (!signal?.aborted) throw err2;
1978
+ task.status = "aborted";
1979
+ emitter.emit(true);
1980
+ return void 0;
1981
+ }
1982
+ try {
1983
+ task.status = "running";
1984
+ emitter.emit(true);
1985
+ const outcome = await runChild({
1986
+ // Per-child id: registry entries, pidfiles and transcript
1987
+ // files must not collide across one call's siblings.
1988
+ toolCallId: mode === "single" ? toolCallId : `${toolCallId}.${i}`,
1989
+ agent: r.agent,
1990
+ task: r.task,
1991
+ cwd: ctx.cwd,
1992
+ sessionFile,
1993
+ appendSystemPrompt: composeChildPrompt(r.agent),
1994
+ inheritModel: r.agent.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : void 0),
1995
+ signal,
1996
+ timeoutMs: TASK_TIMEOUT_MS,
1997
+ stallTimeoutMs: STALL_TIMEOUT_MS,
1998
+ costCeilingUsd: COST_CEILING_USD,
1999
+ onProgress: (p) => {
2000
+ task.usage = p.usage;
2001
+ task.model = p.model ?? task.model;
2002
+ task.activity = p.activity;
2003
+ emitter.emit();
2004
+ }
2005
+ });
2006
+ mergeOutcome(task, outcome);
2007
+ emitter.emit(true);
2008
+ return outcome;
2009
+ } finally {
2010
+ release();
2011
+ }
2012
+ };
2013
+ try {
2014
+ const outcomes = await Promise.all(resolved.map((_, i) => runOne(i)));
2015
+ emitter.cancel();
2016
+ lastDetails.set(toolCallId, details);
2017
+ if (tasks.some((t) => t.status === "aborted")) {
2018
+ throw new Error("Aborted by user");
2019
+ }
2020
+ return {
2021
+ content: [{ type: "text", text: combinedParentText(mode, resolved, outcomes) }],
2022
+ details
2023
+ };
2024
+ } finally {
2025
+ emitter.cancel();
2026
+ }
2027
+ }
2028
+ });
2029
+ pi.on("tool_result", (ev) => {
2030
+ if (ev.toolName !== "subagent") return void 0;
2031
+ const fromMap = lastDetails.get(ev.toolCallId);
2032
+ lastDetails.delete(ev.toolCallId);
2033
+ const details = isSubagentDetails(ev.details) ? ev.details : fromMap;
2034
+ if (!details) return void 0;
2035
+ const failed = details.tasks.some(
2036
+ (t) => t.status === "failed" || t.status === "timeout"
2037
+ );
2038
+ const patch = {};
2039
+ if (failed && !ev.isError) patch.isError = true;
2040
+ if (ev.isError && !isSubagentDetails(ev.details) && fromMap) patch.details = fromMap;
2041
+ return patch.isError !== void 0 || patch.details !== void 0 ? patch : void 0;
2042
+ });
2043
+ };
2044
+ function normalizeCalls(params) {
2045
+ const hasSingle = params.agent !== void 0 || params.task !== void 0;
2046
+ const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
2047
+ if (hasSingle && hasTasks) {
2048
+ return "Pass EITHER `agent` + `task` (single delegation) OR `tasks` (parallel fan-out) \u2014 not both in one call.";
2049
+ }
2050
+ if (hasTasks) {
2051
+ const list = params.tasks;
2052
+ if (list.length > MAX_TASKS_PER_CALL) {
2053
+ return `tasks[] is capped at ${MAX_TASKS_PER_CALL} per call (got ${list.length}). Split the fan-out into multiple subagent calls.`;
2054
+ }
2055
+ if (list.some((t) => !t?.agent?.trim() || !t?.task?.trim())) {
2056
+ return "Every tasks[] entry needs a non-empty `agent` and `task`.";
2057
+ }
2058
+ return { mode: "parallel", briefs: list.map((t) => ({ agent: t.agent, task: t.task })) };
2059
+ }
2060
+ if (params.agent?.trim() && params.task?.trim()) {
2061
+ return { mode: "single", briefs: [{ agent: params.agent, task: params.task }] };
2062
+ }
2063
+ return "Provide either `agent` + `task` (single delegation) or `tasks` (parallel fan-out of independent briefs).";
2064
+ }
2065
+ function invalidCallResult(text, params) {
2066
+ return {
2067
+ content: [{ type: "text", text }],
2068
+ details: {
2069
+ version: 1,
2070
+ mode: Array.isArray(params.tasks) && params.tasks.length > 0 ? "parallel" : "single",
2071
+ tasks: []
2072
+ }
2073
+ };
2074
+ }
2075
+ function composeChildPrompt(agent) {
2076
+ 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.`;
2077
+ return agent.systemPrompt ? `${header}
2078
+
2079
+ ${agent.systemPrompt}` : header;
2080
+ }
2081
+ function mergeOutcome(task, outcome) {
2082
+ task.status = outcome.status;
2083
+ task.usage = outcome.usage;
2084
+ task.activity = outcome.activity;
2085
+ task.model = outcome.model ?? task.model;
2086
+ task.stopReason = outcome.stopReason;
2087
+ task.errorMessage = outcome.errorMessage;
2088
+ task.stderrTail = outcome.stderrTail ? outcome.stderrTail.slice(-STDERR_DETAILS_CAP) : void 0;
2089
+ task.exitCode = outcome.exitCode;
2090
+ task.durationMs = outcome.durationMs;
2091
+ task.transcriptPath = outcome.transcriptPath;
2092
+ task.finalPreview = outcome.finalText ? tailCap(outcome.finalText, PREVIEW_CAP) : void 0;
2093
+ }
2094
+ function combinedParentText(mode, resolved, outcomes) {
2095
+ if (mode === "single") {
2096
+ return formatParentText(resolved[0].agent, outcomes[0], TASK_OUTPUT_CAP);
2097
+ }
2098
+ const n = outcomes.length;
2099
+ const perTaskCap = Math.min(TASK_OUTPUT_CAP, Math.floor(AGGREGATE_OUTPUT_CAP / n));
2100
+ const counts = /* @__PURE__ */ new Map();
2101
+ for (const o of outcomes) {
2102
+ const s = o?.status ?? "aborted";
2103
+ counts.set(s, (counts.get(s) ?? 0) + 1);
2104
+ }
2105
+ const summary = [...counts.entries()].map(([s, c]) => `${c} ${s}`).join(", ");
2106
+ const sections = outcomes.map((o, i) => {
2107
+ const head = `=== task ${i + 1}/${n} ===`;
2108
+ const body = o ? formatParentText(resolved[i].agent, o, perTaskCap) : `[${resolved[i].agent.name}] never started (aborted while queued)`;
2109
+ return `${head}
2110
+ ${body}`;
2111
+ });
2112
+ return [`[subagent] ${n} parallel tasks: ${summary}`, ...sections].join("\n\n");
2113
+ }
2114
+ function formatParentText(agent, outcome, outputCap) {
2115
+ 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)}`;
2116
+ if (outcome.status === "done") {
2117
+ const body = outcome.finalText || "(the subagent produced no final text)";
2118
+ return `[${agent.name}] done in ${stats}
2119
+
2120
+ ${tailCap(body, outputCap)}`;
2121
+ }
2122
+ const head = outcome.status === "timeout" ? `[${agent.name}] TIMED OUT after ${stats}` : `[${agent.name}] FAILED (stopReason=${outcome.stopReason ?? "?"}, exit ${outcome.exitCode}) after ${stats}`;
2123
+ const parts = [head];
2124
+ if (outcome.errorMessage) parts.push(`error: ${outcome.errorMessage}`);
2125
+ if (outcome.finalText) parts.push(`partial output:
2126
+ ${tailCap(outcome.finalText, 2 * 1024)}`);
2127
+ if (outcome.stderrTail) parts.push(`stderr tail:
2128
+ ${outcome.stderrTail.slice(-STDERR_DETAILS_CAP)}`);
2129
+ if (outcome.transcriptPath) parts.push(`full transcript: ${outcome.transcriptPath}`);
2130
+ if (outcome.status === "timeout") {
2131
+ parts.push(
2132
+ "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."
2133
+ );
2134
+ }
2135
+ return parts.join("\n");
2136
+ }
2137
+ function tailCap(text, max) {
2138
+ if (text.length <= max) return text;
2139
+ const dropped = text.length - max;
2140
+ return `\u2026(${dropped} chars truncated \u2014 full transcript in details)
2141
+ ${text.slice(-max)}`;
2142
+ }
2143
+ function fmtTokens(n) {
2144
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
2145
+ }
2146
+ function makeThrottledEmitter(onUpdate, details) {
2147
+ let lastEmit = 0;
2148
+ let timer;
2149
+ let cancelled = false;
2150
+ const fire = () => {
2151
+ timer = void 0;
2152
+ lastEmit = Date.now();
2153
+ onUpdate?.({
2154
+ content: [{ type: "text", text: statusLine(details) }],
2155
+ details: structuredClone(details)
2156
+ });
2157
+ };
2158
+ return {
2159
+ emit: (force = false) => {
2160
+ if (cancelled || !onUpdate) return;
2161
+ const elapsed = Date.now() - lastEmit;
2162
+ if (force || elapsed >= UPDATE_THROTTLE_MS) {
2163
+ if (timer) {
2164
+ clearTimeout(timer);
2165
+ timer = void 0;
2166
+ }
2167
+ fire();
2168
+ } else if (!timer) {
2169
+ timer = setTimeout(fire, UPDATE_THROTTLE_MS - elapsed);
2170
+ }
2171
+ },
2172
+ cancel: () => {
2173
+ cancelled = true;
2174
+ if (timer) {
2175
+ clearTimeout(timer);
2176
+ timer = void 0;
2177
+ }
2178
+ }
2179
+ };
2180
+ }
2181
+ function statusLine(details) {
2182
+ if (details.tasks.length > 1) {
2183
+ const counts = /* @__PURE__ */ new Map();
2184
+ let cost = 0;
2185
+ let turns = 0;
2186
+ for (const t2 of details.tasks) {
2187
+ counts.set(t2.status, (counts.get(t2.status) ?? 0) + 1);
2188
+ cost += t2.usage.cost;
2189
+ turns += t2.usage.turns;
2190
+ }
2191
+ const bits2 = [`${details.tasks.length} tasks`];
2192
+ for (const status2 of ["running", "queued", "done", "failed", "timeout", "aborted"]) {
2193
+ const c = counts.get(status2);
2194
+ if (c) bits2.push(`${c} ${status2}`);
2195
+ }
2196
+ if (turns > 0) bits2.push(`$${cost.toFixed(3)}`);
2197
+ return bits2.join(" \xB7 ");
2198
+ }
2199
+ const t = details.tasks[0];
2200
+ if (!t) return "subagent";
2201
+ const bits = [t.agent, t.status];
2202
+ if (t.usage.turns > 0) {
2203
+ bits.push(`${t.usage.turns} turns`);
2204
+ bits.push(`${fmtTokens(t.usage.input + t.usage.output)} tok`);
2205
+ bits.push(`$${t.usage.cost.toFixed(3)}`);
2206
+ }
2207
+ const last = t.activity[t.activity.length - 1];
2208
+ if (last && t.status === "running") bits.push(last.label);
2209
+ return bits.join(" \xB7 ");
2210
+ }
2211
+
2212
+ // src/extensions/web_search/schema.ts
2213
+ import { Type as Type5 } from "typebox";
2214
+ var webSearchParamsSchema = Type5.Object({
2215
+ query: Type5.String({
2216
+ description: "The search query. Phrase it like a search-engine query (keywords, entities), not a chat sentence."
2217
+ }),
2218
+ max_results: Type5.Optional(
2219
+ Type5.Number({
2220
+ description: "How many results to return (1\u201310). Default 5.",
2221
+ minimum: 1,
2222
+ maximum: 10
2223
+ })
2224
+ ),
2225
+ topic: Type5.Optional(
2226
+ Type5.Union([Type5.Literal("general"), Type5.Literal("news")], {
2227
+ description: 'Search topic. Use "news" for recent/current events. Default "general".'
2228
+ })
2229
+ ),
2230
+ search_depth: Type5.Optional(
2231
+ Type5.Union([Type5.Literal("basic"), Type5.Literal("advanced")], {
2232
+ description: '"advanced" digs deeper (slower, costs more credits); "basic" is usually enough. Default "basic".'
2233
+ })
2234
+ )
2235
+ });
2236
+ var webFetchParamsSchema = Type5.Object({
2237
+ urls: Type5.Array(
2238
+ Type5.String({ description: "An absolute http(s) URL." }),
2239
+ {
2240
+ description: "URLs to fetch and extract the main text from (1\u20135).",
2241
+ minItems: 1,
2242
+ maxItems: 5
2243
+ }
2244
+ )
2245
+ });
2246
+
2247
+ // src/storage/web-search-prefs.ts
2248
+ import { readFile as readFile4 } from "fs/promises";
2249
+ import { join as join9 } from "path";
2250
+ var PREFS_PATH2 = join9(config.dataDir, "web-search.json");
2251
+ var cache4 = {};
2252
+ async function loadWebSearchPrefs() {
2253
+ try {
2254
+ const raw = await readFile4(PREFS_PATH2, "utf8");
2255
+ const parsed = JSON.parse(raw);
2256
+ cache4 = { tavilyApiKey: typeof parsed.tavilyApiKey === "string" ? parsed.tavilyApiKey : void 0 };
2257
+ } catch (err2) {
2258
+ cache4 = {};
2259
+ if (err2.code !== "ENOENT") {
2260
+ console.warn(`[web-search-prefs] ignoring unreadable ${PREFS_PATH2}:`, err2);
2261
+ }
2262
+ }
2263
+ }
2264
+ function getTavilyApiKey() {
2265
+ const fromSettings = cache4.tavilyApiKey?.trim();
2266
+ if (fromSettings) return fromSettings;
2267
+ const fromEnv = process.env.TAVILY_API_KEY?.trim();
2268
+ return fromEnv || void 0;
2269
+ }
2270
+ function getKeyStatus() {
2271
+ const fromSettings = cache4.tavilyApiKey?.trim();
2272
+ const fromEnv = process.env.TAVILY_API_KEY?.trim();
2273
+ const live = fromSettings || fromEnv || void 0;
2274
+ const source = fromSettings ? "settings" : fromEnv ? "env" : "none";
2275
+ return {
2276
+ configured: Boolean(live),
2277
+ source,
2278
+ ...live ? { hint: maskKey(live) } : {}
2279
+ };
2280
+ }
2281
+ function maskKey(key) {
2282
+ return `\u2026${key.slice(-4)}`;
2283
+ }
2284
+ async function setTavilyApiKey(key) {
2285
+ const trimmed = key.trim();
2286
+ cache4 = trimmed ? { tavilyApiKey: trimmed } : {};
2287
+ await save3();
2288
+ }
2289
+ async function clearTavilyApiKey() {
2290
+ cache4 = {};
2291
+ await save3();
2292
+ }
2293
+ async function save3() {
2294
+ await writeJsonAtomic(PREFS_PATH2, cache4, { mode: 384 });
2295
+ }
2296
+
2297
+ // src/extensions/web_search/client.ts
2298
+ var DEFAULT_TIMEOUT_MS = 3e4;
2299
+ function baseUrl() {
2300
+ return process.env.TAVILY_BASE_URL ?? "https://api.tavily.com";
2301
+ }
2302
+ var WebSearchError = class extends Error {
2303
+ constructor(message) {
2304
+ super(message);
2305
+ this.name = "WebSearchError";
2306
+ }
2307
+ };
2308
+ function apiKey() {
2309
+ const key = getTavilyApiKey();
2310
+ if (!key) {
2311
+ throw new WebSearchError(
2312
+ "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."
2313
+ );
2314
+ }
2315
+ return key;
2316
+ }
2317
+ async function postJson(path, body, signal) {
2318
+ const key = apiKey();
2319
+ const ctl = new AbortController();
2320
+ const onAbort = () => ctl.abort(signal?.reason);
2321
+ if (signal) {
2322
+ if (signal.aborted) ctl.abort(signal.reason);
2323
+ else signal.addEventListener("abort", onAbort, { once: true });
2324
+ }
2325
+ const timer = setTimeout(
2326
+ () => ctl.abort(new WebSearchError(`Tavily request timed out after ${DEFAULT_TIMEOUT_MS / 1e3}s.`)),
2327
+ DEFAULT_TIMEOUT_MS
2328
+ );
2329
+ let res;
2330
+ try {
2331
+ res = await fetch(`${baseUrl()}${path}`, {
2332
+ method: "POST",
2333
+ headers: {
2334
+ "content-type": "application/json",
2335
+ authorization: `Bearer ${key}`
2336
+ },
2337
+ body: JSON.stringify(body),
2338
+ signal: ctl.signal
2339
+ });
2340
+ } catch (err2) {
2341
+ if (signal?.aborted) throw err2;
2342
+ const reason = ctl.signal.reason;
2343
+ if (reason instanceof WebSearchError) throw reason;
2344
+ throw new WebSearchError(`Tavily request failed: ${errMsg(err2)}`);
2345
+ } finally {
2346
+ clearTimeout(timer);
2347
+ if (signal) signal.removeEventListener("abort", onAbort);
2348
+ }
2349
+ if (!res.ok) {
2350
+ throw new WebSearchError(await describeHttpError(res));
2351
+ }
2352
+ try {
2353
+ return await res.json();
2354
+ } catch {
2355
+ throw new WebSearchError(
2356
+ `Tavily returned a non-JSON response (HTTP ${res.status}) \u2014 the service may be down or returning an error page.`
2357
+ );
2358
+ }
2359
+ }
2360
+ async function describeHttpError(res) {
2361
+ let detail = "";
2362
+ try {
2363
+ const data = await res.json();
2364
+ const d = data?.detail ?? data?.error;
2365
+ if (typeof d === "string") detail = d;
2366
+ else if (d && typeof d === "object" && typeof d.error === "string") {
2367
+ detail = d.error;
2368
+ }
2369
+ } catch {
2370
+ }
2371
+ const suffix = detail ? `: ${detail}` : "";
2372
+ if (res.status === 401 || res.status === 403) {
2373
+ return `Tavily rejected the API key (HTTP ${res.status})${suffix}. Check TAVILY_API_KEY.`;
2374
+ }
2375
+ if (res.status === 429) {
2376
+ return `Tavily rate limit / quota exceeded (HTTP 429)${suffix}.`;
2377
+ }
2378
+ return `Tavily request failed (HTTP ${res.status})${suffix}.`;
2379
+ }
2380
+ function errMsg(err2) {
2381
+ return err2 instanceof Error ? err2.message : String(err2);
2382
+ }
2383
+ function tavilySearch(opts, signal) {
2384
+ return postJson(
2385
+ "/search",
2386
+ {
2387
+ query: opts.query,
2388
+ max_results: opts.maxResults,
2389
+ topic: opts.topic,
2390
+ search_depth: opts.searchDepth,
2391
+ include_answer: true
2392
+ },
2393
+ signal
2394
+ );
2395
+ }
2396
+ function tavilyExtract(urls, signal) {
2397
+ return postJson("/extract", { urls }, signal);
2398
+ }
2399
+
2400
+ // src/extensions/web_search/factory.ts
2401
+ var SNIPPET_CARD_CAP = 320;
2402
+ var SNIPPET_MODEL_CAP = 1024;
2403
+ var FETCH_PREVIEW_CAP = 2 * 1024;
2404
+ var FETCH_PER_URL_CAP = 8 * 1024;
2405
+ var FETCH_TOTAL_CAP = 24 * 1024;
2406
+ function clampInt(v, lo, hi, dflt) {
2407
+ if (typeof v !== "number" || !Number.isFinite(v)) return dflt;
2408
+ return Math.max(lo, Math.min(hi, Math.round(v)));
2409
+ }
2410
+ function truncate(s, cap) {
2411
+ return s.length <= cap ? s : `${s.slice(0, cap)}\u2026`;
2412
+ }
2413
+ var webSearchExtensionFactory = (pi) => {
2414
+ pi.registerTool({
2415
+ name: "web_search",
2416
+ label: "Web search",
2417
+ 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.",
2418
+ parameters: webSearchParamsSchema,
2419
+ executionMode: "parallel",
2420
+ promptSnippet: "web_search: search the web (answer + ranked results) for current or uncertain facts; pair with web_fetch to read a page in full.",
2421
+ promptGuidelines: [
2422
+ "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.",
2423
+ 'Phrase `query` like a search-engine query (keywords, entities), not a chat sentence. Set `topic: "news"` for recent events.',
2424
+ "Cite the URLs you relied on, and use web_fetch to read a page in full before trusting details beyond the snippet."
2425
+ ],
2426
+ async execute(_toolCallId, params, signal) {
2427
+ const query = params.query.trim();
2428
+ if (!query) throw new WebSearchError("web_search needs a non-empty query.");
2429
+ const maxResults = clampInt(params.max_results, 1, 10, 5);
2430
+ const topic = params.topic === "news" ? "news" : "general";
2431
+ const searchDepth = params.search_depth === "advanced" ? "advanced" : "basic";
2432
+ const data = await tavilySearch({ query, maxResults, topic, searchDepth }, signal);
2433
+ const raw = (data.results ?? []).filter(
2434
+ (r) => typeof r.url === "string"
2435
+ );
2436
+ const answer = typeof data.answer === "string" && data.answer.trim() ? data.answer.trim() : void 0;
2437
+ const results = raw.map((r) => ({
2438
+ title: (r.title ?? "").trim() || r.url,
2439
+ url: r.url,
2440
+ content: truncate((r.content ?? "").trim(), SNIPPET_CARD_CAP),
2441
+ score: typeof r.score === "number" ? r.score : void 0,
2442
+ publishedDate: typeof r.published_date === "string" ? r.published_date : void 0
2443
+ }));
2444
+ const details = { version: 1, kind: "search", query, answer, results };
2445
+ return { content: [{ type: "text", text: formatSearch(query, answer, raw) }], details };
2446
+ }
2447
+ });
2448
+ pi.registerTool({
2449
+ name: "web_fetch",
2450
+ label: "Web fetch",
2451
+ 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.",
2452
+ parameters: webFetchParamsSchema,
2453
+ executionMode: "parallel",
2454
+ 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.",
2455
+ promptGuidelines: [
2456
+ "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.",
2457
+ "Pass up to 5 absolute http(s) URLs in one call to read them together."
2458
+ ],
2459
+ async execute(_toolCallId, params, signal) {
2460
+ const urls = params.urls.map((u) => u.trim()).filter(Boolean);
2461
+ if (urls.length === 0) throw new WebSearchError("web_fetch needs at least one non-empty URL.");
2462
+ const data = await tavilyExtract(urls, signal);
2463
+ const results = (data.results ?? []).filter((r) => typeof r.url === "string").map((r) => {
2464
+ const full = (r.raw_content ?? "").trim();
2465
+ return { url: r.url, content: truncate(full, FETCH_PREVIEW_CAP), chars: full.length };
2466
+ });
2467
+ const failed = (data.failed_results ?? []).filter((f) => typeof f.url === "string").map((f) => ({ url: f.url, error: (f.error ?? "extraction failed").toString() }));
2468
+ const details = { version: 1, kind: "fetch", results, failed };
2469
+ return { content: [{ type: "text", text: formatFetch(data) }], details };
2470
+ }
2471
+ });
2472
+ };
2473
+ function formatSearch(query, answer, results) {
2474
+ const lines = [];
2475
+ lines.push(`Search results for "${query}" (${results.length} result${results.length === 1 ? "" : "s"}):`);
2476
+ if (answer) {
2477
+ lines.push("");
2478
+ lines.push(`Answer: ${answer}`);
2479
+ }
2480
+ results.forEach((r, i) => {
2481
+ lines.push("");
2482
+ lines.push(`${i + 1}. ${(r.title ?? "").trim() || r.url}`);
2483
+ lines.push(` ${r.url}`);
2484
+ const snippet = (r.content ?? "").trim();
2485
+ if (snippet) lines.push(` ${truncate(snippet, SNIPPET_MODEL_CAP)}`);
2486
+ });
2487
+ if (results.length === 0) {
2488
+ lines.push("");
2489
+ lines.push("No results found.");
2490
+ }
2491
+ return lines.join("\n");
2492
+ }
2493
+ function formatFetch(data) {
2494
+ const results = (data.results ?? []).filter(
2495
+ (r) => typeof r.url === "string"
2496
+ );
2497
+ const failed = (data.failed_results ?? []).filter(
2498
+ (f) => typeof f.url === "string"
2499
+ );
2500
+ const lines = [];
2501
+ let budget = FETCH_TOTAL_CAP;
2502
+ for (const r of results) {
2503
+ const full = (r.raw_content ?? "").trim();
2504
+ const cap = Math.min(FETCH_PER_URL_CAP, budget);
2505
+ const slice = full.length > cap ? `${full.slice(0, cap)}\u2026` : full;
2506
+ budget -= Math.min(full.length, cap);
2507
+ lines.push(`## ${r.url}`);
2508
+ lines.push(slice || "(no extractable content)");
2509
+ lines.push("");
2510
+ if (budget <= 0) {
2511
+ lines.push("\u2026 (remaining pages omitted to fit the context budget)");
2512
+ break;
2513
+ }
2514
+ }
2515
+ for (const f of failed) {
2516
+ lines.push(`Failed to fetch ${f.url}: ${f.error ?? "extraction failed"}`);
2517
+ }
2518
+ if (results.length === 0 && failed.length === 0) lines.push("No content extracted.");
2519
+ return lines.join("\n").trim();
2520
+ }
2521
+
2522
+ // src/storage/builtin-extension-prefs.ts
2523
+ import { readFile as readFile5 } from "fs/promises";
2524
+ import { join as join10 } from "path";
2525
+ var PREFS_PATH3 = join10(config.dataDir, "builtin-extensions.json");
2526
+ var cache5 = { disabled: [] };
2527
+ async function loadBuiltinPrefs() {
2528
+ try {
2529
+ const raw = await readFile5(PREFS_PATH3, "utf8");
2530
+ const parsed = JSON.parse(raw);
2531
+ cache5 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
2532
+ } catch (err2) {
2533
+ cache5 = { disabled: [] };
2534
+ if (err2.code !== "ENOENT") {
2535
+ console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH3}:`, err2);
2536
+ }
2537
+ }
2538
+ }
2539
+ function isBuiltinDisabled(id) {
2540
+ if (cache5.disabled.includes(id)) return true;
2541
+ if (id === "todo" && cache5.disabled.includes("plan")) return true;
2542
+ return false;
2543
+ }
2544
+ function getDisabledBuiltins() {
2545
+ return [...cache5.disabled];
2546
+ }
2547
+ async function setBuiltinEnabled(id, enabled) {
2548
+ const next = new Set(cache5.disabled);
2549
+ if (enabled) next.delete(id);
2550
+ else next.add(id);
2551
+ cache5 = { disabled: [...next] };
2552
+ await save4();
2553
+ }
2554
+ async function save4() {
2555
+ await writeJsonAtomic(PREFS_PATH3, cache5);
2556
+ }
2557
+
2558
+ // src/extensions/index.ts
2559
+ var BUILTIN_EXTENSIONS = [
2560
+ {
2561
+ id: "todo",
2562
+ name: "Todo",
2563
+ description: "A CRUD task list for tracking multi-step work \u2014 adds the todo tool and the /todos command.",
2564
+ tools: ["todo"],
2565
+ commands: ["todos"],
2566
+ factory: todoExtensionFactory
2567
+ },
2568
+ {
2569
+ id: "ask_user",
2570
+ name: "Ask user",
2571
+ description: "Lets the agent pause and ask you a structured multiple-choice question \u2014 adds the ask_user tool.",
2572
+ tools: ["ask_user"],
2573
+ commands: [],
2574
+ factory: askUserExtensionFactory
2575
+ },
2576
+ {
2577
+ id: "artifact",
2578
+ name: "Artifacts",
2579
+ description: "Lets the agent publish substantial, self-contained content \u2014 web pages, SVG diagrams, documents, code files \u2014 as versioned artifacts rendered in a side panel. Adds the create_artifact tool.",
2580
+ tools: ["create_artifact"],
2581
+ commands: [],
2582
+ factory: artifactExtensionFactory
2583
+ },
2584
+ {
2585
+ id: "subagent",
2586
+ name: "Subagent",
2587
+ 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.",
2588
+ tools: ["subagent"],
2589
+ commands: [],
2590
+ factory: subagentExtensionFactory
2591
+ },
2592
+ {
2593
+ id: "web",
2594
+ name: "Web search",
2595
+ 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).",
2596
+ tools: ["web_search", "web_fetch"],
2597
+ commands: [],
2598
+ factory: webSearchExtensionFactory
2599
+ }
2600
+ ];
2601
+ function gate(def) {
2602
+ return (pi) => {
2603
+ if (isBuiltinDisabled(def.id)) return;
2604
+ return def.factory(pi);
2605
+ };
2606
+ }
2607
+ var builtinExtensionFactories = BUILTIN_EXTENSIONS.map(gate);
2608
+
2609
+ // src/extensions/ask_user/cleanup.ts
2610
+ var CUSTOM_TYPE = "ask_user-restart-cancelled";
2611
+ function reconcileAfterRestart(sessionManager) {
2612
+ const branch = sessionManager.getBranch();
2613
+ if (branch.length === 0) return;
2614
+ const satisfied = /* @__PURE__ */ new Set();
2615
+ const danglingIds = [];
2616
+ const danglingAlreadyHandled = /* @__PURE__ */ new Set();
2617
+ for (let i = branch.length - 1; i >= 0; i--) {
2618
+ const entry = branch[i];
2619
+ if (entry.type === "custom_message") {
2620
+ const cm = entry;
2621
+ if (cm.customType === CUSTOM_TYPE) {
2622
+ const ids = cm.details?.ids;
2623
+ if (Array.isArray(ids)) {
2624
+ for (const id of ids) {
2625
+ if (typeof id === "string") danglingAlreadyHandled.add(id);
2626
+ }
2627
+ }
2628
+ }
2629
+ continue;
2630
+ }
2631
+ if (entry.type !== "message") continue;
2632
+ const msg = entry.message;
2633
+ if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
2634
+ satisfied.add(msg.toolCallId);
2635
+ continue;
2636
+ }
2637
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
2638
+ for (const block of msg.content) {
2639
+ if (!block || typeof block !== "object") continue;
2640
+ const b = block;
2641
+ if (b.type !== "toolCall") continue;
2642
+ if (b.name !== "ask_user") continue;
2643
+ if (typeof b.id !== "string") continue;
2644
+ if (satisfied.has(b.id)) continue;
2645
+ if (danglingAlreadyHandled.has(b.id)) continue;
2646
+ danglingIds.push(b.id);
2647
+ }
2648
+ break;
2649
+ }
2650
+ if (msg.role === "user") break;
2651
+ }
2652
+ if (danglingIds.length === 0) return;
2653
+ const idList = danglingIds.join(", ");
2654
+ const text = `[pi-pilot] Your previous ask_user call(s) [${idList}] were cancelled because the server restarted before the user answered. Use your best judgement and proceed; you may re-call ask_user if the decision still matters.`;
2655
+ sessionManager.appendCustomMessageEntry(
2656
+ CUSTOM_TYPE,
2657
+ text,
2658
+ true,
2659
+ // display in TUI too (no-op in pi-pilot, but harmless)
2660
+ { ids: danglingIds }
2661
+ );
2662
+ }
2663
+
2664
+ // src/extensions/subagent/cleanup.ts
2665
+ import { execFile as execFile3 } from "child_process";
2666
+ import { readdir as readdir2, readFile as readFile6, rm as rm4 } from "fs/promises";
2667
+ import { tmpdir as tmpdir3 } from "os";
2668
+ import { join as join11 } from "path";
2669
+ var CUSTOM_TYPE2 = "subagent-restart-cancelled";
2670
+ function reconcileAfterRestart2(sessionManager) {
2671
+ const branch = sessionManager.getBranch();
2672
+ if (branch.length === 0) return;
2673
+ const satisfied = /* @__PURE__ */ new Set();
2674
+ const danglingIds = [];
2675
+ const danglingAlreadyHandled = /* @__PURE__ */ new Set();
2676
+ for (let i = branch.length - 1; i >= 0; i--) {
2677
+ const entry = branch[i];
2678
+ if (entry.type === "custom_message") {
2679
+ const cm = entry;
2680
+ if (cm.customType === CUSTOM_TYPE2) {
2681
+ const ids = cm.details?.ids;
2682
+ if (Array.isArray(ids)) {
2683
+ for (const id of ids) {
2684
+ if (typeof id === "string") danglingAlreadyHandled.add(id);
2685
+ }
2686
+ }
2687
+ }
2688
+ continue;
2689
+ }
2690
+ if (entry.type !== "message") continue;
2691
+ const msg = entry.message;
2692
+ if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
2693
+ satisfied.add(msg.toolCallId);
2694
+ continue;
2695
+ }
2696
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
2697
+ for (const block of msg.content) {
2698
+ if (!block || typeof block !== "object") continue;
2699
+ const b = block;
2700
+ if (b.type !== "toolCall") continue;
2701
+ if (b.name !== "subagent") continue;
2702
+ if (typeof b.id !== "string") continue;
2703
+ if (satisfied.has(b.id)) continue;
2704
+ if (danglingAlreadyHandled.has(b.id)) continue;
2705
+ danglingIds.push(b.id);
2706
+ }
2707
+ break;
2708
+ }
2709
+ if (msg.role === "user") break;
2710
+ }
2711
+ if (danglingIds.length === 0) return;
2712
+ const idList = danglingIds.join(", ");
2713
+ 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.`;
2714
+ sessionManager.appendCustomMessageEntry(
2715
+ CUSTOM_TYPE2,
2716
+ text,
2717
+ true,
2718
+ // display flag — no-op in pi-pilot, harmless in the TUI
2719
+ { ids: danglingIds }
2720
+ );
2721
+ }
2722
+ async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir3()) {
2723
+ let dirNames;
2724
+ try {
2725
+ dirNames = (await readdir2(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
2726
+ } catch {
2727
+ return;
2728
+ }
2729
+ let swept = 0;
2730
+ for (const name of dirNames) {
2731
+ const dir = join11(rootDir, name);
2732
+ const pidRaw = await readFile6(join11(dir, "pid"), "utf8").catch(() => "");
2733
+ const [childLine, ownerLine] = pidRaw.split("\n");
2734
+ const childPid = Number.parseInt((childLine ?? "").trim(), 10);
2735
+ const ownerPid = Number.parseInt((ownerLine ?? "").trim(), 10);
2736
+ if (Number.isInteger(ownerPid) && ownerPid > 1 && await isLiveNodeProcess(ownerPid)) {
2737
+ continue;
2738
+ }
2739
+ try {
2740
+ if (Number.isInteger(childPid) && childPid > 1 && await isLiveNodeProcess(childPid)) {
2741
+ try {
2742
+ process.kill(childPid, "SIGTERM");
2743
+ swept++;
2744
+ } catch {
2745
+ }
2746
+ }
2747
+ } finally {
2748
+ await rm4(dir, { recursive: true, force: true }).catch(() => {
2749
+ });
2750
+ }
2751
+ }
2752
+ if (swept > 0) {
2753
+ console.warn(`[subagent] swept ${swept} orphaned child(ren) from a previous run`);
2754
+ }
2755
+ }
2756
+ function isLiveNodeProcess(pid) {
2757
+ try {
2758
+ process.kill(pid, 0);
2759
+ } catch {
2760
+ return Promise.resolve(false);
2761
+ }
2762
+ return new Promise((resolve9) => {
2763
+ execFile3("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
2764
+ if (err2) {
2765
+ resolve9(false);
2766
+ return;
2767
+ }
2768
+ const name = stdout.trim().toLowerCase();
2769
+ resolve9(name === "node" || name === "pi");
2770
+ });
2771
+ });
2772
+ }
2773
+
2774
+ // src/ws/bridge.ts
2775
+ function translatePiEvent(ev) {
2776
+ switch (ev.type) {
2777
+ case "agent_start":
2778
+ return { kind: "agent_start" };
2779
+ case "agent_end": {
2780
+ const error = ev.willRetry ? void 0 : finalAssistantError(ev.messages);
2781
+ return { kind: "agent_end", willRetry: ev.willRetry, ...error ? { error } : {} };
2782
+ }
1164
2783
  case "turn_start":
1165
2784
  return { kind: "turn_start" };
1166
2785
  case "turn_end":
@@ -1195,13 +2814,16 @@ function translatePiEvent(ev) {
1195
2814
  toolName: ev.toolName,
1196
2815
  args: ev.args
1197
2816
  };
1198
- case "tool_execution_update":
2817
+ case "tool_execution_update": {
2818
+ const updateDetails = shouldForwardDetails(ev.toolName) ? ev.partialResult?.details : void 0;
1199
2819
  return {
1200
2820
  kind: "tool_execution_update",
1201
2821
  toolCallId: ev.toolCallId,
1202
2822
  toolName: ev.toolName,
1203
- partialText: extractText(ev.partialResult)
2823
+ partialText: extractText(ev.partialResult),
2824
+ ...updateDetails !== void 0 ? { details: updateDetails } : {}
1204
2825
  };
2826
+ }
1205
2827
  case "tool_execution_end": {
1206
2828
  const details = shouldForwardDetails(ev.toolName) ? ev.result?.details : void 0;
1207
2829
  return {
@@ -1252,6 +2874,21 @@ function translatePiEvent(ev) {
1252
2874
  return void 0;
1253
2875
  }
1254
2876
  }
2877
+ function assistantErrorText(message) {
2878
+ const m = message;
2879
+ if (!m || m.stopReason !== "error") return void 0;
2880
+ const text = typeof m.errorMessage === "string" ? m.errorMessage.trim() : "";
2881
+ return text || "The model stream ended with an error.";
2882
+ }
2883
+ function finalAssistantError(messages) {
2884
+ if (!Array.isArray(messages)) return void 0;
2885
+ for (let i = messages.length - 1; i >= 0; i--) {
2886
+ const m = messages[i];
2887
+ if (!m || m.role !== "assistant") continue;
2888
+ return assistantErrorText(m);
2889
+ }
2890
+ return void 0;
2891
+ }
1255
2892
  var warnedUnknownRoles = /* @__PURE__ */ new Set();
1256
2893
  function roleOf(message) {
1257
2894
  const role = message?.role;
@@ -1354,22 +2991,119 @@ function inFlightToolCallsSnapshot(sessionFile) {
1354
2991
  args: p.args
1355
2992
  }));
1356
2993
  }
1357
- var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set(["ask_user", "todo"]);
2994
+ var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
2995
+ "ask_user",
2996
+ "todo",
2997
+ "subagent",
2998
+ "web_search",
2999
+ "web_fetch"
3000
+ ]);
1358
3001
  function shouldForwardDetails(toolName) {
1359
3002
  return DETAILS_FORWARD_WHITELIST.has(toolName);
1360
3003
  }
1361
- function extractText(result) {
1362
- if (!result || typeof result !== "object") return void 0;
1363
- const content = result.content;
1364
- if (!Array.isArray(content)) return void 0;
3004
+ function extractText(result) {
3005
+ if (!result || typeof result !== "object") return void 0;
3006
+ const content = result.content;
3007
+ if (!Array.isArray(content)) return void 0;
3008
+ const parts = [];
3009
+ for (const c of content) {
3010
+ if (c && typeof c === "object" && c.type === "text") {
3011
+ const text = c.text;
3012
+ if (typeof text === "string") parts.push(text);
3013
+ }
3014
+ }
3015
+ return parts.length === 0 ? void 0 : parts.join("");
3016
+ }
3017
+
3018
+ // src/history-builder.ts
3019
+ function isAssistantSupersededByRetry(branch, index) {
3020
+ for (let j = index + 1; j < branch.length; j++) {
3021
+ const e = branch[j];
3022
+ if (e?.type !== "message") continue;
3023
+ return e.message?.role === "assistant";
3024
+ }
3025
+ return false;
3026
+ }
3027
+ function buildHistoryItems(branch) {
3028
+ const items = [];
3029
+ const argsByCallId = /* @__PURE__ */ new Map();
3030
+ for (let i = 0; i < branch.length; i++) {
3031
+ const entry = branch[i];
3032
+ if (!entry || entry.type !== "message") continue;
3033
+ const msg = entry.message;
3034
+ const role = msg.role;
3035
+ if (role === "user") {
3036
+ const text = extractUserText2(msg);
3037
+ if (text) items.push({ kind: "user", text, entryId: entry.id ?? "" });
3038
+ } else if (role === "assistant") {
3039
+ const { text, thinking, toolCalls } = extractAssistantContent(
3040
+ msg
3041
+ );
3042
+ for (const tc of toolCalls) {
3043
+ argsByCallId.set(tc.id, tc.args);
3044
+ }
3045
+ const error = isAssistantSupersededByRetry(branch, i) ? void 0 : assistantErrorText(msg);
3046
+ if (text || thinking || error) {
3047
+ items.push({ kind: "assistant", text, thinking, ...error ? { error } : {} });
3048
+ }
3049
+ } else if (role === "toolResult") {
3050
+ const tr = msg;
3051
+ items.push({
3052
+ kind: "tool",
3053
+ toolCallId: tr.toolCallId,
3054
+ toolName: tr.toolName,
3055
+ args: argsByCallId.get(tr.toolCallId) ?? "",
3056
+ text: extractContentText(tr.content),
3057
+ isError: tr.isError,
3058
+ // Mirror live wire whitelist (bridge.ts): only ship details for
3059
+ // tools whose cards need the structured shape, so the history
3060
+ // payload stays small for bash / edit / read.
3061
+ ...shouldForwardDetails(tr.toolName) && tr.details !== void 0 ? { details: tr.details } : {}
3062
+ });
3063
+ } else if (role === "bashExecution") {
3064
+ const be = msg;
3065
+ items.push({
3066
+ kind: "bash",
3067
+ command: be.command,
3068
+ output: be.output,
3069
+ exitCode: be.exitCode
3070
+ });
3071
+ }
3072
+ }
3073
+ return items;
3074
+ }
3075
+ function extractUserText2(msg) {
3076
+ if (typeof msg.content === "string") return msg.content;
3077
+ return extractContentText(msg.content);
3078
+ }
3079
+ function extractAssistantContent(msg) {
3080
+ const textParts = [];
3081
+ const thinkingParts = [];
3082
+ const toolCalls = [];
3083
+ for (const block of msg.content ?? []) {
3084
+ if (!block || typeof block !== "object") continue;
3085
+ const b = block;
3086
+ if (b.type === "text" && typeof b.text === "string") textParts.push(b.text);
3087
+ else if (b.type === "thinking" && typeof b.thinking === "string") thinkingParts.push(b.thinking);
3088
+ else if (b.type === "toolCall" && typeof b.id === "string") {
3089
+ toolCalls.push({
3090
+ id: b.id,
3091
+ args: b.arguments != null ? JSON.stringify(b.arguments) : ""
3092
+ });
3093
+ }
3094
+ }
3095
+ return { text: textParts.join(""), thinking: thinkingParts.join(""), toolCalls };
3096
+ }
3097
+ function extractContentText(content) {
3098
+ if (!Array.isArray(content)) return "";
1365
3099
  const parts = [];
1366
- for (const c of content) {
1367
- if (c && typeof c === "object" && c.type === "text") {
1368
- const text = c.text;
3100
+ for (const block of content) {
3101
+ if (block && typeof block === "object" && block.type === "text") {
3102
+ const text = block.text;
1369
3103
  if (typeof text === "string") parts.push(text);
1370
3104
  }
1371
3105
  }
1372
- return parts.length === 0 ? void 0 : parts.join("");
3106
+ return parts.join("");
1373
3107
  }
1374
3108
 
1375
3109
  // src/ws/extension-ui.ts
@@ -1503,7 +3237,7 @@ var SessionRuntimeManager = class {
1503
3237
  /** `runtimeKey` for a built runtime, from its session file (or sessionId). */
1504
3238
  keyForRuntime(workspaceId, runtime) {
1505
3239
  const file = runtime.session.sessionFile;
1506
- return this.keyOf(workspaceId, file ? resolve2(file) : runtime.session.sessionId);
3240
+ return this.keyOf(workspaceId, file ? resolve4(file) : runtime.session.sessionId);
1507
3241
  }
1508
3242
  /** Public so the WS hub derives the exact same key for a returned runtime. */
1509
3243
  runtimeKeyFor(workspaceId, runtime) {
@@ -1526,17 +3260,18 @@ var SessionRuntimeManager = class {
1526
3260
  async buildState(workspaceId, cwd, makeSessionManager) {
1527
3261
  const runtime = await createAgentSessionRuntime(createRuntime, {
1528
3262
  cwd,
1529
- agentDir: getAgentDir(),
3263
+ agentDir: getAgentDir2(),
1530
3264
  sessionManager: makeSessionManager()
1531
3265
  });
1532
3266
  const bridge = new ExtensionUIBridge();
1533
3267
  await this.bindExtensions(workspaceId, runtime, bridge);
1534
- safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
3268
+ reapplyToolPrefs(workspaceId, runtime.session);
3269
+ safeReconcileBuiltins(workspaceId, runtime.session.sessionManager);
1535
3270
  return {
1536
3271
  runtime,
1537
3272
  bridge,
1538
3273
  workspaceId,
1539
- sessionPath: runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : null,
3274
+ sessionPath: runtime.session.sessionFile ? resolve4(runtime.session.sessionFile) : null,
1540
3275
  touchedAt: ++this.touchSeq
1541
3276
  };
1542
3277
  }
@@ -1586,7 +3321,7 @@ ${err2.stack}` : "")
1586
3321
  if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1587
3322
  if (sessionPath) {
1588
3323
  if (!isAbsolute2(sessionPath)) throw new Error("Session path must be absolute");
1589
- const resolved = resolve2(sessionPath);
3324
+ const resolved = resolve4(sessionPath);
1590
3325
  const key = this.keyOf(workspaceId, resolved);
1591
3326
  const existing2 = this.runtimes.get(key);
1592
3327
  if (existing2) {
@@ -1649,7 +3384,7 @@ ${err2.stack}` : "")
1649
3384
  const state = await this.buildState(
1650
3385
  workspaceId,
1651
3386
  ws.path,
1652
- () => SessionManager.open(resolve2(sourceSessionPath), void 0, ws.path)
3387
+ () => SessionManager.open(resolve4(sourceSessionPath), void 0, ws.path)
1653
3388
  );
1654
3389
  let result;
1655
3390
  try {
@@ -1663,8 +3398,8 @@ ${err2.stack}` : "")
1663
3398
  return { cancelled: true };
1664
3399
  }
1665
3400
  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;
3401
+ safeReconcileBuiltins(workspaceId, state.runtime.session.sessionManager);
3402
+ state.sessionPath = state.runtime.session.sessionFile ? resolve4(state.runtime.session.sessionFile) : null;
1668
3403
  const winner = await this.adopt(state);
1669
3404
  return { cancelled: false, runtime: winner.runtime };
1670
3405
  }
@@ -1683,7 +3418,7 @@ ${err2.stack}` : "")
1683
3418
  }
1684
3419
  /** The runtime bound to a specific (workspace, session), if live. */
1685
3420
  getForSession(workspaceId, sessionPath) {
1686
- return this.runtimes.get(this.keyOf(workspaceId, resolve2(sessionPath)))?.runtime;
3421
+ return this.runtimes.get(this.keyOf(workspaceId, resolve4(sessionPath)))?.runtime;
1687
3422
  }
1688
3423
  /** Mark `runtime` as the active session for its workspace (hub on primary
1689
3424
  * bind), so per-workspace routes resolve to it. */
@@ -1724,8 +3459,8 @@ ${err2.stack}` : "")
1724
3459
  const ws = await getWorkspace(workspaceId);
1725
3460
  if (!ws) return `workspace not found: ${workspaceId}`;
1726
3461
  const sessions = await SessionManager.list(ws.path);
1727
- const resolved = resolve2(sessionPath);
1728
- const found = sessions.some((s) => resolve2(s.path) === resolved);
3462
+ const resolved = resolve4(sessionPath);
3463
+ const found = sessions.some((s) => resolve4(s.path) === resolved);
1729
3464
  if (!found) return `session not found in workspace: ${sessionPath}`;
1730
3465
  return null;
1731
3466
  }
@@ -1740,55 +3475,14 @@ ${err2.stack}` : "")
1740
3475
  }
1741
3476
  }
1742
3477
  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))));
3478
+ return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(resolve4(info.path))));
1744
3479
  }
1745
3480
  getSessionHistory(workspaceId, sessionPath) {
1746
3481
  const runtime = sessionPath ? this.getForSession(workspaceId, sessionPath) : this.get(workspaceId);
1747
3482
  if (!runtime) return { items: [], isStreaming: false };
1748
3483
  const isStreaming = runtime.session.isStreaming ?? false;
1749
3484
  const branch = runtime.session.sessionManager.getBranch();
1750
- const items = [];
1751
- const argsByCallId = /* @__PURE__ */ new Map();
1752
- for (const entry of branch) {
1753
- if (entry.type !== "message") continue;
1754
- const msg = entry.message;
1755
- const role = msg.role;
1756
- if (role === "user") {
1757
- const text = extractUserText2(msg);
1758
- if (text) items.push({ kind: "user", text, entryId: entry.id });
1759
- } else if (role === "assistant") {
1760
- const { text, thinking, toolCalls } = extractAssistantContent(
1761
- msg
1762
- );
1763
- for (const tc of toolCalls) {
1764
- argsByCallId.set(tc.id, tc.args);
1765
- }
1766
- if (text || thinking) items.push({ kind: "assistant", text, thinking });
1767
- } else if (role === "toolResult") {
1768
- const tr = msg;
1769
- items.push({
1770
- kind: "tool",
1771
- toolCallId: tr.toolCallId,
1772
- toolName: tr.toolName,
1773
- args: argsByCallId.get(tr.toolCallId) ?? "",
1774
- text: extractContentText(tr.content),
1775
- isError: tr.isError,
1776
- // Mirror live wire whitelist (bridge.ts): only ship details
1777
- // for tools whose cards need the structured shape, so the
1778
- // history payload stays small for bash / edit / read.
1779
- ...shouldForwardDetails(tr.toolName) && tr.details !== void 0 ? { details: tr.details } : {}
1780
- });
1781
- } else if (role === "bashExecution") {
1782
- const be = msg;
1783
- items.push({
1784
- kind: "bash",
1785
- command: be.command,
1786
- output: be.output,
1787
- exitCode: be.exitCode
1788
- });
1789
- }
1790
- }
1791
- return { items, isStreaming };
3485
+ return { items: buildHistoryItems(branch), isStreaming };
1792
3486
  }
1793
3487
  /**
1794
3488
  * Delete a session JSONL file belonging to this workspace.
@@ -1808,8 +3502,8 @@ ${err2.stack}` : "")
1808
3502
  throw new HttpError(400, "Session path must be absolute");
1809
3503
  }
1810
3504
  const sessions = await SessionManager.list(ws.path);
1811
- const resolved = resolve2(sessionPath);
1812
- const target = sessions.find((session) => resolve2(session.path) === resolved);
3505
+ const resolved = resolve4(sessionPath);
3506
+ const target = sessions.find((session) => resolve4(session.path) === resolved);
1813
3507
  if (!target) {
1814
3508
  throw new HttpError(404, `Session not found: ${sessionPath}`);
1815
3509
  }
@@ -1831,10 +3525,12 @@ ${err2.stack}` : "")
1831
3525
  console.warn(
1832
3526
  `[wm] deleteSession: ${resolved} was already gone at unlink time`
1833
3527
  );
3528
+ await forgetSessionToolPrefs(resolved);
1834
3529
  return;
1835
3530
  }
1836
3531
  throw err2;
1837
3532
  }
3533
+ await forgetSessionToolPrefs(resolved);
1838
3534
  }
1839
3535
  /** Dispose every runtime for a workspace (e.g. when it's removed). */
1840
3536
  async dispose(workspaceId) {
@@ -1871,6 +3567,12 @@ ${err2.stack}` : "")
1871
3567
  } catch (e) {
1872
3568
  console.error(`[wm] dispose ${state.workspaceId} failed:`, e);
1873
3569
  }
3570
+ const sweptChildren = killChildrenForSession(state.runtime.session.sessionFile ?? null);
3571
+ if (sweptChildren > 0) {
3572
+ console.warn(
3573
+ `[wm] killed ${sweptChildren} lingering subagent child(ren) for ${state.workspaceId}`
3574
+ );
3575
+ }
1874
3576
  }
1875
3577
  /**
1876
3578
  * Keep the live-runtime count under `MAX_LIVE_RUNTIMES` by disposing the
@@ -1898,56 +3600,28 @@ ${err2.stack}` : "")
1898
3600
  }
1899
3601
  }
1900
3602
  };
1901
- function safeReconcileAskUser(workspaceId, sm) {
3603
+ function safeReconcileBuiltins(workspaceId, sm) {
1902
3604
  try {
1903
3605
  reconcileAfterRestart(sm);
1904
3606
  } catch (e) {
1905
3607
  console.error(`[wm] ask_user cleanup for ${workspaceId} failed:`, e);
1906
3608
  }
3609
+ try {
3610
+ reconcileAfterRestart2(sm);
3611
+ } catch (e) {
3612
+ console.error(`[wm] subagent cleanup for ${workspaceId} failed:`, e);
3613
+ }
1907
3614
  }
1908
- function toSessionSummary(info, running) {
3615
+ function toSessionSummary(info, running2) {
1909
3616
  const preview = info.firstMessage.replace(/\s+/g, " ").trim();
1910
3617
  return {
1911
3618
  path: info.path,
1912
3619
  name: info.name,
1913
3620
  updatedAt: info.modified.toISOString(),
1914
3621
  preview: preview ? preview.slice(0, 160) : void 0,
1915
- ...running ? { running: true } : {}
3622
+ ...running2 ? { running: true } : {}
1916
3623
  };
1917
3624
  }
1918
- function extractUserText2(msg) {
1919
- if (typeof msg.content === "string") return msg.content;
1920
- return extractContentText(msg.content);
1921
- }
1922
- function extractAssistantContent(msg) {
1923
- const textParts = [];
1924
- const thinkingParts = [];
1925
- const toolCalls = [];
1926
- for (const block of msg.content ?? []) {
1927
- if (!block || typeof block !== "object") continue;
1928
- const b = block;
1929
- if (b.type === "text" && typeof b.text === "string") textParts.push(b.text);
1930
- else if (b.type === "thinking" && typeof b.thinking === "string") thinkingParts.push(b.thinking);
1931
- else if (b.type === "toolCall" && typeof b.id === "string") {
1932
- toolCalls.push({
1933
- id: b.id,
1934
- args: b.arguments != null ? JSON.stringify(b.arguments) : ""
1935
- });
1936
- }
1937
- }
1938
- return { text: textParts.join(""), thinking: thinkingParts.join(""), toolCalls };
1939
- }
1940
- function extractContentText(content) {
1941
- if (!Array.isArray(content)) return "";
1942
- const parts = [];
1943
- for (const block of content) {
1944
- if (block && typeof block === "object" && block.type === "text") {
1945
- const text = block.text;
1946
- if (typeof text === "string") parts.push(text);
1947
- }
1948
- }
1949
- return parts.join("");
1950
- }
1951
3625
  var workspaceManager = new SessionRuntimeManager();
1952
3626
  function broadcastTo(subscribers, msg) {
1953
3627
  const wire = JSON.stringify(msg);
@@ -1961,6 +3635,9 @@ function broadcastTo(subscribers, msg) {
1961
3635
  }
1962
3636
 
1963
3637
  // src/api/config.ts
3638
+ var BUILTIN_TOOL_SOURCE = new Map(
3639
+ BUILTIN_EXTENSIONS.flatMap((d) => d.tools.map((tool) => [tool, d.name]))
3640
+ );
1964
3641
  function buildConfigResponse(workspaceId) {
1965
3642
  const runtime = workspaceManager.get(workspaceId);
1966
3643
  if (!runtime) throw new Error("runtime not initialized");
@@ -1978,10 +3655,14 @@ function buildConfigResponse(workspaceId) {
1978
3655
  name: m.name,
1979
3656
  reasoning: m.reasoning
1980
3657
  }));
1981
- const allTools = session.getAllTools().map((t) => ({
1982
- name: t.name,
1983
- description: t.description
1984
- }));
3658
+ const allTools = session.getAllTools().map((t) => {
3659
+ const builtinExtension = BUILTIN_TOOL_SOURCE.get(t.name);
3660
+ return {
3661
+ name: t.name,
3662
+ description: t.description,
3663
+ ...builtinExtension ? { builtinExtension } : {}
3664
+ };
3665
+ });
1985
3666
  return {
1986
3667
  currentModel,
1987
3668
  thinkingLevel: session.thinkingLevel,
@@ -2105,7 +3786,7 @@ function mountConfigRoutes(app2) {
2105
3786
  if (!runtime) {
2106
3787
  return c.json({ ok: false, error: "runtime not initialized" }, 500);
2107
3788
  }
2108
- runtime.session.setActiveToolsByName(body.tools);
3789
+ await persistActiveTools(id, runtime.session, body.tools);
2109
3790
  return c.json(buildConfigResponse(id));
2110
3791
  } catch (err2) {
2111
3792
  const message = err2 instanceof Error ? err2.message : String(err2);
@@ -2144,11 +3825,11 @@ function mountConfigRoutes(app2) {
2144
3825
  }
2145
3826
 
2146
3827
  // 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";
2150
- import { promisify as promisify2 } from "util";
2151
- var exec2 = promisify2(execFile2);
3828
+ import { execFile as execFile4 } from "child_process";
3829
+ import { readdir as readdir3, readFile as readFile7, realpath, stat as stat2, writeFile as writeFile4 } from "fs/promises";
3830
+ import { join as join12, relative, resolve as resolve5, sep as sep2 } from "path";
3831
+ import { promisify as promisify3 } from "util";
3832
+ var exec3 = promisify3(execFile4);
2152
3833
  var LIST_TTL_MS = 1e4;
2153
3834
  var MAX_CACHED_WORKSPACES = 16;
2154
3835
  var MAX_FILES_TRACKED = 2e4;
@@ -2176,8 +3857,8 @@ var listCache = /* @__PURE__ */ new Map();
2176
3857
  var inflight2 = /* @__PURE__ */ new Map();
2177
3858
  async function getFileList(workspacePath) {
2178
3859
  const now = Date.now();
2179
- const cached = listCache.get(workspacePath);
2180
- if (cached && cached.expiresAt > now) return cached.files;
3860
+ const cached2 = listCache.get(workspacePath);
3861
+ if (cached2 && cached2.expiresAt > now) return cached2.files;
2181
3862
  const pending2 = inflight2.get(workspacePath);
2182
3863
  if (pending2) return (await pending2).files;
2183
3864
  const probe = probeFileList(workspacePath).then((files) => {
@@ -2199,7 +3880,7 @@ async function getFileList(workspacePath) {
2199
3880
  }
2200
3881
  async function probeFileList(workspacePath) {
2201
3882
  try {
2202
- const { stdout } = await exec2(
3883
+ const { stdout } = await exec3(
2203
3884
  "git",
2204
3885
  ["ls-files", "--cached", "--others", "--exclude-standard"],
2205
3886
  {
@@ -2228,14 +3909,14 @@ async function walkDir(root, dir, depth, out) {
2228
3909
  if (depth > WALK_MAX_DEPTH) return;
2229
3910
  let dirents;
2230
3911
  try {
2231
- dirents = await readdir(dir, { withFileTypes: true });
3912
+ dirents = await readdir3(dir, { withFileTypes: true });
2232
3913
  } catch {
2233
3914
  return;
2234
3915
  }
2235
3916
  for (const d of dirents) {
2236
3917
  if (out.length >= MAX_FILES_TRACKED) return;
2237
3918
  if (WALK_IGNORES.has(d.name)) continue;
2238
- const abs = join5(dir, d.name);
3919
+ const abs = join12(dir, d.name);
2239
3920
  if (d.isDirectory()) {
2240
3921
  await walkDir(root, abs, depth + 1, out);
2241
3922
  } else if (d.isFile()) {
@@ -2258,6 +3939,21 @@ async function ensureWorkspaceExists(id) {
2258
3939
  const ws = await getWorkspace(id);
2259
3940
  return ws ? ws.path : null;
2260
3941
  }
3942
+ var MAX_EDIT_BYTES = 1024 * 1024;
3943
+ var TREE_MAX_FILES = 5e3;
3944
+ function resolveInWorkspace(workspacePath, relPath) {
3945
+ const root = resolve5(workspacePath);
3946
+ const abs = resolve5(root, relPath);
3947
+ if (abs !== root && !abs.startsWith(root + sep2)) return null;
3948
+ return abs;
3949
+ }
3950
+ async function resolveExistingInWorkspace(workspacePath, relPath) {
3951
+ const abs = resolveInWorkspace(workspacePath, relPath);
3952
+ if (!abs) return null;
3953
+ const [rootReal, targetReal] = await Promise.all([realpath(workspacePath), realpath(abs)]);
3954
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep2)) return null;
3955
+ return targetReal;
3956
+ }
2261
3957
  function mountFilesRoute(app2) {
2262
3958
  app2.get("/:id/files/search", async (c) => {
2263
3959
  const id = c.req.param("id");
@@ -2275,7 +3971,7 @@ function mountFilesRoute(app2) {
2275
3971
  if (!qRaw) {
2276
3972
  const slice = all.slice(0, limit);
2277
3973
  entries = slice.map((relPath) => ({
2278
- path: join5(workspacePath, relPath),
3974
+ path: join12(workspacePath, relPath),
2279
3975
  relPath
2280
3976
  }));
2281
3977
  truncated = all.length > limit;
@@ -2292,7 +3988,7 @@ function mountFilesRoute(app2) {
2292
3988
  scored.sort((a, b) => b.score - a.score);
2293
3989
  const top = scored.slice(0, limit);
2294
3990
  entries = top.map((e) => ({
2295
- path: join5(workspacePath, e.relPath),
3991
+ path: join12(workspacePath, e.relPath),
2296
3992
  relPath: e.relPath
2297
3993
  }));
2298
3994
  truncated = matchCount > limit;
@@ -2305,12 +4001,81 @@ function mountFilesRoute(app2) {
2305
4001
  return c.json({ ok: false, error: message }, 500);
2306
4002
  }
2307
4003
  });
4004
+ app2.get("/:id/files/list", async (c) => {
4005
+ const id = c.req.param("id");
4006
+ const workspacePath = await ensureWorkspaceExists(id);
4007
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4008
+ try {
4009
+ const all = await getFileList(workspacePath);
4010
+ const entries = all.slice(0, TREE_MAX_FILES);
4011
+ const body = {
4012
+ workspacePath,
4013
+ entries,
4014
+ truncated: all.length > TREE_MAX_FILES
4015
+ };
4016
+ return c.json(body);
4017
+ } catch (err2) {
4018
+ const message = err2 instanceof Error ? err2.message : String(err2);
4019
+ console.error(`[api/files] list for ${id} failed:`, err2);
4020
+ return c.json({ ok: false, error: message }, 500);
4021
+ }
4022
+ });
4023
+ app2.get("/:id/file", async (c) => {
4024
+ const id = c.req.param("id");
4025
+ const workspacePath = await ensureWorkspaceExists(id);
4026
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4027
+ const relPath = c.req.query("path");
4028
+ if (!relPath) return c.json({ ok: false, error: "path query is required" }, 400);
4029
+ try {
4030
+ const abs = await resolveExistingInWorkspace(workspacePath, relPath);
4031
+ if (!abs) return c.json({ ok: false, error: "path escapes workspace" }, 400);
4032
+ const st = await stat2(abs);
4033
+ if (!st.isFile()) return c.json({ ok: false, error: "not a file" }, 400);
4034
+ if (st.size > MAX_EDIT_BYTES) {
4035
+ return c.json({ ok: false, error: "file too large to edit (>1 MB)" }, 400);
4036
+ }
4037
+ const buf = await readFile7(abs);
4038
+ if (buf.includes(0)) return c.json({ ok: false, error: "binary file" }, 400);
4039
+ const body = { relPath, content: buf.toString("utf-8") };
4040
+ return c.json(body);
4041
+ } catch (err2) {
4042
+ const code = err2.code;
4043
+ const msg = code === "ENOENT" ? "not found" : code === "EACCES" ? "permission denied" : "read failed";
4044
+ return c.json({ ok: false, error: msg }, 400);
4045
+ }
4046
+ });
4047
+ app2.put("/:id/file", async (c) => {
4048
+ const id = c.req.param("id");
4049
+ const workspacePath = await ensureWorkspaceExists(id);
4050
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4051
+ const body = await c.req.json().catch(() => null);
4052
+ if (!body || typeof body.path !== "string" || typeof body.content !== "string") {
4053
+ return c.json({ ok: false, error: "path and content are required" }, 400);
4054
+ }
4055
+ if (Buffer.byteLength(body.content, "utf-8") > MAX_EDIT_BYTES) {
4056
+ return c.json({ ok: false, error: "content too large (>1 MB)" }, 400);
4057
+ }
4058
+ try {
4059
+ const abs = await resolveExistingInWorkspace(workspacePath, body.path);
4060
+ if (!abs) return c.json({ ok: false, error: "path escapes workspace" }, 400);
4061
+ const st = await stat2(abs);
4062
+ if (!st.isFile()) return c.json({ ok: false, error: "not a file" }, 400);
4063
+ await writeFile4(abs, body.content, "utf-8");
4064
+ const ok = { ok: true };
4065
+ return c.json(ok);
4066
+ } catch (err2) {
4067
+ const code = err2.code;
4068
+ const msg = code === "ENOENT" ? "not found" : code === "EACCES" ? "permission denied" : "write failed";
4069
+ return c.json({ ok: false, error: msg }, 400);
4070
+ }
4071
+ });
2308
4072
  }
2309
4073
 
2310
4074
  // 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";
4075
+ import { readdir as readdir4 } from "fs/promises";
4076
+ import { join as join13 } from "path";
4077
+ import { getAgentDir as getAgentDir3 } from "@earendil-works/pi-coding-agent";
4078
+ var MAX_SKILL_ZIP_BYTES = 25 * 1024 * 1024;
2314
4079
  function toResourceSource(info) {
2315
4080
  return {
2316
4081
  scope: info.scope,
@@ -2319,16 +4084,16 @@ function toResourceSource(info) {
2319
4084
  };
2320
4085
  }
2321
4086
  async function scanExtensionDirs(workspaceCwd) {
2322
- const dirs = [join6(getAgentDir2(), "extensions"), join6(workspaceCwd, ".pi", "extensions")];
4087
+ const dirs = [join13(getAgentDir3(), "extensions"), join13(workspaceCwd, ".pi", "extensions")];
2323
4088
  const found = [];
2324
4089
  for (const dir of dirs) {
2325
4090
  try {
2326
- const entries = await readdir2(dir, { withFileTypes: true });
4091
+ const entries = await readdir4(dir, { withFileTypes: true });
2327
4092
  for (const entry of entries) {
2328
4093
  if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
2329
- found.push(join6(dir, entry.name));
4094
+ found.push(join13(dir, entry.name));
2330
4095
  } else if (entry.isDirectory()) {
2331
- found.push(join6(dir, entry.name));
4096
+ found.push(join13(dir, entry.name));
2332
4097
  }
2333
4098
  }
2334
4099
  } catch {
@@ -2410,7 +4175,7 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
2410
4175
  async function rootsFor(workspaceId) {
2411
4176
  const ws = await getWorkspace(workspaceId);
2412
4177
  if (!ws) throw new HttpError(404, "workspace not found");
2413
- const roots = resolveResourceRoots({ agentDir: getAgentDir2(), workspaceCwd: ws.path });
4178
+ const roots = resolveResourceRoots({ agentDir: getAgentDir3(), workspaceCwd: ws.path });
2414
4179
  return { roots, workspaceCwd: ws.path };
2415
4180
  }
2416
4181
  function respondError(c, err2) {
@@ -2503,6 +4268,39 @@ function mountResourcesRoute(app2) {
2503
4268
  return respondError(c, err2);
2504
4269
  }
2505
4270
  });
4271
+ app2.post("/:id/resources/skills/install", async (c) => {
4272
+ const id = c.req.param("id");
4273
+ const ws = await getWorkspace(id);
4274
+ if (!ws) return c.json({ ok: false, error: "not found" }, 404);
4275
+ const scope = c.req.query("scope");
4276
+ if (!isScope(scope)) {
4277
+ return c.json({ ok: false, error: "scope query must be 'user' or 'project'" }, 400);
4278
+ }
4279
+ const overwrite = c.req.query("overwrite") === "1";
4280
+ const declared = Number(c.req.header("content-length") ?? "0");
4281
+ if (declared > MAX_SKILL_ZIP_BYTES) {
4282
+ return c.json({ ok: false, error: "archive too large (max 25 MB)" }, 400);
4283
+ }
4284
+ let zip;
4285
+ try {
4286
+ zip = Buffer.from(await c.req.arrayBuffer());
4287
+ } catch {
4288
+ return c.json({ ok: false, error: "could not read upload" }, 400);
4289
+ }
4290
+ if (zip.length === 0) return c.json({ ok: false, error: "empty upload" }, 400);
4291
+ if (zip.length > MAX_SKILL_ZIP_BYTES) {
4292
+ return c.json({ ok: false, error: "archive too large (max 25 MB)" }, 400);
4293
+ }
4294
+ try {
4295
+ await workspaceManager.getOrCreate(id);
4296
+ const { roots, workspaceCwd } = await rootsFor(id);
4297
+ await installSkillFromZip({ roots, scope, zip, overwrite });
4298
+ await reload(id);
4299
+ return c.json(await snapshot(id, roots, workspaceCwd));
4300
+ } catch (err2) {
4301
+ return respondError(c, err2);
4302
+ }
4303
+ });
2506
4304
  app2.put("/:id/resources/skills", async (c) => {
2507
4305
  const id = c.req.param("id");
2508
4306
  const ws = await getWorkspace(id);
@@ -2657,6 +4455,7 @@ function mountResourcesRoute(app2) {
2657
4455
  }
2658
4456
  await setBuiltinEnabled(body.id, body.enabled);
2659
4457
  await runtime.session.reload();
4458
+ reapplyToolPrefs(id, runtime.session);
2660
4459
  const { roots, workspaceCwd } = await rootsFor(id);
2661
4460
  return c.json(await snapshot(id, roots, workspaceCwd));
2662
4461
  } catch (err2) {
@@ -2775,9 +4574,9 @@ function extractPreview(entry) {
2775
4574
  case "message":
2776
4575
  return extractMessagePreview(entry["message"]);
2777
4576
  case "compaction":
2778
- return truncate(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
4577
+ return truncate2(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
2779
4578
  case "branch_summary":
2780
- return truncate(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
4579
+ return truncate2(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
2781
4580
  case "model_change":
2782
4581
  return `${entry["provider"] ?? ""}/${entry["modelId"] ?? ""}`;
2783
4582
  case "thinking_level_change":
@@ -2785,7 +4584,7 @@ function extractPreview(entry) {
2785
4584
  case "session_info":
2786
4585
  return entry["name"] ? `Name: ${entry["name"]}` : "Session info";
2787
4586
  case "custom_message":
2788
- return truncate(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
4587
+ return truncate2(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
2789
4588
  case "custom":
2790
4589
  return `Custom: ${entry["customType"] ?? ""}`;
2791
4590
  case "label":
@@ -2798,9 +4597,9 @@ function extractMessagePreview(msg) {
2798
4597
  if (!msg || typeof msg !== "object") return "";
2799
4598
  const m = msg;
2800
4599
  if (m.role === "bashExecution") {
2801
- return truncate(`$ ${m.command ?? ""}`, PREVIEW_MAX);
4600
+ return truncate2(`$ ${m.command ?? ""}`, PREVIEW_MAX);
2802
4601
  }
2803
- return truncate(extractContentText2(m.content), PREVIEW_MAX);
4602
+ return truncate2(extractContentText2(m.content), PREVIEW_MAX);
2804
4603
  }
2805
4604
  function extractContentText2(content) {
2806
4605
  if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
@@ -2817,7 +4616,7 @@ function extractContentText2(content) {
2817
4616
  }
2818
4617
  return parts.join(" ").replace(/\s+/g, " ").trim();
2819
4618
  }
2820
- function truncate(text, max) {
4619
+ function truncate2(text, max) {
2821
4620
  if (text.length <= max) return text;
2822
4621
  return text.slice(0, max) + "\u2026";
2823
4622
  }
@@ -2913,7 +4712,7 @@ workspacesRoute.get("/:id/export", async (c) => {
2913
4712
  }
2914
4713
  const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
2915
4714
  const outputPath = await runtime.session.exportToHtml();
2916
- const html = await readFile4(outputPath, "utf-8");
4715
+ const html = await readFile8(outputPath, "utf-8");
2917
4716
  const filename = basename2(outputPath);
2918
4717
  const body = { html, filename };
2919
4718
  return c.json(body);
@@ -2946,9 +4745,9 @@ workspacesRoute.post("/", async (c) => {
2946
4745
  if (!isAbsolute3(body.path)) {
2947
4746
  return c.json({ ok: false, error: "path must be absolute" }, 400);
2948
4747
  }
2949
- const resolved = resolve3(body.path);
4748
+ const resolved = resolve6(body.path);
2950
4749
  try {
2951
- const st = await stat2(resolved);
4750
+ const st = await stat3(resolved);
2952
4751
  if (!st.isDirectory()) {
2953
4752
  return c.json({ ok: false, error: "path is not a directory" }, 400);
2954
4753
  }
@@ -2972,24 +4771,35 @@ workspacesRoute.delete("/:id", async (c) => {
2972
4771
  const body = { ok: true };
2973
4772
  return c.json(body);
2974
4773
  });
4774
+ workspacesRoute.patch("/:id", async (c) => {
4775
+ const id = c.req.param("id");
4776
+ const body = await c.req.json();
4777
+ if (typeof body?.trustProjectAgents !== "boolean") {
4778
+ return c.json({ ok: false, error: "trustProjectAgents must be a boolean" }, 400);
4779
+ }
4780
+ const updated = await setWorkspaceTrustProjectAgents(id, body.trustProjectAgents);
4781
+ if (!updated) return c.json({ ok: false, error: "not found" }, 404);
4782
+ const res = { workspace: await enrichWorkspace(updated) };
4783
+ return c.json(res);
4784
+ });
2975
4785
  mountConfigRoutes(workspacesRoute);
2976
4786
  mountResourcesRoute(workspacesRoute);
2977
4787
  mountFilesRoute(workspacesRoute);
2978
4788
  workspacesRoute.route("/:id/tree", treeRoute);
2979
4789
 
2980
4790
  // 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";
4791
+ import { readdir as readdir5 } from "fs/promises";
4792
+ import { homedir as homedir3 } from "os";
4793
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve7 } from "path";
2984
4794
  import { Hono as Hono3 } from "hono";
2985
4795
  var fsRoute = new Hono3();
2986
4796
  fsRoute.get("/browse", async (c) => {
2987
4797
  const rawPath = c.req.query("path");
2988
4798
  const showHidden = c.req.query("showHidden") === "1";
2989
- const target = rawPath && isAbsolute4(rawPath) ? resolve4(rawPath) : homedir2();
4799
+ const target = rawPath && isAbsolute4(rawPath) ? resolve7(rawPath) : homedir3();
2990
4800
  let dirents;
2991
4801
  try {
2992
- dirents = await readdir3(target, { withFileTypes: true });
4802
+ dirents = await readdir5(target, { withFileTypes: true });
2993
4803
  } catch (err2) {
2994
4804
  const code = err2.code;
2995
4805
  const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
@@ -2997,11 +4807,11 @@ fsRoute.get("/browse", async (c) => {
2997
4807
  }
2998
4808
  const entries = dirents.filter((d) => d.isDirectory()).filter((d) => showHidden || !d.name.startsWith(".")).map((d) => ({
2999
4809
  name: d.name,
3000
- path: join7(target, d.name),
4810
+ path: join14(target, d.name),
3001
4811
  type: "dir"
3002
4812
  })).sort((a, b) => a.name.localeCompare(b.name));
3003
4813
  const parent = (() => {
3004
- const p = dirname4(target);
4814
+ const p = dirname5(target);
3005
4815
  return p === target ? null : p;
3006
4816
  })();
3007
4817
  const body = { path: target, parent, entries };
@@ -3009,12 +4819,48 @@ fsRoute.get("/browse", async (c) => {
3009
4819
  });
3010
4820
 
3011
4821
  // 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";
4822
+ import { readFile as readFile9 } from "fs/promises";
4823
+ import { join as join15 } from "path";
3014
4824
  import { Hono as Hono4 } from "hono";
3015
4825
  import {
3016
- getAgentDir as getAgentDir3
4826
+ getAgentDir as getAgentDir4
3017
4827
  } from "@earendil-works/pi-coding-agent";
4828
+
4829
+ // src/api/model-config-keys.ts
4830
+ var MASKED_KEY_RE = /^….{1,8}$/;
4831
+ function maskApiKey(key) {
4832
+ return `\u2026${key.slice(-4)}`;
4833
+ }
4834
+ function isPreservedApiKey(key) {
4835
+ return key === "" || MASKED_KEY_RE.test(key);
4836
+ }
4837
+ function maskConfigForResponse(config2) {
4838
+ const providers = {};
4839
+ for (const [name, provider] of Object.entries(config2.providers)) {
4840
+ providers[name] = {
4841
+ ...provider,
4842
+ apiKey: provider.apiKey ? maskApiKey(provider.apiKey) : ""
4843
+ };
4844
+ }
4845
+ return { providers };
4846
+ }
4847
+ function preserveApiKeys(incoming, existing) {
4848
+ const providers = { ...incoming.providers };
4849
+ for (const [name, provider] of Object.entries(providers)) {
4850
+ if (!isPreservedApiKey(provider.apiKey)) continue;
4851
+ const prev = existing.providers[name]?.apiKey;
4852
+ if (prev) {
4853
+ providers[name] = { ...provider, apiKey: prev };
4854
+ }
4855
+ }
4856
+ return { providers };
4857
+ }
4858
+ function resolveUpsertApiKey(incomingKey, existingKey) {
4859
+ if (!isPreservedApiKey(incomingKey)) return incomingKey;
4860
+ return existingKey || void 0;
4861
+ }
4862
+
4863
+ // src/api/model-configs.ts
3018
4864
  var modelConfigsRoute = new Hono4();
3019
4865
  var writeLock = Promise.resolve();
3020
4866
  function withWriteLock(fn) {
@@ -3025,11 +4871,11 @@ function withWriteLock(fn) {
3025
4871
  return next;
3026
4872
  }
3027
4873
  function modelsPath() {
3028
- return join8(getAgentDir3(), "models.json");
4874
+ return join15(getAgentDir4(), "models.json");
3029
4875
  }
3030
4876
  async function readModelsJson() {
3031
4877
  try {
3032
- const raw = await readFile5(modelsPath(), "utf-8");
4878
+ const raw = await readFile9(modelsPath(), "utf-8");
3033
4879
  return JSON.parse(raw);
3034
4880
  } catch (err2) {
3035
4881
  if (err2?.code === "ENOENT") {
@@ -3039,14 +4885,12 @@ async function readModelsJson() {
3039
4885
  }
3040
4886
  }
3041
4887
  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");
4888
+ await writeJsonAtomic(modelsPath(), config2, { mode: 384 });
3045
4889
  }
3046
4890
  var ValidationError = class extends Error {
3047
- constructor(message, status) {
4891
+ constructor(message, status2) {
3048
4892
  super(message);
3049
- this.status = status;
4893
+ this.status = status2;
3050
4894
  }
3051
4895
  status;
3052
4896
  };
@@ -3064,7 +4908,7 @@ function refreshRegistry(workspaceId) {
3064
4908
  modelConfigsRoute.get("/", async (c) => {
3065
4909
  try {
3066
4910
  const config2 = await readModelsJson();
3067
- const body = { config: config2 };
4911
+ const body = { config: maskConfigForResponse(config2) };
3068
4912
  return c.json(body);
3069
4913
  } catch (err2) {
3070
4914
  const message = err2 instanceof Error ? err2.message : String(err2);
@@ -3077,12 +4921,15 @@ modelConfigsRoute.put("/", async (c) => {
3077
4921
  return c.json({ ok: false, error: "config.providers is required" }, 400);
3078
4922
  }
3079
4923
  try {
3080
- await withWriteLock(async () => {
3081
- await writeModelsJson(body.config);
4924
+ const config2 = await withWriteLock(async () => {
4925
+ const existing = await readModelsJson();
4926
+ const merged = preserveApiKeys(body.config, existing);
4927
+ await writeModelsJson(merged);
4928
+ return merged;
3082
4929
  });
3083
4930
  const workspaceId = c.req.query("workspaceId");
3084
4931
  refreshRegistry(workspaceId ?? void 0);
3085
- const resp = { config: body.config };
4932
+ const resp = { config: maskConfigForResponse(config2) };
3086
4933
  return c.json(resp);
3087
4934
  } catch (err2) {
3088
4935
  const message = err2 instanceof Error ? err2.message : String(err2);
@@ -3094,8 +4941,8 @@ modelConfigsRoute.post("/providers", async (c) => {
3094
4941
  if (!body?.name || !body?.provider) {
3095
4942
  return c.json({ ok: false, error: "name and provider are required" }, 400);
3096
4943
  }
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);
4944
+ if (!body.provider.baseUrl || !body.provider.api) {
4945
+ return c.json({ ok: false, error: "provider must have baseUrl and api" }, 400);
3099
4946
  }
3100
4947
  if (!Array.isArray(body.provider.models)) {
3101
4948
  return c.json({ ok: false, error: "provider.models must be an array" }, 400);
@@ -3103,15 +4950,22 @@ modelConfigsRoute.post("/providers", async (c) => {
3103
4950
  try {
3104
4951
  const config2 = await withWriteLock(async () => {
3105
4952
  const cfg = await readModelsJson();
3106
- cfg.providers[body.name] = body.provider;
4953
+ const apiKey2 = resolveUpsertApiKey(body.provider.apiKey, cfg.providers[body.name]?.apiKey);
4954
+ if (!apiKey2) {
4955
+ throw new ValidationError("apiKey is required for a new provider", 400);
4956
+ }
4957
+ cfg.providers[body.name] = { ...body.provider, apiKey: apiKey2 };
3107
4958
  await writeModelsJson(cfg);
3108
4959
  return cfg;
3109
4960
  });
3110
4961
  const workspaceId = c.req.query("workspaceId");
3111
4962
  refreshRegistry(workspaceId ?? void 0);
3112
- const resp = { config: config2 };
4963
+ const resp = { config: maskConfigForResponse(config2) };
3113
4964
  return c.json(resp);
3114
4965
  } catch (err2) {
4966
+ if (err2 instanceof ValidationError) {
4967
+ return c.json({ ok: false, error: err2.message }, err2.status);
4968
+ }
3115
4969
  const message = err2 instanceof Error ? err2.message : String(err2);
3116
4970
  return c.json({ ok: false, error: message }, 500);
3117
4971
  }
@@ -3133,7 +4987,7 @@ modelConfigsRoute.delete("/providers", async (c) => {
3133
4987
  });
3134
4988
  const workspaceId = c.req.query("workspaceId");
3135
4989
  refreshRegistry(workspaceId ?? void 0);
3136
- const resp = { config: config2 };
4990
+ const resp = { config: maskConfigForResponse(config2) };
3137
4991
  return c.json(resp);
3138
4992
  } catch (err2) {
3139
4993
  if (err2 instanceof ValidationError) {
@@ -3165,7 +5019,7 @@ modelConfigsRoute.post("/providers/:provider/models", async (c) => {
3165
5019
  });
3166
5020
  const workspaceId = c.req.query("workspaceId");
3167
5021
  refreshRegistry(workspaceId ?? void 0);
3168
- const resp = { config: config2 };
5022
+ const resp = { config: maskConfigForResponse(config2) };
3169
5023
  return c.json(resp);
3170
5024
  } catch (err2) {
3171
5025
  if (err2 instanceof ValidationError) {
@@ -3201,7 +5055,7 @@ modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
3201
5055
  });
3202
5056
  const workspaceId = c.req.query("workspaceId");
3203
5057
  refreshRegistry(workspaceId ?? void 0);
3204
- const resp = { config: config2 };
5058
+ const resp = { config: maskConfigForResponse(config2) };
3205
5059
  return c.json(resp);
3206
5060
  } catch (err2) {
3207
5061
  if (err2 instanceof ValidationError) {
@@ -3230,7 +5084,7 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
3230
5084
  });
3231
5085
  const workspaceId = c.req.query("workspaceId");
3232
5086
  refreshRegistry(workspaceId ?? void 0);
3233
- const resp = { config: config2 };
5087
+ const resp = { config: maskConfigForResponse(config2) };
3234
5088
  return c.json(resp);
3235
5089
  } catch (err2) {
3236
5090
  if (err2 instanceof ValidationError) {
@@ -3241,8 +5095,197 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
3241
5095
  }
3242
5096
  });
3243
5097
 
5098
+ // src/api/web-search.ts
5099
+ import { Hono as Hono5 } from "hono";
5100
+ var webSearchRoute = new Hono5();
5101
+ function status() {
5102
+ return getKeyStatus();
5103
+ }
5104
+ webSearchRoute.get("/", (c) => c.json(status()));
5105
+ webSearchRoute.put("/", async (c) => {
5106
+ const body = await c.req.json().catch(() => null);
5107
+ if (!body || typeof body.apiKey !== "string") {
5108
+ return c.json({ ok: false, error: "apiKey (string) is required" }, 400);
5109
+ }
5110
+ try {
5111
+ await setTavilyApiKey(body.apiKey);
5112
+ return c.json(status());
5113
+ } catch (err2) {
5114
+ const message = err2 instanceof Error ? err2.message : String(err2);
5115
+ return c.json({ ok: false, error: message }, 500);
5116
+ }
5117
+ });
5118
+ webSearchRoute.delete("/", async (c) => {
5119
+ try {
5120
+ await clearTavilyApiKey();
5121
+ return c.json(status());
5122
+ } catch (err2) {
5123
+ const message = err2 instanceof Error ? err2.message : String(err2);
5124
+ return c.json({ ok: false, error: message }, 500);
5125
+ }
5126
+ });
5127
+
3244
5128
  // src/ws/hub.ts
3245
5129
  import { WebSocketServer } from "ws";
5130
+
5131
+ // src/security.ts
5132
+ var LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost"];
5133
+ function buildAllowedHosts() {
5134
+ const ports = /* @__PURE__ */ new Set([config.port, 5173]);
5135
+ const hosts = /* @__PURE__ */ new Set();
5136
+ for (const name of LOOPBACK_HOSTNAMES) {
5137
+ for (const port of ports) {
5138
+ hosts.add(`${name}:${port}`);
5139
+ }
5140
+ }
5141
+ return hosts;
5142
+ }
5143
+ function buildAllowedWsOrigins() {
5144
+ const origins = /* @__PURE__ */ new Set([config.corsOrigin]);
5145
+ for (const name of LOOPBACK_HOSTNAMES) {
5146
+ origins.add(`http://${name}:${config.port}`);
5147
+ origins.add(`http://${name}:5173`);
5148
+ }
5149
+ return origins;
5150
+ }
5151
+ var allowedHosts = buildAllowedHosts();
5152
+ var allowedWsOrigins = buildAllowedWsOrigins();
5153
+ function isAllowedHost(host) {
5154
+ if (!host) return false;
5155
+ return allowedHosts.has(host.toLowerCase());
5156
+ }
5157
+ function isAllowedWsOrigin(origin) {
5158
+ if (!origin) return false;
5159
+ return allowedWsOrigins.has(origin);
5160
+ }
5161
+
5162
+ // src/ws/terminals.ts
5163
+ import { createRequire } from "module";
5164
+ import { accessSync, chmodSync, constants, statSync } from "fs";
5165
+ import { join as join16 } from "path";
5166
+ import { spawn as spawn2 } from "node-pty";
5167
+ var FALLBACK_SHELL = process.platform === "win32" ? "powershell.exe" : "/bin/bash";
5168
+ function send(ws, msg) {
5169
+ if (ws.readyState !== ws.OPEN) return;
5170
+ ws.send(JSON.stringify(msg));
5171
+ }
5172
+ function ptyEnv() {
5173
+ const env = {};
5174
+ for (const [k, v] of Object.entries(process.env)) {
5175
+ if (typeof v === "string") env[k] = v;
5176
+ }
5177
+ env.TERM = "xterm-256color";
5178
+ env.COLORTERM = "truecolor";
5179
+ return env;
5180
+ }
5181
+ async function handleTerminalMessage(ws, terminals, msg) {
5182
+ switch (msg.type) {
5183
+ case "terminal_open": {
5184
+ ensureSpawnHelperExecutable();
5185
+ const existing = terminals.get(msg.terminalId);
5186
+ if (existing) {
5187
+ try {
5188
+ existing.kill();
5189
+ } catch {
5190
+ }
5191
+ terminals.delete(msg.terminalId);
5192
+ }
5193
+ const ws_ = await getWorkspace(msg.workspaceId);
5194
+ if (!ws_) {
5195
+ send(ws, { type: "terminal_error", terminalId: msg.terminalId, message: "workspace not found" });
5196
+ return;
5197
+ }
5198
+ const shell = process.env.SHELL || FALLBACK_SHELL;
5199
+ let pty;
5200
+ try {
5201
+ pty = spawn2(shell, [], {
5202
+ name: "xterm-256color",
5203
+ cols: clampDim(msg.cols, 80),
5204
+ rows: clampDim(msg.rows, 24),
5205
+ cwd: ws_.path,
5206
+ env: ptyEnv()
5207
+ });
5208
+ } catch (err2) {
5209
+ const message = err2 instanceof Error ? err2.message : String(err2);
5210
+ send(ws, { type: "terminal_error", terminalId: msg.terminalId, message });
5211
+ return;
5212
+ }
5213
+ terminals.set(msg.terminalId, pty);
5214
+ pty.onData((data) => {
5215
+ send(ws, { type: "terminal_output", terminalId: msg.terminalId, data });
5216
+ });
5217
+ pty.onExit(({ exitCode }) => {
5218
+ terminals.delete(msg.terminalId);
5219
+ send(ws, { type: "terminal_exit", terminalId: msg.terminalId, exitCode });
5220
+ });
5221
+ return;
5222
+ }
5223
+ case "terminal_input": {
5224
+ terminals.get(msg.terminalId)?.write(msg.data);
5225
+ return;
5226
+ }
5227
+ case "terminal_resize": {
5228
+ const pty = terminals.get(msg.terminalId);
5229
+ if (pty) {
5230
+ try {
5231
+ pty.resize(clampDim(msg.cols, 80), clampDim(msg.rows, 24));
5232
+ } catch {
5233
+ }
5234
+ }
5235
+ return;
5236
+ }
5237
+ case "terminal_close": {
5238
+ const pty = terminals.get(msg.terminalId);
5239
+ if (pty) {
5240
+ terminals.delete(msg.terminalId);
5241
+ try {
5242
+ pty.kill();
5243
+ } catch {
5244
+ }
5245
+ }
5246
+ return;
5247
+ }
5248
+ }
5249
+ }
5250
+ function closeAllTerminals(terminals) {
5251
+ for (const pty of terminals.values()) {
5252
+ try {
5253
+ pty.kill();
5254
+ } catch {
5255
+ }
5256
+ }
5257
+ terminals.clear();
5258
+ }
5259
+ function clampDim(n, fallback) {
5260
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
5261
+ }
5262
+ var spawnHelperChecked = false;
5263
+ function ensureSpawnHelperExecutable() {
5264
+ if (spawnHelperChecked || process.platform === "win32") return;
5265
+ spawnHelperChecked = true;
5266
+ try {
5267
+ const require2 = createRequire(import.meta.url);
5268
+ const root = join16(require2.resolve("node-pty/package.json"), "..");
5269
+ const candidates = [
5270
+ join16(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
5271
+ join16(root, "build", "Release", "spawn-helper")
5272
+ ];
5273
+ for (const path of candidates) {
5274
+ try {
5275
+ accessSync(path, constants.X_OK);
5276
+ } catch {
5277
+ try {
5278
+ const mode = statSync(path).mode;
5279
+ chmodSync(path, mode | 73);
5280
+ } catch {
5281
+ }
5282
+ }
5283
+ }
5284
+ } catch {
5285
+ }
5286
+ }
5287
+
5288
+ // src/ws/hub.ts
3246
5289
  var BACKGROUND_CAP = 4;
3247
5290
  var replacementLocks = /* @__PURE__ */ new Map();
3248
5291
  function withReplacementLock(workspaceId, fn) {
@@ -3258,23 +5301,40 @@ function withReplacementLock(workspaceId, fn) {
3258
5301
  return next;
3259
5302
  }
3260
5303
  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" });
5304
+ const wss = new WebSocketServer({
5305
+ server: httpServer,
5306
+ path: "/ws",
5307
+ verifyClient: (info, cb) => {
5308
+ const host = info.req.headers.host;
5309
+ const origin = info.origin;
5310
+ if (!isAllowedHost(host) || !isAllowedWsOrigin(origin)) {
5311
+ cb(false, 403, "Forbidden");
3270
5312
  return;
3271
5313
  }
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
- }
5314
+ cb(true);
5315
+ }
5316
+ });
5317
+ wss.on("connection", (ws) => {
5318
+ const state = { background: /* @__PURE__ */ new Map(), terminals: /* @__PURE__ */ new Map() };
5319
+ let inbound = Promise.resolve();
5320
+ ws.on("message", (raw) => {
5321
+ inbound = inbound.then(async () => {
5322
+ let msg;
5323
+ try {
5324
+ msg = JSON.parse(raw.toString());
5325
+ } catch {
5326
+ send2(ws, { type: "error", message: "invalid JSON" });
5327
+ return;
5328
+ }
5329
+ try {
5330
+ await handle(ws, state, msg);
5331
+ } catch (err2) {
5332
+ const message = err2 instanceof Error ? err2.message : String(err2);
5333
+ send2(ws, { type: "error", message, command: msg.type });
5334
+ }
5335
+ }).catch((err2) => {
5336
+ console.error("[ws] inbound chain error:", err2);
5337
+ });
3278
5338
  });
3279
5339
  ws.on("close", () => {
3280
5340
  detach(state, ws);
@@ -3290,12 +5350,12 @@ async function handle(ws, state, msg) {
3290
5350
  runtime = await workspaceManager.getOrCreate(msg.workspaceId, msg.sessionPath);
3291
5351
  } catch (err2) {
3292
5352
  const message = err2 instanceof Error ? err2.message : String(err2);
3293
- send(ws, { type: "error", message, command: "subscribe" });
3294
- send(ws, { type: "ack", command: "subscribe" });
5353
+ send2(ws, { type: "error", message, command: "subscribe" });
5354
+ send2(ws, { type: "ack", command: "subscribe" });
3295
5355
  return;
3296
5356
  }
3297
5357
  promoteToPrimary(ws, state, msg.workspaceId, runtime);
3298
- send(ws, { type: "ack", command: "subscribe" });
5358
+ send2(ws, { type: "ack", command: "subscribe" });
3299
5359
  return;
3300
5360
  }
3301
5361
  case "unsubscribe": {
@@ -3307,23 +5367,23 @@ async function handle(ws, state, msg) {
3307
5367
  case "prompt": {
3308
5368
  const primary = state.primary;
3309
5369
  if (!primary) {
3310
- send(ws, { type: "error", message: "not subscribed", command: "prompt" });
5370
+ send2(ws, { type: "error", message: "not subscribed", command: "prompt" });
3311
5371
  return;
3312
5372
  }
3313
5373
  if (replacementLocks.has(primary.workspaceId)) {
3314
- send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
5374
+ send2(ws, { type: "error", message: "session switching in progress", command: "prompt" });
3315
5375
  return;
3316
5376
  }
3317
5377
  void primary.runtime.session.prompt(msg.message, { streamingBehavior: msg.streamingBehavior }).catch((err2) => {
3318
5378
  const message = err2 instanceof Error ? err2.message : String(err2);
3319
- send(ws, { type: "error", message, command: "prompt" });
5379
+ send2(ws, { type: "error", message, command: "prompt" });
3320
5380
  });
3321
5381
  return;
3322
5382
  }
3323
5383
  case "abort": {
3324
5384
  const primary = state.primary;
3325
5385
  if (!primary) {
3326
- send(ws, { type: "error", message: "not subscribed", command: "abort" });
5386
+ send2(ws, { type: "error", message: "not subscribed", command: "abort" });
3327
5387
  return;
3328
5388
  }
3329
5389
  await primary.runtime.session.abort();
@@ -3337,7 +5397,7 @@ async function handle(ws, state, msg) {
3337
5397
  runtime = await workspaceManager.createSession(workspaceId);
3338
5398
  } catch (err2) {
3339
5399
  const message = err2 instanceof Error ? err2.message : String(err2);
3340
- send(ws, { type: "error", message, command: "new_session" });
5400
+ send2(ws, { type: "error", message, command: "new_session" });
3341
5401
  return;
3342
5402
  }
3343
5403
  promoteToPrimary(ws, state, workspaceId, runtime);
@@ -3347,22 +5407,22 @@ async function handle(ws, state, msg) {
3347
5407
  case "fork": {
3348
5408
  const primary = state.primary;
3349
5409
  if (!primary) {
3350
- send(ws, { type: "error", message: "not subscribed", command: "fork" });
5410
+ send2(ws, { type: "error", message: "not subscribed", command: "fork" });
3351
5411
  return;
3352
5412
  }
3353
5413
  const workspaceId = primary.workspaceId;
3354
5414
  await withReplacementLock(workspaceId, async () => {
3355
5415
  const source = state.primary;
3356
5416
  if (!source) {
3357
- send(ws, { type: "error", message: "not subscribed", command: "fork" });
5417
+ send2(ws, { type: "error", message: "not subscribed", command: "fork" });
3358
5418
  return;
3359
5419
  }
3360
5420
  if (!source.sessionPath) {
3361
- send(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
5421
+ send2(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
3362
5422
  return;
3363
5423
  }
3364
5424
  if (source.runtime.session.isStreaming) {
3365
- send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
5425
+ send2(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
3366
5426
  return;
3367
5427
  }
3368
5428
  let result;
@@ -3370,11 +5430,11 @@ async function handle(ws, state, msg) {
3370
5430
  result = await workspaceManager.fork(workspaceId, source.sessionPath, msg.entryId);
3371
5431
  } catch (err2) {
3372
5432
  const message = err2 instanceof Error ? err2.message : String(err2);
3373
- send(ws, { type: "error", message, command: "fork" });
5433
+ send2(ws, { type: "error", message, command: "fork" });
3374
5434
  return;
3375
5435
  }
3376
5436
  if (result.cancelled || !result.runtime) {
3377
- send(ws, { type: "error", message: "fork cancelled", command: "fork" });
5437
+ send2(ws, { type: "error", message: "fork cancelled", command: "fork" });
3378
5438
  return;
3379
5439
  }
3380
5440
  promoteToPrimary(ws, state, workspaceId, result.runtime);
@@ -3384,7 +5444,7 @@ async function handle(ws, state, msg) {
3384
5444
  case "answer_question": {
3385
5445
  const primary = state.primary;
3386
5446
  if (!primary) {
3387
- send(ws, { type: "error", message: "not subscribed", command: "answer_question" });
5447
+ send2(ws, { type: "error", message: "not subscribed", command: "answer_question" });
3388
5448
  return;
3389
5449
  }
3390
5450
  resolveAnswer(msg.toolCallId, msg.answer, primary.runtime.session.sessionFile ?? null);
@@ -3393,28 +5453,28 @@ async function handle(ws, state, msg) {
3393
5453
  case "navigate_tree": {
3394
5454
  const primary = state.primary;
3395
5455
  if (!primary) {
3396
- send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5456
+ send2(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
3397
5457
  return;
3398
5458
  }
3399
5459
  if (msg.workspaceId !== primary.workspaceId) {
3400
- send(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
5460
+ send2(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
3401
5461
  return;
3402
5462
  }
3403
5463
  await withReplacementLock(primary.workspaceId, async () => {
3404
5464
  const current = state.primary;
3405
5465
  if (!current) {
3406
- send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5466
+ send2(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
3407
5467
  return;
3408
5468
  }
3409
5469
  if (current.runtime.session.isStreaming) {
3410
- send(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
5470
+ send2(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
3411
5471
  return;
3412
5472
  }
3413
5473
  const result = await current.runtime.session.navigateTree(msg.targetId, {
3414
5474
  summarize: msg.summarize,
3415
5475
  customInstructions: msg.customInstructions
3416
5476
  });
3417
- send(ws, {
5477
+ send2(ws, {
3418
5478
  type: "navigate_tree_result",
3419
5479
  workspaceId: current.workspaceId,
3420
5480
  editorText: result.editorText,
@@ -3426,27 +5486,34 @@ async function handle(ws, state, msg) {
3426
5486
  case "compact": {
3427
5487
  const primary = state.primary;
3428
5488
  if (!primary) {
3429
- send(ws, { type: "error", message: "not subscribed", command: "compact" });
5489
+ send2(ws, { type: "error", message: "not subscribed", command: "compact" });
3430
5490
  return;
3431
5491
  }
3432
5492
  if (primary.runtime.session.isStreaming) {
3433
- send(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
5493
+ send2(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
3434
5494
  return;
3435
5495
  }
3436
5496
  if (primary.runtime.session.isCompacting) {
3437
- send(ws, { type: "error", message: "compaction already in progress", command: "compact" });
5497
+ send2(ws, { type: "error", message: "compaction already in progress", command: "compact" });
3438
5498
  return;
3439
5499
  }
3440
5500
  primary.runtime.session.compact().catch((err2) => {
3441
5501
  const message = err2 instanceof Error ? err2.message : String(err2);
3442
- send(ws, { type: "error", message, command: "compact" });
5502
+ send2(ws, { type: "error", message, command: "compact" });
3443
5503
  });
3444
5504
  return;
3445
5505
  }
5506
+ case "terminal_open":
5507
+ case "terminal_input":
5508
+ case "terminal_resize":
5509
+ case "terminal_close": {
5510
+ await handleTerminalMessage(ws, state.terminals, msg);
5511
+ return;
5512
+ }
3446
5513
  default: {
3447
5514
  const _ = msg;
3448
5515
  void _;
3449
- send(ws, { type: "error", message: "unknown command" });
5516
+ send2(ws, { type: "error", message: "unknown command" });
3450
5517
  }
3451
5518
  }
3452
5519
  }
@@ -3478,7 +5545,7 @@ function bindPrimary(ws, state, workspaceId, runtime) {
3478
5545
  } else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
3479
5546
  assistantFirstTokenAt = performance.now();
3480
5547
  }
3481
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5548
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
3482
5549
  if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
3483
5550
  const now = performance.now();
3484
5551
  const timing = {
@@ -3486,7 +5553,7 @@ function bindPrimary(ws, state, workspaceId, runtime) {
3486
5553
  firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
3487
5554
  totalMs: Math.round(now - assistantStartAt)
3488
5555
  };
3489
- send(ws, { type: "event", workspaceId, sessionPath, payload: timing });
5556
+ send2(ws, { type: "event", workspaceId, sessionPath, payload: timing });
3490
5557
  assistantStartAt = void 0;
3491
5558
  assistantFirstTokenAt = void 0;
3492
5559
  }
@@ -3502,16 +5569,16 @@ function bindPrimary(ws, state, workspaceId, runtime) {
3502
5569
  runtime.session.state.pendingToolCalls,
3503
5570
  scanMessages
3504
5571
  )) {
3505
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5572
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
3506
5573
  }
3507
5574
  const inFlight = inFlightAssistantSnapshot(streamingMessage);
3508
5575
  if (inFlight) {
3509
5576
  for (const payload of inFlight) {
3510
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5577
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
3511
5578
  }
3512
5579
  }
3513
5580
  for (const payload of inFlightToolCallsSnapshot(sessionPath)) {
3514
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5581
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
3515
5582
  }
3516
5583
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
3517
5584
  }
@@ -3525,7 +5592,7 @@ function demotePrimaryToBackground(ws, state) {
3525
5592
  const unsubscribeSession = session.subscribe((ev) => {
3526
5593
  const payload = translatePiEvent(ev);
3527
5594
  if (!payload) return;
3528
- send(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
5595
+ send2(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
3529
5596
  if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
3530
5597
  sendContextUsage(ws, primary.runtime, primary.workspaceId, sessionPath);
3531
5598
  }
@@ -3541,7 +5608,7 @@ function demotePrimaryToBackground(ws, state) {
3541
5608
  const evicted = state.background.get(oldestKey);
3542
5609
  teardownBackground(state, oldestKey, ws);
3543
5610
  if (evicted) {
3544
- send(ws, {
5611
+ send2(ws, {
3545
5612
  type: "background_evicted",
3546
5613
  workspaceId: evicted.workspaceId,
3547
5614
  sessionPath: evicted.sessionPath
@@ -3557,7 +5624,7 @@ function teardownBackground(state, runtimeKey, ws) {
3557
5624
  if (ws) unrefWorkspaceSubscriber(state, bg.workspaceId, ws);
3558
5625
  }
3559
5626
  function sendSubscribed(ws, workspaceId, runtime) {
3560
- send(ws, {
5627
+ send2(ws, {
3561
5628
  type: "subscribed",
3562
5629
  workspaceId,
3563
5630
  sessionPath: runtime.session.sessionFile ?? null,
@@ -3577,7 +5644,7 @@ function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
3577
5644
  contextWindow: usage.contextWindow,
3578
5645
  percent: usage.percent
3579
5646
  };
3580
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5647
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
3581
5648
  }
3582
5649
  function detachPrimary(state, ws) {
3583
5650
  const primary = state.primary;
@@ -3591,18 +5658,19 @@ function detach(state, ws) {
3591
5658
  for (const runtimeKey of [...state.background.keys()]) {
3592
5659
  teardownBackground(state, runtimeKey, ws);
3593
5660
  }
5661
+ closeAllTerminals(state.terminals);
3594
5662
  }
3595
- function send(ws, msg) {
5663
+ function send2(ws, msg) {
3596
5664
  if (ws.readyState !== ws.OPEN) return;
3597
5665
  ws.send(JSON.stringify(msg));
3598
5666
  }
3599
5667
 
3600
5668
  // src/index.ts
3601
5669
  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");
5670
+ var app = new Hono6();
5671
+ var distDir = dirname6(fileURLToPath2(import.meta.url));
5672
+ var webRoot = resolve8(process.env.PI_PILOT_WEB_ROOT ?? join17(distDir, "..", "public"));
5673
+ var webIndexPath = join17(webRoot, "index.html");
3606
5674
  var mimeTypes = {
3607
5675
  ".css": "text/css; charset=utf-8",
3608
5676
  ".html": "text/html; charset=utf-8",
@@ -3628,7 +5696,7 @@ function safeResolveWebPath(pathname) {
3628
5696
  return void 0;
3629
5697
  }
3630
5698
  const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
3631
- const candidate = resolve5(webRoot, relativePath);
5699
+ const candidate = resolve8(webRoot, relativePath);
3632
5700
  if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
3633
5701
  return void 0;
3634
5702
  }
@@ -3636,7 +5704,7 @@ function safeResolveWebPath(pathname) {
3636
5704
  }
3637
5705
  async function readWebFile(path) {
3638
5706
  try {
3639
- return await readFile6(path);
5707
+ return await readFile10(path);
3640
5708
  } catch (err2) {
3641
5709
  const code = err2.code;
3642
5710
  if (code === "ENOENT" || code === "EISDIR") return void 0;
@@ -3649,7 +5717,7 @@ async function serveWeb(c) {
3649
5717
  const assetPath = safeResolveWebPath(pathname);
3650
5718
  if (!assetPath) return c.text("invalid asset path", 400);
3651
5719
  const asset = await readWebFile(assetPath);
3652
- const body = asset ?? await readFile6(webIndexPath);
5720
+ const body = asset ?? await readFile10(webIndexPath);
3653
5721
  const filePath = asset ? assetPath : webIndexPath;
3654
5722
  const headers = {
3655
5723
  "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",
@@ -3657,6 +5725,12 @@ async function serveWeb(c) {
3657
5725
  };
3658
5726
  return new Response(body, { headers });
3659
5727
  }
5728
+ app.use("*", async (c, next) => {
5729
+ if (!isAllowedHost(c.req.header("host"))) {
5730
+ return c.text("Forbidden", 403);
5731
+ }
5732
+ await next();
5733
+ });
3660
5734
  app.use(
3661
5735
  "/api/*",
3662
5736
  cors({
@@ -3668,7 +5742,8 @@ app.get("/api/health", (c) => c.json({ ok: true }));
3668
5742
  app.route("/api/workspaces", workspacesRoute);
3669
5743
  app.route("/api/fs", fsRoute);
3670
5744
  app.route("/api/model-configs", modelConfigsRoute);
3671
- if (existsSync(webIndexPath)) {
5745
+ app.route("/api/web-search", webSearchRoute);
5746
+ if (existsSync2(webIndexPath)) {
3672
5747
  app.get("*", serveWeb);
3673
5748
  } else {
3674
5749
  app.get(
@@ -3680,6 +5755,9 @@ if (existsSync(webIndexPath)) {
3680
5755
  );
3681
5756
  }
3682
5757
  await loadBuiltinPrefs();
5758
+ await loadSessionToolPrefs();
5759
+ await loadWebSearchPrefs();
5760
+ await sweepOrphanedChildrenOnBoot();
3683
5761
  var server = serve(
3684
5762
  {
3685
5763
  fetch: app.fetch,
@@ -3698,9 +5776,20 @@ async function shutdown(reason) {
3698
5776
  } catch (e) {
3699
5777
  console.error("[pi-pilot] disposeAll error:", e);
3700
5778
  }
5779
+ const sweptChildren = killAllChildren();
5780
+ if (sweptChildren > 0) {
5781
+ console.warn(`[pi-pilot] killed ${sweptChildren} lingering subagent child(ren)`);
5782
+ }
3701
5783
  server.close(() => process.exit(0));
3702
5784
  setTimeout(() => process.exit(1), 3e3).unref();
3703
5785
  }
5786
+ process.on("unhandledRejection", (reason) => {
5787
+ console.error("[pi-pilot] unhandled rejection (process kept alive):", reason);
5788
+ });
5789
+ process.on("uncaughtException", (err2) => {
5790
+ console.error("[pi-pilot] uncaught exception:", err2);
5791
+ void shutdown("uncaughtException");
5792
+ });
3704
5793
  process.on("SIGINT", () => void shutdown("SIGINT"));
3705
5794
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
3706
5795
  //# sourceMappingURL=index.js.map