@jyork0828/pi-pilot 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { existsSync } from "fs";
5
- import { readFile as readFile5 } from "fs/promises";
6
- import { dirname as dirname6, extname, join as join9, resolve as resolve5, sep as sep3 } from "path";
7
- import { fileURLToPath } from "url";
4
+ import { existsSync as existsSync2 } from "fs";
5
+ import { readFile as readFile9 } from "fs/promises";
6
+ import { dirname as dirname6, extname, join as join16, resolve as resolve7, sep as sep3 } from "path";
7
+ import { fileURLToPath as fileURLToPath2 } from "url";
8
8
  import { serve } from "@hono/node-server";
9
- import { Hono as Hono4 } from "hono";
9
+ import { Hono as Hono6 } from "hono";
10
10
  import { cors } from "hono/cors";
11
11
 
12
12
  // src/config.ts
@@ -41,9 +41,9 @@ function configureHttpProxy() {
41
41
  }
42
42
 
43
43
  // src/api/workspaces.ts
44
- import { stat as stat2 } from "fs/promises";
45
- import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
46
- import { Hono } from "hono";
44
+ import { readFile as readFile7, stat as stat2 } from "fs/promises";
45
+ import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve5 } from "path";
46
+ import { Hono as Hono2 } from "hono";
47
47
 
48
48
  // src/storage/resource-writer.ts
49
49
  import {
@@ -54,7 +54,7 @@ import {
54
54
  unlink,
55
55
  writeFile
56
56
  } from "fs/promises";
57
- import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
57
+ import { basename, dirname, isAbsolute, join as join2, resolve, sep } from "path";
58
58
  var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
59
59
  var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
60
60
  function ensureSkillName(name) {
@@ -277,9 +277,9 @@ async function updatePrompt(opts) {
277
277
  await writeFile(newPath, text, "utf8");
278
278
  try {
279
279
  await unlink(opts.filePath);
280
- } catch (err) {
280
+ } catch (err2) {
281
281
  await unlink(newPath).catch(() => void 0);
282
- throw err;
282
+ throw err2;
283
283
  }
284
284
  return newPath;
285
285
  }
@@ -311,7 +311,7 @@ async function readPromptFile(filePath, roots) {
311
311
  assertUnder(filePath, [roots.userPrompts, roots.projectPrompts]);
312
312
  const text = await readFile(filePath, "utf8");
313
313
  const { frontmatter, body } = parseFile(text);
314
- const stem = basename(filePath).replace(/\.md$/, "");
314
+ const stem = basename(filePath, ".md");
315
315
  return {
316
316
  body,
317
317
  name: stem,
@@ -319,10 +319,6 @@ async function readPromptFile(filePath, roots) {
319
319
  argumentHint: stringOr(frontmatter["argument-hint"], void 0)
320
320
  };
321
321
  }
322
- function basename(p) {
323
- const parts = p.split(sep);
324
- return parts.at(-1) || p;
325
- }
326
322
  function stringOr(value, fallback) {
327
323
  return typeof value === "string" ? value : fallback;
328
324
  }
@@ -338,38 +334,72 @@ async function exists(p) {
338
334
  }
339
335
  }
340
336
  var HttpError = class extends Error {
341
- constructor(status, message) {
337
+ constructor(status2, message) {
342
338
  super(message);
343
- this.status = status;
339
+ this.status = status2;
344
340
  }
345
341
  status;
346
342
  };
347
343
 
348
344
  // src/storage/workspace-registry.ts
349
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
350
- import { dirname as dirname2, join as join3 } from "path";
345
+ import { readFile as readFile2 } from "fs/promises";
346
+ import { join as join3 } from "path";
347
+ import { randomUUID as randomUUID2 } from "crypto";
348
+
349
+ // src/storage/atomic-json.ts
350
+ import { chmod, mkdir as mkdir2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
351
+ import { dirname as dirname2 } from "path";
351
352
  import { randomUUID } from "crypto";
353
+ async function writeJsonAtomic(filePath, data, opts) {
354
+ await mkdir2(dirname2(filePath), { recursive: true });
355
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
356
+ const text = JSON.stringify(data, null, 2);
357
+ try {
358
+ if (opts?.mode !== void 0) {
359
+ await writeFile2(tmp, text, { encoding: "utf8", mode: opts.mode });
360
+ } else {
361
+ await writeFile2(tmp, text, "utf8");
362
+ }
363
+ await rename(tmp, filePath);
364
+ } catch (err2) {
365
+ await rm2(tmp, { force: true }).catch(() => {
366
+ });
367
+ throw err2;
368
+ }
369
+ if (opts?.mode !== void 0) {
370
+ try {
371
+ await chmod(filePath, opts.mode);
372
+ } catch {
373
+ }
374
+ }
375
+ }
376
+
377
+ // src/storage/workspace-registry.ts
352
378
  var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
353
379
  var cache;
380
+ var writeChain = Promise.resolve();
381
+ function serializedWrite(fn) {
382
+ writeChain = writeChain.then(fn, fn);
383
+ return writeChain;
384
+ }
354
385
  async function load() {
355
386
  if (cache) return cache;
356
387
  try {
357
388
  const raw = await readFile2(REGISTRY_PATH, "utf8");
358
389
  cache = JSON.parse(raw);
359
390
  if (!Array.isArray(cache.workspaces)) cache = { workspaces: [] };
360
- } catch (err) {
361
- if (err.code === "ENOENT") {
391
+ } catch (err2) {
392
+ if (err2.code === "ENOENT") {
362
393
  cache = { workspaces: [] };
363
394
  } else {
364
- throw err;
395
+ throw err2;
365
396
  }
366
397
  }
367
398
  return cache;
368
399
  }
369
400
  async function save() {
370
401
  if (!cache) return;
371
- await mkdir2(dirname2(REGISTRY_PATH), { recursive: true });
372
- await writeFile2(REGISTRY_PATH, JSON.stringify(cache, null, 2), "utf8");
402
+ await writeJsonAtomic(REGISTRY_PATH, cache);
373
403
  }
374
404
  async function listWorkspaces() {
375
405
  const r = await load();
@@ -380,26 +410,70 @@ async function getWorkspace(id) {
380
410
  return r.workspaces.find((w) => w.id === id);
381
411
  }
382
412
  async function addWorkspace(input) {
383
- const r = await load();
384
- const existing = r.workspaces.find((w) => w.path === input.path);
385
- if (existing) return existing;
386
- const ws = {
387
- id: randomUUID(),
388
- name: input.name,
389
- path: input.path,
390
- addedAt: (/* @__PURE__ */ new Date()).toISOString()
391
- };
392
- r.workspaces.push(ws);
393
- await save();
394
- return ws;
413
+ let result;
414
+ await serializedWrite(async () => {
415
+ const r = await load();
416
+ const existing = r.workspaces.find((w) => w.path === input.path);
417
+ if (existing) {
418
+ result = existing;
419
+ return;
420
+ }
421
+ const ws = {
422
+ id: randomUUID2(),
423
+ name: input.name,
424
+ path: input.path,
425
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
426
+ };
427
+ r.workspaces.push(ws);
428
+ await save();
429
+ result = ws;
430
+ });
431
+ return result;
395
432
  }
396
433
  async function removeWorkspace(id) {
397
- const r = await load();
398
- const before = r.workspaces.length;
399
- r.workspaces = r.workspaces.filter((w) => w.id !== id);
400
- if (r.workspaces.length === before) return false;
401
- await save();
402
- return true;
434
+ let removed = false;
435
+ await serializedWrite(async () => {
436
+ const r = await load();
437
+ const before = r.workspaces.length;
438
+ r.workspaces = r.workspaces.filter((w) => w.id !== id);
439
+ if (r.workspaces.length === before) return;
440
+ removed = true;
441
+ await save();
442
+ });
443
+ return removed;
444
+ }
445
+ async function setWorkspaceTrustProjectAgents(id, trusted) {
446
+ let updated;
447
+ await serializedWrite(async () => {
448
+ const r = await load();
449
+ const ws = r.workspaces.find((w) => w.id === id);
450
+ if (!ws) return;
451
+ if (trusted) ws.trustProjectAgents = true;
452
+ else delete ws.trustProjectAgents;
453
+ await save();
454
+ updated = ws;
455
+ });
456
+ return updated;
457
+ }
458
+ async function reorderWorkspaces(ids) {
459
+ await serializedWrite(async () => {
460
+ const r = await load();
461
+ const byId = new Map(r.workspaces.map((w) => [w.id, w]));
462
+ const reordered = [];
463
+ const seen = /* @__PURE__ */ new Set();
464
+ for (const id of ids) {
465
+ const ws = byId.get(id);
466
+ if (ws && !seen.has(id)) {
467
+ reordered.push(ws);
468
+ seen.add(id);
469
+ }
470
+ }
471
+ for (const ws of r.workspaces) {
472
+ if (!seen.has(ws.id)) reordered.push(ws);
473
+ }
474
+ r.workspaces = reordered;
475
+ await save();
476
+ });
403
477
  }
404
478
 
405
479
  // src/storage/workspace-stats.ts
@@ -417,13 +491,14 @@ async function enrichWorkspace(ws) {
417
491
  path: ws.path,
418
492
  addedAt: ws.addedAt,
419
493
  gitBranch: stats.gitBranch,
420
- fileCount: stats.fileCount
494
+ fileCount: stats.fileCount,
495
+ trustProjectAgents: ws.trustProjectAgents === true
421
496
  };
422
497
  }
423
498
  async function getStats(path) {
424
499
  const now = Date.now();
425
- const cached = cache2.get(path);
426
- if (cached && cached.expiresAt > now) return cached;
500
+ const cached2 = cache2.get(path);
501
+ if (cached2 && cached2.expiresAt > now) return cached2;
427
502
  const pending2 = inflight.get(path);
428
503
  if (pending2) return pending2;
429
504
  const probe = probeStats(path).then((stats) => {
@@ -493,66 +568,332 @@ async function runGit(cwd, args) {
493
568
 
494
569
  // src/workspace-manager.ts
495
570
  import { unlink as unlink2 } from "fs/promises";
496
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
571
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
497
572
  import {
498
573
  createAgentSessionFromServices,
499
574
  createAgentSessionRuntime,
500
575
  createAgentSessionServices,
501
- getAgentDir,
576
+ getAgentDir as getAgentDir2,
502
577
  SessionManager
503
578
  } from "@earendil-works/pi-coding-agent";
504
579
 
505
- // src/extensions/plan/schema.ts
580
+ // src/storage/session-tool-prefs.ts
581
+ import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile3 } from "fs/promises";
582
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "path";
583
+ var PREFS_PATH = join4(config.dataDir, "session-tools.json");
584
+ var cache3 = { sessions: {} };
585
+ async function loadSessionToolPrefs() {
586
+ try {
587
+ const raw = await readFile3(PREFS_PATH, "utf8");
588
+ const parsed = JSON.parse(raw);
589
+ cache3 = { sessions: normalizeSessions(parsed.sessions) };
590
+ } catch (err2) {
591
+ cache3 = { sessions: {} };
592
+ if (err2.code !== "ENOENT") {
593
+ console.warn(`[session-tool-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
594
+ }
595
+ }
596
+ }
597
+ function keyOf(workspaceId, session) {
598
+ return session.sessionFile ? resolve2(session.sessionFile) : `${workspaceId}:${session.sessionId}`;
599
+ }
600
+ function storedDisabled(workspaceId, session) {
601
+ return cache3.sessions[keyOf(workspaceId, session)]?.disabled ?? [];
602
+ }
603
+ async function persistActiveTools(workspaceId, session, activeNames) {
604
+ const registered = session.getAllTools().map((t) => t.name);
605
+ const registeredSet = new Set(registered);
606
+ const active = new Set(activeNames);
607
+ const disabled = /* @__PURE__ */ new Set();
608
+ for (const name of registered) {
609
+ if (!active.has(name)) disabled.add(name);
610
+ }
611
+ for (const name of storedDisabled(workspaceId, session)) {
612
+ if (!registeredSet.has(name)) disabled.add(name);
613
+ }
614
+ cache3 = {
615
+ sessions: {
616
+ ...cache3.sessions,
617
+ [keyOf(workspaceId, session)]: {
618
+ disabled: sortUnique(disabled),
619
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
620
+ }
621
+ }
622
+ };
623
+ await save2();
624
+ session.setActiveToolsByName(registered.filter((name) => !disabled.has(name)));
625
+ }
626
+ function reapplyToolPrefs(workspaceId, session) {
627
+ const disabled = storedDisabled(workspaceId, session);
628
+ if (disabled.length === 0) return;
629
+ const disabledSet = new Set(disabled);
630
+ const registered = session.getAllTools().map((t) => t.name);
631
+ session.setActiveToolsByName(registered.filter((name) => !disabledSet.has(name)));
632
+ }
633
+ async function forgetSessionToolPrefs(sessionPath) {
634
+ const key = resolve2(sessionPath);
635
+ if (!(key in cache3.sessions)) return;
636
+ const next = { ...cache3.sessions };
637
+ delete next[key];
638
+ cache3 = { sessions: next };
639
+ await save2();
640
+ }
641
+ function sortUnique(values) {
642
+ return [...new Set(values)].sort((a, b) => a.localeCompare(b));
643
+ }
644
+ function normalizeSessions(value) {
645
+ if (!value || typeof value !== "object") return {};
646
+ const out = {};
647
+ for (const [key, entry] of Object.entries(value)) {
648
+ if (!entry || typeof entry !== "object") continue;
649
+ const disabled = entry.disabled;
650
+ if (!Array.isArray(disabled)) continue;
651
+ const updatedAt = entry.updatedAt;
652
+ out[key] = {
653
+ disabled: sortUnique(disabled.filter((n) => typeof n === "string")),
654
+ updatedAt: typeof updatedAt === "string" ? updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
655
+ };
656
+ }
657
+ return out;
658
+ }
659
+ var writeChain2 = Promise.resolve();
660
+ function save2() {
661
+ writeChain2 = writeChain2.catch(() => {
662
+ }).then(async () => {
663
+ await mkdir3(dirname3(PREFS_PATH), { recursive: true });
664
+ const tmp = `${PREFS_PATH}.tmp`;
665
+ await writeFile3(tmp, JSON.stringify(cache3, null, 2), "utf8");
666
+ await rename2(tmp, PREFS_PATH);
667
+ });
668
+ return writeChain2;
669
+ }
670
+
671
+ // src/extensions/todo/schema.ts
506
672
  import { Type } from "typebox";
507
- var planItemStatusSchema = Type.Union([
673
+ var EMPTY_STATE = { tasks: [], nextId: 1 };
674
+ var VALID_TRANSITIONS = {
675
+ pending: /* @__PURE__ */ new Set(["in_progress", "completed", "deleted"]),
676
+ in_progress: /* @__PURE__ */ new Set(["pending", "completed", "deleted"]),
677
+ completed: /* @__PURE__ */ new Set(["deleted"]),
678
+ deleted: /* @__PURE__ */ new Set()
679
+ };
680
+ function isTransitionValid(from, to) {
681
+ if (from === to) return true;
682
+ return VALID_TRANSITIONS[from].has(to);
683
+ }
684
+ var ActionEnum = Type.Union([
685
+ Type.Literal("create"),
686
+ Type.Literal("update"),
687
+ Type.Literal("list"),
688
+ Type.Literal("get"),
689
+ Type.Literal("delete"),
690
+ Type.Literal("clear")
691
+ ]);
692
+ var StatusEnum = Type.Union([
508
693
  Type.Literal("pending"),
509
694
  Type.Literal("in_progress"),
510
- Type.Literal("completed")
695
+ Type.Literal("completed"),
696
+ Type.Literal("deleted")
511
697
  ]);
512
- var planItemSchema = Type.Object({
513
- id: Type.String({ description: 'Short stable identifier for the item (kebab-case, e.g. "wire-factory").' }),
514
- title: Type.String({ description: "One-line description of the step. Specific and verifiable." }),
515
- status: planItemStatusSchema,
516
- note: Type.Optional(Type.String({ description: "Optional short context \u2014 blocker, decision, or follow-up." }))
517
- });
518
- var updatePlanParamsSchema = Type.Object({
519
- items: Type.Array(planItemSchema, {
520
- description: "The full ordered plan. Always send the complete list; previous tool calls are not merged."
521
- })
698
+ var todoParamsSchema = Type.Object({
699
+ action: ActionEnum,
700
+ subject: Type.Optional(Type.String({ description: "Task subject line (required for create)" })),
701
+ description: Type.Optional(Type.String({ description: "Long-form task description" })),
702
+ status: Type.Optional(StatusEnum),
703
+ id: Type.Optional(Type.Number({ description: "Task id (required for update, get, delete)" })),
704
+ includeDeleted: Type.Optional(Type.Boolean({
705
+ description: "If true, list action returns deleted (tombstoned) tasks as well. Default: false."
706
+ }))
522
707
  });
523
708
 
524
- // src/extensions/plan/factory.ts
525
- var planExtensionFactory = (pi) => {
709
+ // src/extensions/todo/reducer.ts
710
+ function err(state, message) {
711
+ return { state, text: `Error: ${message}`, error: message };
712
+ }
713
+ function formatListLine(t) {
714
+ return `[${t.status}] #${t.id} ${t.subject}`;
715
+ }
716
+ function applyTodoAction(state, action, params) {
717
+ switch (action) {
718
+ case "create": {
719
+ if (!params.subject?.trim()) {
720
+ return err(state, "subject required for create");
721
+ }
722
+ const task = {
723
+ id: state.nextId,
724
+ subject: params.subject,
725
+ status: "pending"
726
+ };
727
+ if (params.description) task.description = params.description;
728
+ const newTasks = [...state.tasks, task];
729
+ return {
730
+ state: { tasks: newTasks, nextId: state.nextId + 1 },
731
+ text: `Created #${task.id}: ${task.subject} (pending)`
732
+ };
733
+ }
734
+ case "update": {
735
+ if (params.id === void 0) return err(state, "id required for update");
736
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
737
+ if (idx === -1) return err(state, `#${params.id} not found`);
738
+ const current = state.tasks[idx];
739
+ const hasMutation = params.subject !== void 0 || params.description !== void 0 || params.status !== void 0;
740
+ if (!hasMutation) return err(state, "update requires at least one mutable field");
741
+ let newStatus = current.status;
742
+ if (params.status !== void 0) {
743
+ if (!isTransitionValid(current.status, params.status)) {
744
+ return err(state, `illegal transition ${current.status} \u2192 ${params.status}`);
745
+ }
746
+ newStatus = params.status;
747
+ }
748
+ const updated = { ...current, status: newStatus };
749
+ if (params.subject !== void 0) updated.subject = params.subject;
750
+ if (params.description !== void 0) updated.description = params.description;
751
+ const newTasks = [...state.tasks];
752
+ newTasks[idx] = updated;
753
+ const transition = current.status !== newStatus ? ` (${current.status} \u2192 ${newStatus})` : "";
754
+ return {
755
+ state: { tasks: newTasks, nextId: state.nextId },
756
+ text: `Updated #${updated.id}${transition}`
757
+ };
758
+ }
759
+ case "list": {
760
+ let view = state.tasks;
761
+ if (!params.includeDeleted) view = view.filter((t) => t.status !== "deleted");
762
+ if (params.status) view = view.filter((t) => t.status === params.status);
763
+ return {
764
+ state,
765
+ text: view.length === 0 ? "No tasks" : view.map(formatListLine).join("\n")
766
+ };
767
+ }
768
+ case "get": {
769
+ if (params.id === void 0) return err(state, "id required for get");
770
+ const task = state.tasks.find((t) => t.id === params.id);
771
+ if (!task) return err(state, `#${params.id} not found`);
772
+ const lines = [`#${task.id} [${task.status}] ${task.subject}`];
773
+ if (task.description) lines.push(` description: ${task.description}`);
774
+ return { state, text: lines.join("\n") };
775
+ }
776
+ case "delete": {
777
+ if (params.id === void 0) return err(state, "id required for delete");
778
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
779
+ if (idx === -1) return err(state, `#${params.id} not found`);
780
+ const current = state.tasks[idx];
781
+ if (current.status === "deleted") return err(state, `#${current.id} is already deleted`);
782
+ const updated = { ...current, status: "deleted" };
783
+ const newTasks = [...state.tasks];
784
+ newTasks[idx] = updated;
785
+ return {
786
+ state: { tasks: newTasks, nextId: state.nextId },
787
+ text: `Deleted #${updated.id}: ${updated.subject}`
788
+ };
789
+ }
790
+ case "clear": {
791
+ const count = state.tasks.length;
792
+ return {
793
+ state: { tasks: [], nextId: 1 },
794
+ text: `Cleared ${count} tasks`
795
+ };
796
+ }
797
+ }
798
+ }
799
+
800
+ // src/extensions/todo/factory.ts
801
+ var TOOL_NAME = "todo";
802
+ function replayFromBranch(ctx) {
803
+ let result = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
804
+ for (const entry of ctx.sessionManager.getBranch()) {
805
+ const e = entry;
806
+ if (e.type !== "message") continue;
807
+ const msg = e.message;
808
+ if (msg?.role !== "toolResult" || msg.toolName !== TOOL_NAME) continue;
809
+ if (msg.isError) continue;
810
+ const details = msg.details;
811
+ if (!details || !Array.isArray(details.tasks) || typeof details.nextId !== "number") continue;
812
+ if (details.error) continue;
813
+ result = {
814
+ tasks: details.tasks.map((t) => ({ ...t })),
815
+ nextId: details.nextId
816
+ };
817
+ }
818
+ return result;
819
+ }
820
+ var todoExtensionFactory = (pi) => {
821
+ let state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
526
822
  pi.registerTool({
527
- name: "update_plan",
528
- label: "Plan",
529
- description: "Publish or refresh the working plan for the current task. Use for any task that takes 3+ discrete steps. Always send the full ordered list; previous calls are replaced, not merged.",
530
- parameters: updatePlanParamsSchema,
531
- promptSnippet: "update_plan: maintain a live checklist for multi-step tasks; update statuses as you progress.",
823
+ name: TOOL_NAME,
824
+ label: "Todo",
825
+ description: "Manage a task list for tracking multi-step progress. Actions: create (new task), update (change status/fields), list (all tasks, optionally filtered), get (single task), delete (tombstone), clear (reset). Status: pending \u2192 in_progress \u2192 completed, plus deleted tombstone.",
826
+ promptSnippet: "Manage a task list to track multi-step progress.",
532
827
  promptGuidelines: [
533
- "For tasks with 3+ discrete steps, call update_plan once with the full list before starting work.",
534
- "After completing each step, call update_plan again with refreshed statuses, then continue immediately \u2014 do not pause the turn just because the plan was updated.",
535
- 'Plan items should be specific and verifiable (e.g. "Add typebox dependency to packages/server"), not vague ("Set up infrastructure").',
536
- "Exactly one item should be in_progress at a time. Mark completed only when the work is actually done."
828
+ "Use `todo` for complex work with 3+ steps or when the user gives you an explicit list of tasks. Skip it for single trivial tasks and purely conversational requests.",
829
+ "When starting any task, mark it in_progress BEFORE beginning work. Mark it completed IMMEDIATELY when done \u2014 never batch completions. Exactly one task should be in_progress at a time.",
830
+ "Never mark a task completed if tests are failing, the implementation is partial, or you hit unresolved errors \u2014 keep it in_progress and address the issue first."
537
831
  ],
538
- execute: async (_toolCallId, params) => ({
539
- content: [
540
- {
541
- type: "text",
542
- text: `Plan updated (${params.items.length} item${params.items.length === 1 ? "" : "s"}).`
543
- }
544
- ],
545
- details: params
546
- })
832
+ parameters: todoParamsSchema,
833
+ async execute(_toolCallId, params) {
834
+ const result = applyTodoAction(state, params.action, params);
835
+ state = result.state;
836
+ const details = {
837
+ action: params.action,
838
+ tasks: state.tasks,
839
+ nextId: state.nextId,
840
+ ...result.error ? { error: result.error } : {}
841
+ };
842
+ return {
843
+ content: [{ type: "text", text: result.text }],
844
+ details
845
+ };
846
+ }
847
+ });
848
+ pi.registerCommand("todos", {
849
+ description: "Show current todo list grouped by status.",
850
+ handler: async () => {
851
+ const visible = state.tasks.filter((t) => t.status !== "deleted");
852
+ if (visible.length === 0) {
853
+ pi.sendUserMessage("Show the current todo list.");
854
+ return;
855
+ }
856
+ const pending2 = visible.filter((t) => t.status === "pending");
857
+ const inProgress = visible.filter((t) => t.status === "in_progress");
858
+ const completed = visible.filter((t) => t.status === "completed");
859
+ const lines = [];
860
+ const total = visible.length;
861
+ const doneCount = completed.length;
862
+ lines.push(`Todos (${doneCount}/${total})`);
863
+ if (inProgress.length > 0) {
864
+ lines.push("\u2500\u2500 In Progress \u2500\u2500");
865
+ for (const t of inProgress) lines.push(` \u25D0 #${t.id} ${t.subject}`);
866
+ }
867
+ if (pending2.length > 0) {
868
+ lines.push("\u2500\u2500 Pending \u2500\u2500");
869
+ for (const t of pending2) lines.push(` \u25CB #${t.id} ${t.subject}`);
870
+ }
871
+ if (completed.length > 0) {
872
+ lines.push("\u2500\u2500 Completed \u2500\u2500");
873
+ for (const t of completed) lines.push(` \u2713 #${t.id} ${t.subject}`);
874
+ }
875
+ pi.sendUserMessage(`Current todos:
876
+ ${lines.join("\n")}`);
877
+ }
878
+ });
879
+ pi.on("session_start", async (_event, ctx) => {
880
+ state = replayFromBranch(ctx);
881
+ });
882
+ pi.on("session_compact", async (_event, ctx) => {
883
+ try {
884
+ state = replayFromBranch(ctx);
885
+ } catch {
886
+ }
547
887
  });
548
- pi.registerCommand("plan", {
549
- description: "Ask the agent to draft a plan for the current task.",
550
- handler: async (args) => {
551
- const task = args.trim();
552
- const message = task ? `Draft a plan for: ${task}. Use the update_plan tool to publish it before starting any work.` : "Draft a plan for the current task. Use the update_plan tool to publish it before starting any work.";
553
- pi.sendUserMessage(message);
888
+ pi.on("session_tree", async (_event, ctx) => {
889
+ try {
890
+ state = replayFromBranch(ctx);
891
+ } catch {
554
892
  }
555
893
  });
894
+ pi.on("session_shutdown", async () => {
895
+ state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
896
+ });
556
897
  };
557
898
 
558
899
  // src/extensions/ask_user/schema.ts
@@ -616,13 +957,6 @@ function resolveAnswer(toolCallId, answer, expectedSessionFile) {
616
957
  entry.resolve(answer);
617
958
  return true;
618
959
  }
619
- function cancelPendingExcept(keepSessionFile) {
620
- for (const [id, entry] of pending) {
621
- if (entry.sessionFile === keepSessionFile) continue;
622
- pending.delete(id);
623
- entry.reject(new Error("Session replaced before answer arrived"));
624
- }
625
- }
626
960
  function cancelPendingForSession(sessionFile) {
627
961
  for (const [id, entry] of pending) {
628
962
  if (entry.sessionFile !== sessionFile) continue;
@@ -673,7 +1007,7 @@ function waitForAnswer({
673
1007
  sessionFile,
674
1008
  signal
675
1009
  }) {
676
- return new Promise((resolve6, reject) => {
1010
+ return new Promise((resolve8, reject) => {
677
1011
  let settled = false;
678
1012
  let timeoutHandle;
679
1013
  const cleanup = () => {
@@ -685,13 +1019,13 @@ function waitForAnswer({
685
1019
  if (settled) return;
686
1020
  settled = true;
687
1021
  cleanup();
688
- resolve6(a);
1022
+ resolve8(a);
689
1023
  };
690
- const finishErr = (err) => {
1024
+ const finishErr = (err2) => {
691
1025
  if (settled) return;
692
1026
  settled = true;
693
1027
  cleanup();
694
- reject(err);
1028
+ reject(err2);
695
1029
  };
696
1030
  const onAbort = () => finishErr(new Error("Aborted by user"));
697
1031
  if (signal?.aborted) {
@@ -709,7 +1043,7 @@ function waitForAnswer({
709
1043
  args: params,
710
1044
  sessionFile,
711
1045
  resolve: (answer) => finishOk(answer),
712
- reject: (err) => finishErr(err)
1046
+ reject: (err2) => finishErr(err2)
713
1047
  });
714
1048
  });
715
1049
  }
@@ -759,102 +1093,1392 @@ function descriptionSuffix(params, index) {
759
1093
  return desc ? ` \u2014 ${desc}` : "";
760
1094
  }
761
1095
 
762
- // src/storage/builtin-extension-prefs.ts
763
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
764
- import { dirname as dirname3, join as join4 } from "path";
765
- var PREFS_PATH = join4(config.dataDir, "builtin-extensions.json");
766
- var cache3 = { disabled: [] };
767
- async function loadBuiltinPrefs() {
768
- try {
769
- const raw = await readFile3(PREFS_PATH, "utf8");
770
- const parsed = JSON.parse(raw);
771
- cache3 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
772
- } catch (err) {
773
- cache3 = { disabled: [] };
774
- if (err.code !== "ENOENT") {
775
- console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH}:`, err);
1096
+ // src/extensions/artifact/schema.ts
1097
+ import { Type as Type3 } from "typebox";
1098
+ var TypeEnum = Type3.Union(
1099
+ [
1100
+ Type3.Literal("html"),
1101
+ Type3.Literal("svg"),
1102
+ Type3.Literal("markdown"),
1103
+ Type3.Literal("code")
1104
+ ],
1105
+ {
1106
+ description: 'How to render the content: "html" (a self-contained HTML document or fragment, run in a sandboxed iframe), "svg" (SVG markup), "markdown" (rich text), or "code" (a source file shown with syntax highlighting).'
1107
+ }
1108
+ );
1109
+ var createArtifactParamsSchema = Type3.Object({
1110
+ id: Type3.Optional(
1111
+ Type3.String({
1112
+ description: 'Stable identifier. Omit on first creation. To REVISE an existing artifact, pass the same id you used before \u2014 that records a new version instead of a separate artifact. Use a short slug like "landing-page".'
1113
+ })
1114
+ ),
1115
+ type: TypeEnum,
1116
+ title: Type3.String({
1117
+ description: "Short human-readable title shown in the artifact panel."
1118
+ }),
1119
+ content: Type3.String({
1120
+ description: "The full artifact content \u2014 the complete document, markup, or source."
1121
+ }),
1122
+ language: Type3.Optional(
1123
+ Type3.String({
1124
+ description: 'For type="code", the language id for syntax highlighting (e.g. "python", "typescript").'
1125
+ })
1126
+ )
1127
+ });
1128
+
1129
+ // src/extensions/artifact/factory.ts
1130
+ var TOOL_NAME2 = "create_artifact";
1131
+ var artifactExtensionFactory = (pi) => {
1132
+ pi.registerTool({
1133
+ name: TOOL_NAME2,
1134
+ label: "Create artifact",
1135
+ description: 'Publish a substantial, self-contained piece of content as an "artifact" the user can view and iterate on in a dedicated side panel: a web page (html), an SVG diagram (svg), a document (markdown), or a source file (code). Reuse the same `id` to revise an existing artifact (records a new version).',
1136
+ promptSnippet: "create_artifact: render substantial, self-contained content (web page / SVG / document / code file) in a side panel the user can view and iterate on.",
1137
+ promptGuidelines: [
1138
+ "Use create_artifact for substantial, self-contained, reusable content the user will want to view, keep, or iterate on \u2014 a runnable HTML page, an SVG diagram, a full document, or a standalone code file. Do NOT use it for short snippets, command output, or your normal conversational answer; a fenced code block in your reply is better for those.",
1139
+ "To revise an artifact, call create_artifact again with the SAME id and the full updated content \u2014 this records a new version the user can step through. Don't spawn a near-duplicate artifact under a new id.",
1140
+ 'Put the entire content in `content`, give it a concise `title`, and pick the `type` that matches how it should render. For type="code", set `language`.',
1141
+ "After creating an artifact, keep your chat reply short \u2014 the content lives in the panel, so don't paste it again in prose."
1142
+ ],
1143
+ parameters: createArtifactParamsSchema,
1144
+ execute: async (toolCallId, params) => {
1145
+ const id = params.id?.trim() || toolCallId;
1146
+ const details = {
1147
+ id,
1148
+ type: params.type,
1149
+ title: params.title
1150
+ };
1151
+ const text = `Artifact "${params.title}" (${params.type}) is now shown to the user in the Artifacts panel. Its id is "${id}" \u2014 pass that same id to create_artifact to revise it.`;
1152
+ return {
1153
+ content: [{ type: "text", text }],
1154
+ details
1155
+ };
776
1156
  }
1157
+ });
1158
+ };
1159
+
1160
+ // src/extensions/subagent/agents.ts
1161
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
1162
+ import { join as join6 } from "path";
1163
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
1164
+
1165
+ // src/extensions/subagent/builtin-agents.ts
1166
+ var COMMON_RULES = `Hard rules:
1167
+ - You run headless as a subagent: there is NO user to talk to. Never ask questions, never wait for confirmation \u2014 decide and proceed.
1168
+ - NEVER run the \`pi\` CLI or spawn any other agent. No nesting.
1169
+ - End with ONE final message that is a complete, self-contained report \u2014 it is the ONLY thing returned to the agent that delegated to you. Keep it under ~8000 characters and reference code as \`path:line\`.`;
1170
+ var scout = {
1171
+ name: "scout",
1172
+ source: "builtin",
1173
+ description: "Fast read-only codebase recon: locate files, symbols, flows, conventions, and report compressed findings. Cheap to use; cannot edit anything.",
1174
+ tools: ["read", "grep", "find", "ls"],
1175
+ systemPrompt: `You are "scout", a read-only reconnaissance subagent.
1176
+
1177
+ ${COMMON_RULES}
1178
+
1179
+ Method: start broad (find/ls/grep), then read only the spans that matter. Prefer reading slices over whole files. Stop as soon as you can answer confidently.
1180
+
1181
+ Final report shape: a short answer first, then the supporting map \u2014 relevant files with one-line roles, key symbols as \`path:line\`, and any conventions or gotchas the delegator should know. Say explicitly what you did NOT verify.`
1182
+ };
1183
+ var worker = {
1184
+ name: "worker",
1185
+ source: "builtin",
1186
+ description: "General-purpose implementer with the full default toolset (bash/edit/write). Use for a self-contained change with a precise brief; verifies its own work.",
1187
+ systemPrompt: `You are "worker", an implementation subagent.
1188
+
1189
+ ${COMMON_RULES}
1190
+
1191
+ Method: read the relevant code before changing it; follow the surrounding style exactly; make the smallest change that satisfies the brief. Verify with the project's own commands (typecheck / tests / build) when available \u2014 report what you ran and its outcome honestly.
1192
+
1193
+ Final report shape: what changed (file by file, one line each), how it was verified (commands + results), and any caveats or follow-ups. If you could not finish, say precisely how far you got and what is left.`
1194
+ };
1195
+ var reviewer = {
1196
+ name: "reviewer",
1197
+ source: "builtin",
1198
+ description: "Code review of specific files or diffs: correctness, edge cases, convention drift. Read-mostly (bash for git diff / running tests). Returns prioritized findings.",
1199
+ tools: ["read", "grep", "find", "ls", "bash"],
1200
+ systemPrompt: `You are "reviewer", a code-review subagent. Your job is to FIND problems, not to fix them \u2014 do not edit any file.
1201
+
1202
+ ${COMMON_RULES}
1203
+
1204
+ Method: read the target code fully before judging; use bash only for read-only inspection (git diff/log, running existing tests). Hunt real defects first \u2014 correctness, edge cases, lifecycle/cleanup holes \u2014 then convention drift. Verify each suspicion against the actual code before reporting it.
1205
+
1206
+ Final report shape: findings ordered by severity, each with \`path:line\`, what's wrong, why it matters, and a concrete suggested fix. End with what you checked and found clean, so silence isn't ambiguous.`
1207
+ };
1208
+ var BUILTIN_AGENTS = [scout, worker, reviewer];
1209
+
1210
+ // src/extensions/subagent/trust.ts
1211
+ import { readFileSync } from "fs";
1212
+ import { homedir as homedir2 } from "os";
1213
+ import { join as join5, resolve as resolve3 } from "path";
1214
+ function isProjectDirTrusted(projectDir) {
1215
+ const registryPath = join5(
1216
+ process.env.PI_PILOT_DATA_DIR ?? join5(homedir2(), ".pi", "webui"),
1217
+ "workspaces.json"
1218
+ );
1219
+ try {
1220
+ const raw = JSON.parse(readFileSync(registryPath, "utf8"));
1221
+ if (!Array.isArray(raw.workspaces)) return false;
1222
+ const wanted = resolve3(projectDir);
1223
+ return raw.workspaces.some(
1224
+ (w) => typeof w?.path === "string" && resolve3(w.path) === wanted && w.trustProjectAgents === true
1225
+ );
1226
+ } catch {
1227
+ return false;
777
1228
  }
778
1229
  }
779
- function isBuiltinDisabled(id) {
780
- return cache3.disabled.includes(id);
1230
+
1231
+ // src/extensions/subagent/agents.ts
1232
+ function userAgentsDir() {
1233
+ return join6(getAgentDir(), "agents");
781
1234
  }
782
- function getDisabledBuiltins() {
783
- return [...cache3.disabled];
1235
+ function projectAgentsDir(projectDir) {
1236
+ return join6(projectDir, ".pi", "agents");
784
1237
  }
785
- async function setBuiltinEnabled(id, enabled) {
786
- const next = new Set(cache3.disabled);
787
- if (enabled) next.delete(id);
788
- else next.add(id);
789
- cache3 = { disabled: [...next] };
790
- await save2();
1238
+ function discoverAgents(projectDir) {
1239
+ const roster = /* @__PURE__ */ new Map();
1240
+ for (const agent of BUILTIN_AGENTS) roster.set(agent.name, agent);
1241
+ mergeDir(roster, userAgentsDir(), "user");
1242
+ if (projectDir && isProjectDirTrusted(projectDir)) {
1243
+ mergeDir(roster, projectAgentsDir(projectDir), "project");
1244
+ }
1245
+ return roster;
1246
+ }
1247
+ function mergeDir(roster, dir, source) {
1248
+ let files;
1249
+ try {
1250
+ files = readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
1251
+ } catch {
1252
+ return;
1253
+ }
1254
+ for (const file of files) {
1255
+ const def = parseAgentFile(join6(dir, file), file, source);
1256
+ if (def) roster.set(def.name, def);
1257
+ }
791
1258
  }
792
- async function save2() {
793
- await mkdir3(dirname3(PREFS_PATH), { recursive: true });
794
- await writeFile3(PREFS_PATH, JSON.stringify(cache3, null, 2), "utf8");
1259
+ function rosterSummary(roster) {
1260
+ return [...roster.values()].map((a) => `- ${a.name}: ${a.description || "(no description)"}`).join("\n");
795
1261
  }
796
-
797
- // src/extensions/index.ts
798
- var BUILTIN_EXTENSIONS = [
799
- {
800
- id: "plan",
801
- name: "Plan",
802
- description: "A live task checklist for multi-step work \u2014 adds the update_plan tool and the /plan command.",
803
- tools: ["update_plan"],
804
- commands: ["plan"],
805
- factory: planExtensionFactory
806
- },
807
- {
808
- id: "ask_user",
809
- name: "Ask user",
810
- description: "Lets the agent pause and ask you a structured multiple-choice question \u2014 adds the ask_user tool.",
811
- tools: ["ask_user"],
812
- commands: [],
813
- factory: askUserExtensionFactory
1262
+ function parseAgentFile(path, filename, source) {
1263
+ try {
1264
+ const raw = readFileSync2(path, "utf8");
1265
+ const { frontmatter, body } = parseFrontmatter(raw);
1266
+ const name = strField(frontmatter.name) ?? filename.replace(/\.md$/, "").trim();
1267
+ if (!name) return void 0;
1268
+ return {
1269
+ name,
1270
+ description: strField(frontmatter.description) ?? "",
1271
+ systemPrompt: body.trim(),
1272
+ tools: toolsField(frontmatter.tools),
1273
+ model: strField(frontmatter.model),
1274
+ source
1275
+ };
1276
+ } catch {
1277
+ return void 0;
814
1278
  }
815
- ];
816
- function gate(def) {
817
- return (pi) => {
818
- if (isBuiltinDisabled(def.id)) return;
819
- return def.factory(pi);
820
- };
821
1279
  }
822
- var builtinExtensionFactories = BUILTIN_EXTENSIONS.map(gate);
1280
+ function strField(value) {
1281
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1282
+ }
1283
+ function toolsField(value) {
1284
+ if (typeof value === "string") {
1285
+ const parts = value.split(",").map((s) => s.trim()).filter(Boolean);
1286
+ return parts.length > 0 ? parts : void 0;
1287
+ }
1288
+ if (Array.isArray(value)) {
1289
+ const parts = value.filter((v) => typeof v === "string" && v.trim() !== "");
1290
+ return parts.length > 0 ? parts.map((s) => s.trim()) : void 0;
1291
+ }
1292
+ return void 0;
1293
+ }
823
1294
 
824
- // src/extensions/ask_user/cleanup.ts
825
- var CUSTOM_TYPE = "ask_user-restart-cancelled";
826
- function reconcileAfterRestart(sessionManager) {
827
- const branch = sessionManager.getBranch();
828
- if (branch.length === 0) return;
829
- const satisfied = /* @__PURE__ */ new Set();
830
- const danglingIds = [];
831
- const danglingAlreadyHandled = /* @__PURE__ */ new Set();
832
- for (let i = branch.length - 1; i >= 0; i--) {
833
- const entry = branch[i];
834
- if (entry.type === "custom_message") {
835
- const cm = entry;
836
- if (cm.customType === CUSTOM_TYPE) {
837
- const ids = cm.details?.ids;
838
- if (Array.isArray(ids)) {
839
- for (const id of ids) {
840
- if (typeof id === "string") danglingAlreadyHandled.add(id);
841
- }
842
- }
1295
+ // src/extensions/subagent/child.ts
1296
+ import { spawn } from "child_process";
1297
+ import {
1298
+ createWriteStream,
1299
+ mkdirSync,
1300
+ mkdtempSync,
1301
+ writeFileSync
1302
+ } from "fs";
1303
+ import { rm as rm3 } from "fs/promises";
1304
+ import { tmpdir } from "os";
1305
+ import { join as join8 } from "path";
1306
+
1307
+ // src/extensions/subagent/schema.ts
1308
+ import { Type as Type4 } from "typebox";
1309
+ var MAX_TASKS_PER_CALL = 8;
1310
+ var taskBriefDescription = "Complete, self-contained task brief. The subagent sees NOTHING of this conversation \u2014 include all relevant paths, constraints, context, and the exact shape of the answer you want back.";
1311
+ var subagentParamsSchema = Type4.Object({
1312
+ agent: Type4.Optional(
1313
+ Type4.String({
1314
+ description: "Single mode: agent to delegate to, by name. The roster is listed in the tool description; an unknown name returns the available roster. Use together with `task`; omit when using `tasks`."
1315
+ })
1316
+ ),
1317
+ task: Type4.Optional(Type4.String({ description: taskBriefDescription })),
1318
+ tasks: Type4.Optional(
1319
+ Type4.Array(
1320
+ Type4.Object({
1321
+ agent: Type4.String({ description: "Agent to delegate this task to, by name." }),
1322
+ task: Type4.String({ description: taskBriefDescription })
1323
+ }),
1324
+ {
1325
+ maxItems: MAX_TASKS_PER_CALL,
1326
+ description: `Parallel mode: up to ${MAX_TASKS_PER_CALL} INDEPENDENT task briefs, run concurrently. All results return together in one combined report. Only for tasks with no ordering dependency \u2014 sequence dependent steps as separate subagent calls instead. Omit when using \`agent\`/\`task\`.`
843
1327
  }
844
- continue;
845
- }
846
- if (entry.type !== "message") continue;
847
- const msg = entry.message;
848
- if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
849
- satisfied.add(msg.toolCallId);
850
- continue;
1328
+ )
1329
+ )
1330
+ });
1331
+ function emptyUsage() {
1332
+ return {
1333
+ input: 0,
1334
+ output: 0,
1335
+ cacheRead: 0,
1336
+ cacheWrite: 0,
1337
+ cost: 0,
1338
+ contextTokens: 0,
1339
+ turns: 0
1340
+ };
1341
+ }
1342
+ function isSubagentDetails(value) {
1343
+ if (!value || typeof value !== "object") return false;
1344
+ const v = value;
1345
+ return v.version === 1 && (v.mode === "single" || v.mode === "parallel") && Array.isArray(v.tasks);
1346
+ }
1347
+
1348
+ // src/extensions/subagent/pi-bin.ts
1349
+ import { existsSync } from "fs";
1350
+ import { dirname as dirname4, join as join7 } from "path";
1351
+ import { fileURLToPath } from "url";
1352
+ var cached;
1353
+ function resolvePinnedPiCli() {
1354
+ if (cached) return cached;
1355
+ try {
1356
+ const entry = fileURLToPath(
1357
+ import.meta.resolve("@earendil-works/pi-coding-agent")
1358
+ );
1359
+ const candidate = join7(dirname4(entry), "cli.js");
1360
+ if (existsSync(candidate)) {
1361
+ cached = candidate;
1362
+ return candidate;
851
1363
  }
852
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
853
- for (const block of msg.content) {
854
- if (!block || typeof block !== "object") continue;
855
- const b = block;
856
- if (b.type !== "toolCall") continue;
857
- if (b.name !== "ask_user") continue;
1364
+ } catch {
1365
+ }
1366
+ const fallback = fileURLToPath(
1367
+ new URL(
1368
+ "../../../node_modules/@earendil-works/pi-coding-agent/dist/cli.js",
1369
+ import.meta.url
1370
+ )
1371
+ );
1372
+ if (existsSync(fallback)) {
1373
+ cached = fallback;
1374
+ return fallback;
1375
+ }
1376
+ throw new Error(
1377
+ "subagent: cannot locate the pinned @earendil-works/pi-coding-agent CLI (tried import.meta.resolve and the package-local node_modules symlink)"
1378
+ );
1379
+ }
1380
+
1381
+ // src/extensions/subagent/registry.ts
1382
+ var children = /* @__PURE__ */ new Map();
1383
+ function registerChild(toolCallId, handle2) {
1384
+ children.set(toolCallId, handle2);
1385
+ }
1386
+ function unregisterChild(toolCallId) {
1387
+ children.delete(toolCallId);
1388
+ }
1389
+ function killChildrenForSession(sessionFile) {
1390
+ let killed = 0;
1391
+ for (const [id, handle2] of children) {
1392
+ if (handle2.sessionFile !== sessionFile) continue;
1393
+ children.delete(id);
1394
+ handle2.kill();
1395
+ killed++;
1396
+ }
1397
+ return killed;
1398
+ }
1399
+ function killAllChildren() {
1400
+ let killed = 0;
1401
+ for (const [id, handle2] of children) {
1402
+ children.delete(id);
1403
+ handle2.kill();
1404
+ killed++;
1405
+ }
1406
+ return killed;
1407
+ }
1408
+ var MAX_CONCURRENT_CHILDREN = 8;
1409
+ var MAX_CONCURRENT_PER_SESSION = 4;
1410
+ var running = 0;
1411
+ var runningPerSession = /* @__PURE__ */ new Map();
1412
+ var waiters = [];
1413
+ function keyOf2(sessionFile) {
1414
+ return sessionFile ?? "<unpersisted>";
1415
+ }
1416
+ function hasCapacity(sessionKey) {
1417
+ return running < MAX_CONCURRENT_CHILDREN && (runningPerSession.get(sessionKey) ?? 0) < MAX_CONCURRENT_PER_SESSION;
1418
+ }
1419
+ function take(sessionKey) {
1420
+ running++;
1421
+ runningPerSession.set(sessionKey, (runningPerSession.get(sessionKey) ?? 0) + 1);
1422
+ }
1423
+ function acquireChildSlot(signal, sessionFile) {
1424
+ const sessionKey = keyOf2(sessionFile);
1425
+ return new Promise((resolve8, reject) => {
1426
+ if (signal?.aborted) {
1427
+ reject(new Error("Aborted by user"));
1428
+ return;
1429
+ }
1430
+ if (hasCapacity(sessionKey)) {
1431
+ take(sessionKey);
1432
+ resolve8(makeRelease(sessionKey));
1433
+ return;
1434
+ }
1435
+ const waiter = {
1436
+ grant: () => resolve8(makeRelease(sessionKey)),
1437
+ sessionKey,
1438
+ signal,
1439
+ onAbort: void 0
1440
+ };
1441
+ if (signal) {
1442
+ const onAbort = () => {
1443
+ const i = waiters.indexOf(waiter);
1444
+ if (i >= 0) waiters.splice(i, 1);
1445
+ reject(new Error("Aborted by user"));
1446
+ };
1447
+ waiter.onAbort = onAbort;
1448
+ signal.addEventListener("abort", onAbort, { once: true });
1449
+ }
1450
+ waiters.push(waiter);
1451
+ });
1452
+ }
1453
+ function makeRelease(sessionKey) {
1454
+ let released = false;
1455
+ return () => {
1456
+ if (released) return;
1457
+ released = true;
1458
+ running--;
1459
+ const n = (runningPerSession.get(sessionKey) ?? 1) - 1;
1460
+ if (n <= 0) runningPerSession.delete(sessionKey);
1461
+ else runningPerSession.set(sessionKey, n);
1462
+ pump();
1463
+ };
1464
+ }
1465
+ function pump() {
1466
+ for (let i = 0; i < waiters.length && running < MAX_CONCURRENT_CHILDREN; ) {
1467
+ const waiter = waiters[i];
1468
+ if (waiter.signal?.aborted) {
1469
+ waiters.splice(i, 1);
1470
+ continue;
1471
+ }
1472
+ if (!hasCapacity(waiter.sessionKey)) {
1473
+ i++;
1474
+ continue;
1475
+ }
1476
+ waiters.splice(i, 1);
1477
+ if (waiter.signal && waiter.onAbort) {
1478
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
1479
+ }
1480
+ take(waiter.sessionKey);
1481
+ waiter.grant();
1482
+ }
1483
+ }
1484
+
1485
+ // src/extensions/subagent/child.ts
1486
+ var PROMPT_DIR_PREFIX = "pi-pilot-subagent-";
1487
+ var TRANSCRIPTS_DIR = join8(tmpdir(), "pi-pilot-subagents", "transcripts");
1488
+ var ACTIVITY_MAX = 30;
1489
+ var LABEL_MAX = 160;
1490
+ var STDERR_TAIL_MAX = 2048;
1491
+ var FINAL_TEXT_MAX = 2e5;
1492
+ var SIGKILL_DELAY_MS = 5e3;
1493
+ async function runChild(opts) {
1494
+ const startedAt = Date.now();
1495
+ const cli = opts.cliPath ?? process.env.PI_PILOT_SUBAGENT_CLI ?? resolvePinnedPiCli();
1496
+ const promptDir = mkdtempSync(join8(tmpdir(), PROMPT_DIR_PREFIX));
1497
+ const promptPath = join8(promptDir, "prompt.md");
1498
+ writeFileSync(promptPath, opts.appendSystemPrompt, { mode: 384 });
1499
+ let transcriptPath;
1500
+ let tee;
1501
+ try {
1502
+ mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
1503
+ transcriptPath = join8(TRANSCRIPTS_DIR, `${sanitizeId(opts.toolCallId)}.ndjson`);
1504
+ tee = createWriteStream(transcriptPath, { flags: "w" });
1505
+ tee.on("error", () => {
1506
+ });
1507
+ } catch {
1508
+ transcriptPath = void 0;
1509
+ }
1510
+ const args = [cli, "--mode", "json", "-p", "--no-session", "--no-extensions", "--no-skills"];
1511
+ const model = opts.agent.model ?? opts.inheritModel;
1512
+ if (model) args.push("--model", model);
1513
+ if (opts.agent.tools && opts.agent.tools.length > 0) {
1514
+ args.push("--tools", opts.agent.tools.join(","));
1515
+ }
1516
+ args.push("--append-system-prompt", promptPath);
1517
+ args.push(opts.task);
1518
+ const usage = emptyUsage();
1519
+ const activity = [];
1520
+ let modelSeen;
1521
+ let finalText = "";
1522
+ let stopReason;
1523
+ let errorMessage;
1524
+ let stderrAccum = "";
1525
+ let aborted = false;
1526
+ let timedOut = false;
1527
+ let costKilled = false;
1528
+ const child = spawn(process.execPath, args, {
1529
+ cwd: opts.cwd,
1530
+ env: { ...process.env, PI_PILOT_SUBAGENT: opts.toolCallId },
1531
+ stdio: ["ignore", "pipe", "pipe"],
1532
+ shell: false
1533
+ });
1534
+ if (child.pid !== void 0) {
1535
+ try {
1536
+ writeFileSync(join8(promptDir, "pid"), `${child.pid}
1537
+ ${process.pid}`);
1538
+ } catch {
1539
+ }
1540
+ }
1541
+ let killed = false;
1542
+ let killTimer;
1543
+ const killGracefully = () => {
1544
+ if (killed) return;
1545
+ killed = true;
1546
+ try {
1547
+ child.kill("SIGTERM");
1548
+ } catch {
1549
+ }
1550
+ killTimer = setTimeout(() => {
1551
+ try {
1552
+ child.kill("SIGKILL");
1553
+ } catch {
1554
+ }
1555
+ }, SIGKILL_DELAY_MS);
1556
+ };
1557
+ registerChild(opts.toolCallId, {
1558
+ sessionFile: opts.sessionFile,
1559
+ agent: opts.agent.name,
1560
+ kill: killGracefully
1561
+ });
1562
+ const onAbort = () => {
1563
+ aborted = true;
1564
+ killGracefully();
1565
+ };
1566
+ if (opts.signal?.aborted) onAbort();
1567
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
1568
+ let stalled = false;
1569
+ const timeoutTimer = setTimeout(() => {
1570
+ timedOut = true;
1571
+ killGracefully();
1572
+ }, opts.timeoutMs);
1573
+ let stallTimer;
1574
+ const armStallTimer = () => {
1575
+ if (killed) return;
1576
+ if (stallTimer) clearTimeout(stallTimer);
1577
+ stallTimer = setTimeout(() => {
1578
+ stalled = true;
1579
+ timedOut = true;
1580
+ killGracefully();
1581
+ }, opts.stallTimeoutMs);
1582
+ };
1583
+ armStallTimer();
1584
+ const emitProgress = () => {
1585
+ opts.onProgress({
1586
+ usage: { ...usage },
1587
+ model: modelSeen,
1588
+ activity: [...activity],
1589
+ lastLabel: activity[activity.length - 1]?.label
1590
+ });
1591
+ };
1592
+ const handleLine = (line) => {
1593
+ if (!line.trim()) return;
1594
+ tee?.write(line + "\n");
1595
+ let event;
1596
+ try {
1597
+ event = JSON.parse(line);
1598
+ } catch {
1599
+ return;
1600
+ }
1601
+ const ev = event;
1602
+ if (ev.type !== "message_end" || !ev.message || typeof ev.message !== "object") return;
1603
+ const msg = ev.message;
1604
+ if (msg.role !== "assistant") return;
1605
+ usage.turns++;
1606
+ const u = msg.usage;
1607
+ if (u) {
1608
+ usage.input += u.input ?? 0;
1609
+ usage.output += u.output ?? 0;
1610
+ usage.cacheRead += u.cacheRead ?? 0;
1611
+ usage.cacheWrite += u.cacheWrite ?? 0;
1612
+ usage.cost += u.cost?.total ?? 0;
1613
+ usage.contextTokens = u.totalTokens ?? usage.contextTokens;
1614
+ }
1615
+ if (!modelSeen && typeof msg.model === "string") modelSeen = msg.model;
1616
+ if (typeof msg.stopReason === "string") stopReason = msg.stopReason;
1617
+ if (typeof msg.errorMessage === "string") errorMessage = msg.errorMessage;
1618
+ if (Array.isArray(msg.content)) {
1619
+ const textParts = [];
1620
+ for (const block of msg.content) {
1621
+ if (!block || typeof block !== "object") continue;
1622
+ const b = block;
1623
+ if (b.type === "text" && typeof b.text === "string") {
1624
+ textParts.push(b.text);
1625
+ } else if (b.type === "toolCall" && typeof b.name === "string") {
1626
+ pushActivity(activity, b.name, b.arguments);
1627
+ }
1628
+ }
1629
+ const text = textParts.join("").trim();
1630
+ if (text) finalText = text.slice(0, FINAL_TEXT_MAX);
1631
+ }
1632
+ if (usage.cost > opts.costCeilingUsd && !costKilled) {
1633
+ costKilled = true;
1634
+ killGracefully();
1635
+ }
1636
+ emitProgress();
1637
+ };
1638
+ let buf = "";
1639
+ child.stdout?.on("data", (chunk) => {
1640
+ armStallTimer();
1641
+ buf += chunk.toString("utf8");
1642
+ let nl;
1643
+ while ((nl = buf.indexOf("\n")) >= 0) {
1644
+ handleLine(buf.slice(0, nl));
1645
+ buf = buf.slice(nl + 1);
1646
+ }
1647
+ });
1648
+ child.stderr?.on("data", (chunk) => {
1649
+ armStallTimer();
1650
+ stderrAccum = (stderrAccum + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX);
1651
+ });
1652
+ const exitCode = await new Promise((resolve8) => {
1653
+ child.on("error", (err2) => {
1654
+ errorMessage ??= err2 instanceof Error ? err2.message : String(err2);
1655
+ resolve8(-1);
1656
+ });
1657
+ child.on("close", (code) => resolve8(code ?? -1));
1658
+ });
1659
+ if (buf) handleLine(buf);
1660
+ clearTimeout(timeoutTimer);
1661
+ if (stallTimer) clearTimeout(stallTimer);
1662
+ if (killTimer) clearTimeout(killTimer);
1663
+ opts.signal?.removeEventListener("abort", onAbort);
1664
+ unregisterChild(opts.toolCallId);
1665
+ tee?.end();
1666
+ await rm3(promptDir, { recursive: true, force: true }).catch(() => {
1667
+ });
1668
+ if (costKilled && !errorMessage) {
1669
+ errorMessage = `cost ceiling ($${opts.costCeilingUsd}) exceeded \u2014 child terminated`;
1670
+ }
1671
+ if (timedOut && !aborted && !errorMessage) {
1672
+ errorMessage = stalled ? `no output for ${Math.round(opts.stallTimeoutMs / 1e3)}s \u2014 presumed hung` : `wall-clock limit (${Math.round(opts.timeoutMs / 1e3)}s) reached while still active`;
1673
+ }
1674
+ const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted" || costKilled;
1675
+ const status2 = aborted ? "aborted" : timedOut ? "timeout" : failed ? "failed" : "done";
1676
+ return {
1677
+ status: status2,
1678
+ finalText,
1679
+ usage,
1680
+ model: modelSeen,
1681
+ stopReason,
1682
+ errorMessage,
1683
+ stderrTail: stderrAccum.trim(),
1684
+ exitCode,
1685
+ durationMs: Date.now() - startedAt,
1686
+ transcriptPath,
1687
+ activity
1688
+ };
1689
+ }
1690
+ function sanitizeId(id) {
1691
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
1692
+ }
1693
+ function pushActivity(activity, name, args) {
1694
+ const label = toolLabel(name, args).slice(0, LABEL_MAX);
1695
+ activity.push({ kind: "tool", label });
1696
+ if (activity.length > ACTIVITY_MAX) {
1697
+ activity.splice(0, activity.length - ACTIVITY_MAX);
1698
+ }
1699
+ }
1700
+ function toolLabel(name, args) {
1701
+ const a = args && typeof args === "object" ? args : {};
1702
+ const pick = (...keys) => {
1703
+ for (const key of keys) {
1704
+ const v = a[key];
1705
+ if (typeof v === "string" && v.trim()) return v;
1706
+ }
1707
+ return void 0;
1708
+ };
1709
+ let detail;
1710
+ switch (name) {
1711
+ case "bash":
1712
+ detail = pick("command");
1713
+ break;
1714
+ case "read":
1715
+ case "write":
1716
+ case "edit":
1717
+ detail = pick("path", "file_path");
1718
+ break;
1719
+ case "grep":
1720
+ detail = pick("pattern");
1721
+ break;
1722
+ case "find":
1723
+ detail = pick("pattern", "path");
1724
+ break;
1725
+ case "ls":
1726
+ detail = pick("path");
1727
+ break;
1728
+ default: {
1729
+ for (const v of Object.values(a)) {
1730
+ if (typeof v === "string" && v.trim()) {
1731
+ detail = v;
1732
+ break;
1733
+ }
1734
+ }
1735
+ }
1736
+ }
1737
+ const clean = detail?.replace(/\s+/g, " ").trim();
1738
+ return clean ? `${name}: ${clean}` : name;
1739
+ }
1740
+
1741
+ // src/extensions/subagent/factory.ts
1742
+ var TASK_OUTPUT_CAP = 12 * 1024;
1743
+ var AGGREGATE_OUTPUT_CAP = 48 * 1024;
1744
+ var PREVIEW_CAP = 8 * 1024;
1745
+ var STDERR_DETAILS_CAP = 1024;
1746
+ var UPDATE_THROTTLE_MS = 500;
1747
+ function tunable(envName, fallback) {
1748
+ const raw = process.env[envName];
1749
+ if (!raw) return fallback;
1750
+ const n = Number.parseFloat(raw);
1751
+ return Number.isFinite(n) && n > 0 ? n : fallback;
1752
+ }
1753
+ var TASK_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_TIMEOUT_SEC", 3600) * 1e3;
1754
+ var STALL_TIMEOUT_MS = tunable("PI_PILOT_SUBAGENT_STALL_SEC", 600) * 1e3;
1755
+ var COST_CEILING_USD = tunable("PI_PILOT_SUBAGENT_COST_USD", 20);
1756
+ var subagentExtensionFactory = (pi) => {
1757
+ const lastDetails = /* @__PURE__ */ new Map();
1758
+ const rosterAtRegistration = discoverAgents();
1759
+ pi.registerTool({
1760
+ name: "subagent",
1761
+ label: "Subagent",
1762
+ description: `Delegate self-contained tasks to subagents running in isolated contexts (separate pi processes that see nothing of this conversation). Returns only the subagents' final reports. Pass \`agent\` + \`task\` for one delegation, or \`tasks\` (up to ${MAX_TASKS_PER_CALL}) to run INDEPENDENT delegations in parallel. Available agents:
1763
+ ` + rosterSummary(rosterAtRegistration) + `
1764
+ The roster is re-read on every call from ${userAgentsDir()}/*.md (builtin presets scout/worker/reviewer; a same-name user file overrides its preset; workspaces with project agents trusted also merge <cwd>/.pi/agents/*.md, which win over both).`,
1765
+ parameters: subagentParamsSchema,
1766
+ executionMode: "parallel",
1767
+ promptSnippet: "subagent: delegate self-contained tasks (recon, a bounded implementation step, a review pass) to isolated child agents \u2014 only their final reports return, keeping large searches and side work out of this context. Independent tasks can fan out in parallel via `tasks`.",
1768
+ promptGuidelines: [
1769
+ "Use subagent when a task is self-contained and would otherwise flood this context (broad codebase recon, a bounded implementation step, a review pass). Don't delegate trivial lookups \u2014 a single read/grep inline is faster and cheaper than a child agent.",
1770
+ "Write complete subagent briefs: the child sees NOTHING of this conversation. Include the relevant paths, constraints, acceptance criteria, and the exact shape of the report you want back.",
1771
+ "Pick the cheapest sufficient agent: scout for read-only recon, reviewer for read-mostly review, worker only when files must change.",
1772
+ `Use \`tasks\` (max ${MAX_TASKS_PER_CALL} per call) ONLY for independent work \u2014 no task's input may depend on another's output, and parallel workers must never edit the same files. All results return together; sequence dependent steps as separate calls instead.`,
1773
+ "Subagents cannot ask the user questions and cannot spawn further subagents. Resolve user decisions (ask_user) BEFORE delegating, and sequence dependent work yourself: delegate, read the report, then issue the next call with the context it needs."
1774
+ ],
1775
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1776
+ const roster = discoverAgents(ctx.cwd);
1777
+ const calls = normalizeCalls(params);
1778
+ if (typeof calls === "string") {
1779
+ return invalidCallResult(calls, params);
1780
+ }
1781
+ const { mode, briefs } = calls;
1782
+ const unknown = [...new Set(briefs.map((b) => b.agent.trim()))].filter(
1783
+ (name) => !roster.has(name)
1784
+ );
1785
+ if (unknown.length > 0) {
1786
+ return invalidCallResult(
1787
+ `Unknown agent${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")}. Available agents:
1788
+ ` + rosterSummary(roster) + "\nCall subagent again with one of these names.",
1789
+ params
1790
+ );
1791
+ }
1792
+ if (signal?.aborted) throw new Error("Aborted by user");
1793
+ const sessionFile = ctx.sessionManager.getSessionFile() ?? null;
1794
+ const resolved = briefs.map((b) => ({
1795
+ agent: roster.get(b.agent.trim()),
1796
+ task: b.task
1797
+ }));
1798
+ const tasks = resolved.map((r) => ({
1799
+ agent: r.agent.name,
1800
+ agentSource: r.agent.source,
1801
+ task: r.task,
1802
+ status: "queued",
1803
+ activity: [],
1804
+ usage: emptyUsage()
1805
+ }));
1806
+ const details = { version: 1, mode, tasks };
1807
+ const emitter = makeThrottledEmitter(onUpdate, details);
1808
+ emitter.emit(true);
1809
+ const runOne = async (i) => {
1810
+ const task = tasks[i];
1811
+ const r = resolved[i];
1812
+ let release;
1813
+ try {
1814
+ release = await acquireChildSlot(signal, sessionFile);
1815
+ } catch (err2) {
1816
+ if (!signal?.aborted) throw err2;
1817
+ task.status = "aborted";
1818
+ emitter.emit(true);
1819
+ return void 0;
1820
+ }
1821
+ try {
1822
+ task.status = "running";
1823
+ emitter.emit(true);
1824
+ const outcome = await runChild({
1825
+ // Per-child id: registry entries, pidfiles and transcript
1826
+ // files must not collide across one call's siblings.
1827
+ toolCallId: mode === "single" ? toolCallId : `${toolCallId}.${i}`,
1828
+ agent: r.agent,
1829
+ task: r.task,
1830
+ cwd: ctx.cwd,
1831
+ sessionFile,
1832
+ appendSystemPrompt: composeChildPrompt(r.agent),
1833
+ inheritModel: r.agent.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : void 0),
1834
+ signal,
1835
+ timeoutMs: TASK_TIMEOUT_MS,
1836
+ stallTimeoutMs: STALL_TIMEOUT_MS,
1837
+ costCeilingUsd: COST_CEILING_USD,
1838
+ onProgress: (p) => {
1839
+ task.usage = p.usage;
1840
+ task.model = p.model ?? task.model;
1841
+ task.activity = p.activity;
1842
+ emitter.emit();
1843
+ }
1844
+ });
1845
+ mergeOutcome(task, outcome);
1846
+ emitter.emit(true);
1847
+ return outcome;
1848
+ } finally {
1849
+ release();
1850
+ }
1851
+ };
1852
+ try {
1853
+ const outcomes = await Promise.all(resolved.map((_, i) => runOne(i)));
1854
+ emitter.cancel();
1855
+ lastDetails.set(toolCallId, details);
1856
+ if (tasks.some((t) => t.status === "aborted")) {
1857
+ throw new Error("Aborted by user");
1858
+ }
1859
+ return {
1860
+ content: [{ type: "text", text: combinedParentText(mode, resolved, outcomes) }],
1861
+ details
1862
+ };
1863
+ } finally {
1864
+ emitter.cancel();
1865
+ }
1866
+ }
1867
+ });
1868
+ pi.on("tool_result", (ev) => {
1869
+ if (ev.toolName !== "subagent") return void 0;
1870
+ const fromMap = lastDetails.get(ev.toolCallId);
1871
+ lastDetails.delete(ev.toolCallId);
1872
+ const details = isSubagentDetails(ev.details) ? ev.details : fromMap;
1873
+ if (!details) return void 0;
1874
+ const failed = details.tasks.some(
1875
+ (t) => t.status === "failed" || t.status === "timeout"
1876
+ );
1877
+ const patch = {};
1878
+ if (failed && !ev.isError) patch.isError = true;
1879
+ if (ev.isError && !isSubagentDetails(ev.details) && fromMap) patch.details = fromMap;
1880
+ return patch.isError !== void 0 || patch.details !== void 0 ? patch : void 0;
1881
+ });
1882
+ };
1883
+ function normalizeCalls(params) {
1884
+ const hasSingle = params.agent !== void 0 || params.task !== void 0;
1885
+ const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
1886
+ if (hasSingle && hasTasks) {
1887
+ return "Pass EITHER `agent` + `task` (single delegation) OR `tasks` (parallel fan-out) \u2014 not both in one call.";
1888
+ }
1889
+ if (hasTasks) {
1890
+ const list = params.tasks;
1891
+ if (list.length > MAX_TASKS_PER_CALL) {
1892
+ return `tasks[] is capped at ${MAX_TASKS_PER_CALL} per call (got ${list.length}). Split the fan-out into multiple subagent calls.`;
1893
+ }
1894
+ if (list.some((t) => !t?.agent?.trim() || !t?.task?.trim())) {
1895
+ return "Every tasks[] entry needs a non-empty `agent` and `task`.";
1896
+ }
1897
+ return { mode: "parallel", briefs: list.map((t) => ({ agent: t.agent, task: t.task })) };
1898
+ }
1899
+ if (params.agent?.trim() && params.task?.trim()) {
1900
+ return { mode: "single", briefs: [{ agent: params.agent, task: params.task }] };
1901
+ }
1902
+ return "Provide either `agent` + `task` (single delegation) or `tasks` (parallel fan-out of independent briefs).";
1903
+ }
1904
+ function invalidCallResult(text, params) {
1905
+ return {
1906
+ content: [{ type: "text", text }],
1907
+ details: {
1908
+ version: 1,
1909
+ mode: Array.isArray(params.tasks) && params.tasks.length > 0 ? "parallel" : "single",
1910
+ tasks: []
1911
+ }
1912
+ };
1913
+ }
1914
+ function composeChildPrompt(agent) {
1915
+ const header = `You are running as the "${agent.name}" subagent, delegated one task by another agent via pi-pilot. Work only within the project's working directory.`;
1916
+ return agent.systemPrompt ? `${header}
1917
+
1918
+ ${agent.systemPrompt}` : header;
1919
+ }
1920
+ function mergeOutcome(task, outcome) {
1921
+ task.status = outcome.status;
1922
+ task.usage = outcome.usage;
1923
+ task.activity = outcome.activity;
1924
+ task.model = outcome.model ?? task.model;
1925
+ task.stopReason = outcome.stopReason;
1926
+ task.errorMessage = outcome.errorMessage;
1927
+ task.stderrTail = outcome.stderrTail ? outcome.stderrTail.slice(-STDERR_DETAILS_CAP) : void 0;
1928
+ task.exitCode = outcome.exitCode;
1929
+ task.durationMs = outcome.durationMs;
1930
+ task.transcriptPath = outcome.transcriptPath;
1931
+ task.finalPreview = outcome.finalText ? tailCap(outcome.finalText, PREVIEW_CAP) : void 0;
1932
+ }
1933
+ function combinedParentText(mode, resolved, outcomes) {
1934
+ if (mode === "single") {
1935
+ return formatParentText(resolved[0].agent, outcomes[0], TASK_OUTPUT_CAP);
1936
+ }
1937
+ const n = outcomes.length;
1938
+ const perTaskCap = Math.min(TASK_OUTPUT_CAP, Math.floor(AGGREGATE_OUTPUT_CAP / n));
1939
+ const counts = /* @__PURE__ */ new Map();
1940
+ for (const o of outcomes) {
1941
+ const s = o?.status ?? "aborted";
1942
+ counts.set(s, (counts.get(s) ?? 0) + 1);
1943
+ }
1944
+ const summary = [...counts.entries()].map(([s, c]) => `${c} ${s}`).join(", ");
1945
+ const sections = outcomes.map((o, i) => {
1946
+ const head = `=== task ${i + 1}/${n} ===`;
1947
+ const body = o ? formatParentText(resolved[i].agent, o, perTaskCap) : `[${resolved[i].agent.name}] never started (aborted while queued)`;
1948
+ return `${head}
1949
+ ${body}`;
1950
+ });
1951
+ return [`[subagent] ${n} parallel tasks: ${summary}`, ...sections].join("\n\n");
1952
+ }
1953
+ function formatParentText(agent, outcome, outputCap) {
1954
+ const stats = `${Math.round(outcome.durationMs / 1e3)}s \xB7 ${outcome.usage.turns} turns \xB7 ${fmtTokens(outcome.usage.input + outcome.usage.output)} tokens \xB7 $${outcome.usage.cost.toFixed(3)}`;
1955
+ if (outcome.status === "done") {
1956
+ const body = outcome.finalText || "(the subagent produced no final text)";
1957
+ return `[${agent.name}] done in ${stats}
1958
+
1959
+ ${tailCap(body, outputCap)}`;
1960
+ }
1961
+ const head = outcome.status === "timeout" ? `[${agent.name}] TIMED OUT after ${stats}` : `[${agent.name}] FAILED (stopReason=${outcome.stopReason ?? "?"}, exit ${outcome.exitCode}) after ${stats}`;
1962
+ const parts = [head];
1963
+ if (outcome.errorMessage) parts.push(`error: ${outcome.errorMessage}`);
1964
+ if (outcome.finalText) parts.push(`partial output:
1965
+ ${tailCap(outcome.finalText, 2 * 1024)}`);
1966
+ if (outcome.stderrTail) parts.push(`stderr tail:
1967
+ ${outcome.stderrTail.slice(-STDERR_DETAILS_CAP)}`);
1968
+ if (outcome.transcriptPath) parts.push(`full transcript: ${outcome.transcriptPath}`);
1969
+ if (outcome.status === "timeout") {
1970
+ parts.push(
1971
+ "note for the user: limits are env-tunable \u2014 PI_PILOT_SUBAGENT_STALL_SEC (silence detector) / PI_PILOT_SUBAGENT_TIMEOUT_SEC (total ceiling), server restart applies."
1972
+ );
1973
+ }
1974
+ return parts.join("\n");
1975
+ }
1976
+ function tailCap(text, max) {
1977
+ if (text.length <= max) return text;
1978
+ const dropped = text.length - max;
1979
+ return `\u2026(${dropped} chars truncated \u2014 full transcript in details)
1980
+ ${text.slice(-max)}`;
1981
+ }
1982
+ function fmtTokens(n) {
1983
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
1984
+ }
1985
+ function makeThrottledEmitter(onUpdate, details) {
1986
+ let lastEmit = 0;
1987
+ let timer;
1988
+ let cancelled = false;
1989
+ const fire = () => {
1990
+ timer = void 0;
1991
+ lastEmit = Date.now();
1992
+ onUpdate?.({
1993
+ content: [{ type: "text", text: statusLine(details) }],
1994
+ details: structuredClone(details)
1995
+ });
1996
+ };
1997
+ return {
1998
+ emit: (force = false) => {
1999
+ if (cancelled || !onUpdate) return;
2000
+ const elapsed = Date.now() - lastEmit;
2001
+ if (force || elapsed >= UPDATE_THROTTLE_MS) {
2002
+ if (timer) {
2003
+ clearTimeout(timer);
2004
+ timer = void 0;
2005
+ }
2006
+ fire();
2007
+ } else if (!timer) {
2008
+ timer = setTimeout(fire, UPDATE_THROTTLE_MS - elapsed);
2009
+ }
2010
+ },
2011
+ cancel: () => {
2012
+ cancelled = true;
2013
+ if (timer) {
2014
+ clearTimeout(timer);
2015
+ timer = void 0;
2016
+ }
2017
+ }
2018
+ };
2019
+ }
2020
+ function statusLine(details) {
2021
+ if (details.tasks.length > 1) {
2022
+ const counts = /* @__PURE__ */ new Map();
2023
+ let cost = 0;
2024
+ let turns = 0;
2025
+ for (const t2 of details.tasks) {
2026
+ counts.set(t2.status, (counts.get(t2.status) ?? 0) + 1);
2027
+ cost += t2.usage.cost;
2028
+ turns += t2.usage.turns;
2029
+ }
2030
+ const bits2 = [`${details.tasks.length} tasks`];
2031
+ for (const status2 of ["running", "queued", "done", "failed", "timeout", "aborted"]) {
2032
+ const c = counts.get(status2);
2033
+ if (c) bits2.push(`${c} ${status2}`);
2034
+ }
2035
+ if (turns > 0) bits2.push(`$${cost.toFixed(3)}`);
2036
+ return bits2.join(" \xB7 ");
2037
+ }
2038
+ const t = details.tasks[0];
2039
+ if (!t) return "subagent";
2040
+ const bits = [t.agent, t.status];
2041
+ if (t.usage.turns > 0) {
2042
+ bits.push(`${t.usage.turns} turns`);
2043
+ bits.push(`${fmtTokens(t.usage.input + t.usage.output)} tok`);
2044
+ bits.push(`$${t.usage.cost.toFixed(3)}`);
2045
+ }
2046
+ const last = t.activity[t.activity.length - 1];
2047
+ if (last && t.status === "running") bits.push(last.label);
2048
+ return bits.join(" \xB7 ");
2049
+ }
2050
+
2051
+ // src/extensions/web_search/schema.ts
2052
+ import { Type as Type5 } from "typebox";
2053
+ var webSearchParamsSchema = Type5.Object({
2054
+ query: Type5.String({
2055
+ description: "The search query. Phrase it like a search-engine query (keywords, entities), not a chat sentence."
2056
+ }),
2057
+ max_results: Type5.Optional(
2058
+ Type5.Number({
2059
+ description: "How many results to return (1\u201310). Default 5.",
2060
+ minimum: 1,
2061
+ maximum: 10
2062
+ })
2063
+ ),
2064
+ topic: Type5.Optional(
2065
+ Type5.Union([Type5.Literal("general"), Type5.Literal("news")], {
2066
+ description: 'Search topic. Use "news" for recent/current events. Default "general".'
2067
+ })
2068
+ ),
2069
+ search_depth: Type5.Optional(
2070
+ Type5.Union([Type5.Literal("basic"), Type5.Literal("advanced")], {
2071
+ description: '"advanced" digs deeper (slower, costs more credits); "basic" is usually enough. Default "basic".'
2072
+ })
2073
+ )
2074
+ });
2075
+ var webFetchParamsSchema = Type5.Object({
2076
+ urls: Type5.Array(
2077
+ Type5.String({ description: "An absolute http(s) URL." }),
2078
+ {
2079
+ description: "URLs to fetch and extract the main text from (1\u20135).",
2080
+ minItems: 1,
2081
+ maxItems: 5
2082
+ }
2083
+ )
2084
+ });
2085
+
2086
+ // src/storage/web-search-prefs.ts
2087
+ import { readFile as readFile4 } from "fs/promises";
2088
+ import { join as join9 } from "path";
2089
+ var PREFS_PATH2 = join9(config.dataDir, "web-search.json");
2090
+ var cache4 = {};
2091
+ async function loadWebSearchPrefs() {
2092
+ try {
2093
+ const raw = await readFile4(PREFS_PATH2, "utf8");
2094
+ const parsed = JSON.parse(raw);
2095
+ cache4 = { tavilyApiKey: typeof parsed.tavilyApiKey === "string" ? parsed.tavilyApiKey : void 0 };
2096
+ } catch (err2) {
2097
+ cache4 = {};
2098
+ if (err2.code !== "ENOENT") {
2099
+ console.warn(`[web-search-prefs] ignoring unreadable ${PREFS_PATH2}:`, err2);
2100
+ }
2101
+ }
2102
+ }
2103
+ function getTavilyApiKey() {
2104
+ const fromSettings = cache4.tavilyApiKey?.trim();
2105
+ if (fromSettings) return fromSettings;
2106
+ const fromEnv = process.env.TAVILY_API_KEY?.trim();
2107
+ return fromEnv || void 0;
2108
+ }
2109
+ function getKeyStatus() {
2110
+ const fromSettings = cache4.tavilyApiKey?.trim();
2111
+ const fromEnv = process.env.TAVILY_API_KEY?.trim();
2112
+ const live = fromSettings || fromEnv || void 0;
2113
+ const source = fromSettings ? "settings" : fromEnv ? "env" : "none";
2114
+ return {
2115
+ configured: Boolean(live),
2116
+ source,
2117
+ ...live ? { hint: maskKey(live) } : {}
2118
+ };
2119
+ }
2120
+ function maskKey(key) {
2121
+ return `\u2026${key.slice(-4)}`;
2122
+ }
2123
+ async function setTavilyApiKey(key) {
2124
+ const trimmed = key.trim();
2125
+ cache4 = trimmed ? { tavilyApiKey: trimmed } : {};
2126
+ await save3();
2127
+ }
2128
+ async function clearTavilyApiKey() {
2129
+ cache4 = {};
2130
+ await save3();
2131
+ }
2132
+ async function save3() {
2133
+ await writeJsonAtomic(PREFS_PATH2, cache4, { mode: 384 });
2134
+ }
2135
+
2136
+ // src/extensions/web_search/client.ts
2137
+ var DEFAULT_TIMEOUT_MS = 3e4;
2138
+ function baseUrl() {
2139
+ return process.env.TAVILY_BASE_URL ?? "https://api.tavily.com";
2140
+ }
2141
+ var WebSearchError = class extends Error {
2142
+ constructor(message) {
2143
+ super(message);
2144
+ this.name = "WebSearchError";
2145
+ }
2146
+ };
2147
+ function apiKey() {
2148
+ const key = getTavilyApiKey();
2149
+ if (!key) {
2150
+ throw new WebSearchError(
2151
+ "Web search is not configured: add a Tavily API key in Settings \u2192 Web search, or set the TAVILY_API_KEY environment variable. Get a free key at https://app.tavily.com."
2152
+ );
2153
+ }
2154
+ return key;
2155
+ }
2156
+ async function postJson(path, body, signal) {
2157
+ const key = apiKey();
2158
+ const ctl = new AbortController();
2159
+ const onAbort = () => ctl.abort(signal?.reason);
2160
+ if (signal) {
2161
+ if (signal.aborted) ctl.abort(signal.reason);
2162
+ else signal.addEventListener("abort", onAbort, { once: true });
2163
+ }
2164
+ const timer = setTimeout(
2165
+ () => ctl.abort(new WebSearchError(`Tavily request timed out after ${DEFAULT_TIMEOUT_MS / 1e3}s.`)),
2166
+ DEFAULT_TIMEOUT_MS
2167
+ );
2168
+ let res;
2169
+ try {
2170
+ res = await fetch(`${baseUrl()}${path}`, {
2171
+ method: "POST",
2172
+ headers: {
2173
+ "content-type": "application/json",
2174
+ authorization: `Bearer ${key}`
2175
+ },
2176
+ body: JSON.stringify(body),
2177
+ signal: ctl.signal
2178
+ });
2179
+ } catch (err2) {
2180
+ if (signal?.aborted) throw err2;
2181
+ const reason = ctl.signal.reason;
2182
+ if (reason instanceof WebSearchError) throw reason;
2183
+ throw new WebSearchError(`Tavily request failed: ${errMsg(err2)}`);
2184
+ } finally {
2185
+ clearTimeout(timer);
2186
+ if (signal) signal.removeEventListener("abort", onAbort);
2187
+ }
2188
+ if (!res.ok) {
2189
+ throw new WebSearchError(await describeHttpError(res));
2190
+ }
2191
+ try {
2192
+ return await res.json();
2193
+ } catch {
2194
+ throw new WebSearchError(
2195
+ `Tavily returned a non-JSON response (HTTP ${res.status}) \u2014 the service may be down or returning an error page.`
2196
+ );
2197
+ }
2198
+ }
2199
+ async function describeHttpError(res) {
2200
+ let detail = "";
2201
+ try {
2202
+ const data = await res.json();
2203
+ const d = data?.detail ?? data?.error;
2204
+ if (typeof d === "string") detail = d;
2205
+ else if (d && typeof d === "object" && typeof d.error === "string") {
2206
+ detail = d.error;
2207
+ }
2208
+ } catch {
2209
+ }
2210
+ const suffix = detail ? `: ${detail}` : "";
2211
+ if (res.status === 401 || res.status === 403) {
2212
+ return `Tavily rejected the API key (HTTP ${res.status})${suffix}. Check TAVILY_API_KEY.`;
2213
+ }
2214
+ if (res.status === 429) {
2215
+ return `Tavily rate limit / quota exceeded (HTTP 429)${suffix}.`;
2216
+ }
2217
+ return `Tavily request failed (HTTP ${res.status})${suffix}.`;
2218
+ }
2219
+ function errMsg(err2) {
2220
+ return err2 instanceof Error ? err2.message : String(err2);
2221
+ }
2222
+ function tavilySearch(opts, signal) {
2223
+ return postJson(
2224
+ "/search",
2225
+ {
2226
+ query: opts.query,
2227
+ max_results: opts.maxResults,
2228
+ topic: opts.topic,
2229
+ search_depth: opts.searchDepth,
2230
+ include_answer: true
2231
+ },
2232
+ signal
2233
+ );
2234
+ }
2235
+ function tavilyExtract(urls, signal) {
2236
+ return postJson("/extract", { urls }, signal);
2237
+ }
2238
+
2239
+ // src/extensions/web_search/factory.ts
2240
+ var SNIPPET_CARD_CAP = 320;
2241
+ var SNIPPET_MODEL_CAP = 1024;
2242
+ var FETCH_PREVIEW_CAP = 2 * 1024;
2243
+ var FETCH_PER_URL_CAP = 8 * 1024;
2244
+ var FETCH_TOTAL_CAP = 24 * 1024;
2245
+ function clampInt(v, lo, hi, dflt) {
2246
+ if (typeof v !== "number" || !Number.isFinite(v)) return dflt;
2247
+ return Math.max(lo, Math.min(hi, Math.round(v)));
2248
+ }
2249
+ function truncate(s, cap) {
2250
+ return s.length <= cap ? s : `${s.slice(0, cap)}\u2026`;
2251
+ }
2252
+ var webSearchExtensionFactory = (pi) => {
2253
+ pi.registerTool({
2254
+ name: "web_search",
2255
+ label: "Web search",
2256
+ description: "Search the web and get back a short answer plus ranked results (title, URL, snippet). Use it for current events, external documentation, or facts you're unsure of or that may have changed since your training cutoff. Follow up with `web_fetch` to read a result's full page when the snippet isn't enough.",
2257
+ parameters: webSearchParamsSchema,
2258
+ executionMode: "parallel",
2259
+ promptSnippet: "web_search: search the web (answer + ranked results) for current or uncertain facts; pair with web_fetch to read a page in full.",
2260
+ promptGuidelines: [
2261
+ "Reach for web_search when the answer depends on current / post-cutoff information, external docs, or facts you can't verify from the repo or your own knowledge \u2014 not for things you already know.",
2262
+ 'Phrase `query` like a search-engine query (keywords, entities), not a chat sentence. Set `topic: "news"` for recent events.',
2263
+ "Cite the URLs you relied on, and use web_fetch to read a page in full before trusting details beyond the snippet."
2264
+ ],
2265
+ async execute(_toolCallId, params, signal) {
2266
+ const query = params.query.trim();
2267
+ if (!query) throw new WebSearchError("web_search needs a non-empty query.");
2268
+ const maxResults = clampInt(params.max_results, 1, 10, 5);
2269
+ const topic = params.topic === "news" ? "news" : "general";
2270
+ const searchDepth = params.search_depth === "advanced" ? "advanced" : "basic";
2271
+ const data = await tavilySearch({ query, maxResults, topic, searchDepth }, signal);
2272
+ const raw = (data.results ?? []).filter(
2273
+ (r) => typeof r.url === "string"
2274
+ );
2275
+ const answer = typeof data.answer === "string" && data.answer.trim() ? data.answer.trim() : void 0;
2276
+ const results = raw.map((r) => ({
2277
+ title: (r.title ?? "").trim() || r.url,
2278
+ url: r.url,
2279
+ content: truncate((r.content ?? "").trim(), SNIPPET_CARD_CAP),
2280
+ score: typeof r.score === "number" ? r.score : void 0,
2281
+ publishedDate: typeof r.published_date === "string" ? r.published_date : void 0
2282
+ }));
2283
+ const details = { version: 1, kind: "search", query, answer, results };
2284
+ return { content: [{ type: "text", text: formatSearch(query, answer, raw) }], details };
2285
+ }
2286
+ });
2287
+ pi.registerTool({
2288
+ name: "web_fetch",
2289
+ label: "Web fetch",
2290
+ description: "Fetch one or more web pages and extract their main text as clean, readable content (stripped of HTML/navigation/boilerplate). Use it to read a page in full \u2014 a URL returned by web_search, a documentation link, or a URL the user gave you.",
2291
+ parameters: webFetchParamsSchema,
2292
+ executionMode: "parallel",
2293
+ promptSnippet: "web_fetch: fetch URL(s) and extract the main page text \u2014 read a web_search result or a user-given link in full.",
2294
+ promptGuidelines: [
2295
+ "Use web_fetch to read the full text of a page when a web_search snippet isn't enough, or whenever the user hands you a URL.",
2296
+ "Pass up to 5 absolute http(s) URLs in one call to read them together."
2297
+ ],
2298
+ async execute(_toolCallId, params, signal) {
2299
+ const urls = params.urls.map((u) => u.trim()).filter(Boolean);
2300
+ if (urls.length === 0) throw new WebSearchError("web_fetch needs at least one non-empty URL.");
2301
+ const data = await tavilyExtract(urls, signal);
2302
+ const results = (data.results ?? []).filter((r) => typeof r.url === "string").map((r) => {
2303
+ const full = (r.raw_content ?? "").trim();
2304
+ return { url: r.url, content: truncate(full, FETCH_PREVIEW_CAP), chars: full.length };
2305
+ });
2306
+ const failed = (data.failed_results ?? []).filter((f) => typeof f.url === "string").map((f) => ({ url: f.url, error: (f.error ?? "extraction failed").toString() }));
2307
+ const details = { version: 1, kind: "fetch", results, failed };
2308
+ return { content: [{ type: "text", text: formatFetch(data) }], details };
2309
+ }
2310
+ });
2311
+ };
2312
+ function formatSearch(query, answer, results) {
2313
+ const lines = [];
2314
+ lines.push(`Search results for "${query}" (${results.length} result${results.length === 1 ? "" : "s"}):`);
2315
+ if (answer) {
2316
+ lines.push("");
2317
+ lines.push(`Answer: ${answer}`);
2318
+ }
2319
+ results.forEach((r, i) => {
2320
+ lines.push("");
2321
+ lines.push(`${i + 1}. ${(r.title ?? "").trim() || r.url}`);
2322
+ lines.push(` ${r.url}`);
2323
+ const snippet = (r.content ?? "").trim();
2324
+ if (snippet) lines.push(` ${truncate(snippet, SNIPPET_MODEL_CAP)}`);
2325
+ });
2326
+ if (results.length === 0) {
2327
+ lines.push("");
2328
+ lines.push("No results found.");
2329
+ }
2330
+ return lines.join("\n");
2331
+ }
2332
+ function formatFetch(data) {
2333
+ const results = (data.results ?? []).filter(
2334
+ (r) => typeof r.url === "string"
2335
+ );
2336
+ const failed = (data.failed_results ?? []).filter(
2337
+ (f) => typeof f.url === "string"
2338
+ );
2339
+ const lines = [];
2340
+ let budget = FETCH_TOTAL_CAP;
2341
+ for (const r of results) {
2342
+ const full = (r.raw_content ?? "").trim();
2343
+ const cap = Math.min(FETCH_PER_URL_CAP, budget);
2344
+ const slice = full.length > cap ? `${full.slice(0, cap)}\u2026` : full;
2345
+ budget -= Math.min(full.length, cap);
2346
+ lines.push(`## ${r.url}`);
2347
+ lines.push(slice || "(no extractable content)");
2348
+ lines.push("");
2349
+ if (budget <= 0) {
2350
+ lines.push("\u2026 (remaining pages omitted to fit the context budget)");
2351
+ break;
2352
+ }
2353
+ }
2354
+ for (const f of failed) {
2355
+ lines.push(`Failed to fetch ${f.url}: ${f.error ?? "extraction failed"}`);
2356
+ }
2357
+ if (results.length === 0 && failed.length === 0) lines.push("No content extracted.");
2358
+ return lines.join("\n").trim();
2359
+ }
2360
+
2361
+ // src/storage/builtin-extension-prefs.ts
2362
+ import { readFile as readFile5 } from "fs/promises";
2363
+ import { join as join10 } from "path";
2364
+ var PREFS_PATH3 = join10(config.dataDir, "builtin-extensions.json");
2365
+ var cache5 = { disabled: [] };
2366
+ async function loadBuiltinPrefs() {
2367
+ try {
2368
+ const raw = await readFile5(PREFS_PATH3, "utf8");
2369
+ const parsed = JSON.parse(raw);
2370
+ cache5 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
2371
+ } catch (err2) {
2372
+ cache5 = { disabled: [] };
2373
+ if (err2.code !== "ENOENT") {
2374
+ console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH3}:`, err2);
2375
+ }
2376
+ }
2377
+ }
2378
+ function isBuiltinDisabled(id) {
2379
+ if (cache5.disabled.includes(id)) return true;
2380
+ if (id === "todo" && cache5.disabled.includes("plan")) return true;
2381
+ return false;
2382
+ }
2383
+ function getDisabledBuiltins() {
2384
+ return [...cache5.disabled];
2385
+ }
2386
+ async function setBuiltinEnabled(id, enabled) {
2387
+ const next = new Set(cache5.disabled);
2388
+ if (enabled) next.delete(id);
2389
+ else next.add(id);
2390
+ cache5 = { disabled: [...next] };
2391
+ await save4();
2392
+ }
2393
+ async function save4() {
2394
+ await writeJsonAtomic(PREFS_PATH3, cache5);
2395
+ }
2396
+
2397
+ // src/extensions/index.ts
2398
+ var BUILTIN_EXTENSIONS = [
2399
+ {
2400
+ id: "todo",
2401
+ name: "Todo",
2402
+ description: "A CRUD task list for tracking multi-step work \u2014 adds the todo tool and the /todos command.",
2403
+ tools: ["todo"],
2404
+ commands: ["todos"],
2405
+ factory: todoExtensionFactory
2406
+ },
2407
+ {
2408
+ id: "ask_user",
2409
+ name: "Ask user",
2410
+ description: "Lets the agent pause and ask you a structured multiple-choice question \u2014 adds the ask_user tool.",
2411
+ tools: ["ask_user"],
2412
+ commands: [],
2413
+ factory: askUserExtensionFactory
2414
+ },
2415
+ {
2416
+ id: "artifact",
2417
+ name: "Artifacts",
2418
+ 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.",
2419
+ tools: ["create_artifact"],
2420
+ commands: [],
2421
+ factory: artifactExtensionFactory
2422
+ },
2423
+ {
2424
+ id: "subagent",
2425
+ name: "Subagent",
2426
+ description: "Lets the agent delegate self-contained tasks to isolated child agents (separate pinned-pi processes; only their final report returns). Builtin presets scout/worker/reviewer; user agents from ~/.pi/agent/agents/*.md. Adds the subagent tool.",
2427
+ tools: ["subagent"],
2428
+ commands: [],
2429
+ factory: subagentExtensionFactory
2430
+ },
2431
+ {
2432
+ id: "web",
2433
+ name: "Web search",
2434
+ description: "Lets the agent search the web and read pages \u2014 adds the web_search and web_fetch tools (backed by Tavily; needs the TAVILY_API_KEY environment variable).",
2435
+ tools: ["web_search", "web_fetch"],
2436
+ commands: [],
2437
+ factory: webSearchExtensionFactory
2438
+ }
2439
+ ];
2440
+ function gate(def) {
2441
+ return (pi) => {
2442
+ if (isBuiltinDisabled(def.id)) return;
2443
+ return def.factory(pi);
2444
+ };
2445
+ }
2446
+ var builtinExtensionFactories = BUILTIN_EXTENSIONS.map(gate);
2447
+
2448
+ // src/extensions/ask_user/cleanup.ts
2449
+ var CUSTOM_TYPE = "ask_user-restart-cancelled";
2450
+ function reconcileAfterRestart(sessionManager) {
2451
+ const branch = sessionManager.getBranch();
2452
+ if (branch.length === 0) return;
2453
+ const satisfied = /* @__PURE__ */ new Set();
2454
+ const danglingIds = [];
2455
+ const danglingAlreadyHandled = /* @__PURE__ */ new Set();
2456
+ for (let i = branch.length - 1; i >= 0; i--) {
2457
+ const entry = branch[i];
2458
+ if (entry.type === "custom_message") {
2459
+ const cm = entry;
2460
+ if (cm.customType === CUSTOM_TYPE) {
2461
+ const ids = cm.details?.ids;
2462
+ if (Array.isArray(ids)) {
2463
+ for (const id of ids) {
2464
+ if (typeof id === "string") danglingAlreadyHandled.add(id);
2465
+ }
2466
+ }
2467
+ }
2468
+ continue;
2469
+ }
2470
+ if (entry.type !== "message") continue;
2471
+ const msg = entry.message;
2472
+ if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
2473
+ satisfied.add(msg.toolCallId);
2474
+ continue;
2475
+ }
2476
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
2477
+ for (const block of msg.content) {
2478
+ if (!block || typeof block !== "object") continue;
2479
+ const b = block;
2480
+ if (b.type !== "toolCall") continue;
2481
+ if (b.name !== "ask_user") continue;
858
2482
  if (typeof b.id !== "string") continue;
859
2483
  if (satisfied.has(b.id)) continue;
860
2484
  if (danglingAlreadyHandled.has(b.id)) continue;
@@ -876,6 +2500,116 @@ function reconcileAfterRestart(sessionManager) {
876
2500
  );
877
2501
  }
878
2502
 
2503
+ // src/extensions/subagent/cleanup.ts
2504
+ import { execFile as execFile2 } from "child_process";
2505
+ import { readdir, readFile as readFile6, rm as rm4 } from "fs/promises";
2506
+ import { tmpdir as tmpdir2 } from "os";
2507
+ import { join as join11 } from "path";
2508
+ var CUSTOM_TYPE2 = "subagent-restart-cancelled";
2509
+ function reconcileAfterRestart2(sessionManager) {
2510
+ const branch = sessionManager.getBranch();
2511
+ if (branch.length === 0) return;
2512
+ const satisfied = /* @__PURE__ */ new Set();
2513
+ const danglingIds = [];
2514
+ const danglingAlreadyHandled = /* @__PURE__ */ new Set();
2515
+ for (let i = branch.length - 1; i >= 0; i--) {
2516
+ const entry = branch[i];
2517
+ if (entry.type === "custom_message") {
2518
+ const cm = entry;
2519
+ if (cm.customType === CUSTOM_TYPE2) {
2520
+ const ids = cm.details?.ids;
2521
+ if (Array.isArray(ids)) {
2522
+ for (const id of ids) {
2523
+ if (typeof id === "string") danglingAlreadyHandled.add(id);
2524
+ }
2525
+ }
2526
+ }
2527
+ continue;
2528
+ }
2529
+ if (entry.type !== "message") continue;
2530
+ const msg = entry.message;
2531
+ if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
2532
+ satisfied.add(msg.toolCallId);
2533
+ continue;
2534
+ }
2535
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
2536
+ for (const block of msg.content) {
2537
+ if (!block || typeof block !== "object") continue;
2538
+ const b = block;
2539
+ if (b.type !== "toolCall") continue;
2540
+ if (b.name !== "subagent") continue;
2541
+ if (typeof b.id !== "string") continue;
2542
+ if (satisfied.has(b.id)) continue;
2543
+ if (danglingAlreadyHandled.has(b.id)) continue;
2544
+ danglingIds.push(b.id);
2545
+ }
2546
+ break;
2547
+ }
2548
+ if (msg.role === "user") break;
2549
+ }
2550
+ if (danglingIds.length === 0) return;
2551
+ const idList = danglingIds.join(", ");
2552
+ const text = `[pi-pilot] Your previous subagent delegation(s) [${idList}] were cancelled because the server restarted mid-run. The child agent's work may be partially applied to the working tree \u2014 verify before assuming anything happened. Re-delegate if the task still matters.`;
2553
+ sessionManager.appendCustomMessageEntry(
2554
+ CUSTOM_TYPE2,
2555
+ text,
2556
+ true,
2557
+ // display flag — no-op in pi-pilot, harmless in the TUI
2558
+ { ids: danglingIds }
2559
+ );
2560
+ }
2561
+ async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir2()) {
2562
+ let dirNames;
2563
+ try {
2564
+ dirNames = (await readdir(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
2565
+ } catch {
2566
+ return;
2567
+ }
2568
+ let swept = 0;
2569
+ for (const name of dirNames) {
2570
+ const dir = join11(rootDir, name);
2571
+ const pidRaw = await readFile6(join11(dir, "pid"), "utf8").catch(() => "");
2572
+ const [childLine, ownerLine] = pidRaw.split("\n");
2573
+ const childPid = Number.parseInt((childLine ?? "").trim(), 10);
2574
+ const ownerPid = Number.parseInt((ownerLine ?? "").trim(), 10);
2575
+ if (Number.isInteger(ownerPid) && ownerPid > 1 && await isLiveNodeProcess(ownerPid)) {
2576
+ continue;
2577
+ }
2578
+ try {
2579
+ if (Number.isInteger(childPid) && childPid > 1 && await isLiveNodeProcess(childPid)) {
2580
+ try {
2581
+ process.kill(childPid, "SIGTERM");
2582
+ swept++;
2583
+ } catch {
2584
+ }
2585
+ }
2586
+ } finally {
2587
+ await rm4(dir, { recursive: true, force: true }).catch(() => {
2588
+ });
2589
+ }
2590
+ }
2591
+ if (swept > 0) {
2592
+ console.warn(`[subagent] swept ${swept} orphaned child(ren) from a previous run`);
2593
+ }
2594
+ }
2595
+ function isLiveNodeProcess(pid) {
2596
+ try {
2597
+ process.kill(pid, 0);
2598
+ } catch {
2599
+ return Promise.resolve(false);
2600
+ }
2601
+ return new Promise((resolve8) => {
2602
+ execFile2("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
2603
+ if (err2) {
2604
+ resolve8(false);
2605
+ return;
2606
+ }
2607
+ const name = stdout.trim().toLowerCase();
2608
+ resolve8(name === "node" || name === "pi");
2609
+ });
2610
+ });
2611
+ }
2612
+
879
2613
  // src/ws/bridge.ts
880
2614
  function translatePiEvent(ev) {
881
2615
  switch (ev.type) {
@@ -917,13 +2651,16 @@ function translatePiEvent(ev) {
917
2651
  toolName: ev.toolName,
918
2652
  args: ev.args
919
2653
  };
920
- case "tool_execution_update":
2654
+ case "tool_execution_update": {
2655
+ const updateDetails = shouldForwardDetails(ev.toolName) ? ev.partialResult?.details : void 0;
921
2656
  return {
922
2657
  kind: "tool_execution_update",
923
2658
  toolCallId: ev.toolCallId,
924
2659
  toolName: ev.toolName,
925
- partialText: extractText(ev.partialResult)
2660
+ partialText: extractText(ev.partialResult),
2661
+ ...updateDetails !== void 0 ? { details: updateDetails } : {}
926
2662
  };
2663
+ }
927
2664
  case "tool_execution_end": {
928
2665
  const details = shouldForwardDetails(ev.toolName) ? ev.result?.details : void 0;
929
2666
  return {
@@ -1036,6 +2773,37 @@ function inFlightAssistantSnapshot(streamingMessage) {
1036
2773
  }
1037
2774
  return events;
1038
2775
  }
2776
+ function inFlightRunningToolsSnapshot(pendingToolCalls, messages) {
2777
+ const pending2 = new Set(pendingToolCalls);
2778
+ if (pending2.size === 0) return [];
2779
+ const infoById = /* @__PURE__ */ new Map();
2780
+ for (const message of messages) {
2781
+ if (!message || typeof message !== "object") continue;
2782
+ if (message.role !== "assistant") continue;
2783
+ const content = message.content;
2784
+ if (!Array.isArray(content)) continue;
2785
+ for (const block of content) {
2786
+ if (!block || typeof block !== "object") continue;
2787
+ const b = block;
2788
+ if (b.type === "toolCall" && typeof b.id === "string" && pending2.has(b.id)) {
2789
+ infoById.set(b.id, { name: typeof b.name === "string" ? b.name : "tool", args: b.arguments });
2790
+ }
2791
+ }
2792
+ }
2793
+ const events = [];
2794
+ for (const toolCallId of pending2) {
2795
+ const info = infoById.get(toolCallId);
2796
+ if (!info) continue;
2797
+ if (info.name === "ask_user") continue;
2798
+ events.push({
2799
+ kind: "tool_execution_start",
2800
+ toolCallId,
2801
+ toolName: info.name,
2802
+ args: info.args
2803
+ });
2804
+ }
2805
+ return events;
2806
+ }
1039
2807
  function inFlightToolCallsSnapshot(sessionFile) {
1040
2808
  const pending2 = snapshotForSession(sessionFile);
1041
2809
  return pending2.map((p) => ({
@@ -1045,7 +2813,13 @@ function inFlightToolCallsSnapshot(sessionFile) {
1045
2813
  args: p.args
1046
2814
  }));
1047
2815
  }
1048
- var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set(["ask_user"]);
2816
+ var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set([
2817
+ "ask_user",
2818
+ "todo",
2819
+ "subagent",
2820
+ "web_search",
2821
+ "web_fetch"
2822
+ ]);
1049
2823
  function shouldForwardDetails(toolName) {
1050
2824
  return DETAILS_FORWARD_WHITELIST.has(toolName);
1051
2825
  }
@@ -1146,6 +2920,7 @@ var ExtensionUIBridge = class {
1146
2920
 
1147
2921
  // src/workspace-manager.ts
1148
2922
  var EXTENSIONS_ENABLED = process.env.PI_PILOT_ENABLE_EXTENSIONS === "1";
2923
+ var MAX_LIVE_RUNTIMES = 12;
1149
2924
  var createRuntime = async ({
1150
2925
  cwd,
1151
2926
  sessionManager,
@@ -1169,18 +2944,36 @@ var createRuntime = async ({
1169
2944
  diagnostics: services.diagnostics
1170
2945
  };
1171
2946
  };
1172
- var WorkspaceManager = class {
1173
- states = /* @__PURE__ */ new Map();
2947
+ var KEY_SEP = "\0";
2948
+ var SessionRuntimeManager = class {
2949
+ /** All live runtimes, keyed by `runtimeKey`. */
2950
+ runtimes = /* @__PURE__ */ new Map();
2951
+ /** Per-build lock keyed by `runtimeKey` to serialize concurrent creations. */
2952
+ pending = /* @__PURE__ */ new Map();
2953
+ /** The runtime the hub last made primary, per workspace. Drives `get`. */
2954
+ activeByWorkspace = /* @__PURE__ */ new Map();
1174
2955
  /**
1175
- * Subscribers live independently of `states` so the hub can register a
1176
- * WebSocket *before* `getOrCreate` triggers a runtime build (which may
1177
- * fire `session_start` synchronously, and any UI request from a
1178
- * session_start handler would otherwise broadcast to an empty set).
2956
+ * WS subscribers, keyed by workspaceId (not runtimeKey): a connection
2957
+ * viewing any session of a workspace receives that workspace's
2958
+ * server-initiated broadcasts (extension errors, context_usage). Owned by
2959
+ * the manager so it can pre-exist any runtime build (extensions may fire
2960
+ * `onError` from `session_start` before any client subscribed).
1179
2961
  */
1180
2962
  subscribers = /* @__PURE__ */ new Map();
1181
- /** Per-workspace lock to serialize concurrent creations. */
1182
- pending = /* @__PURE__ */ new Map();
1183
- rebindListeners = /* @__PURE__ */ new Map();
2963
+ touchSeq = 0;
2964
+ /** `runtimeKey` for a (workspace, session identity). */
2965
+ keyOf(workspaceId, sessionIdentity) {
2966
+ return `${workspaceId}${KEY_SEP}${sessionIdentity}`;
2967
+ }
2968
+ /** `runtimeKey` for a built runtime, from its session file (or sessionId). */
2969
+ keyForRuntime(workspaceId, runtime) {
2970
+ const file = runtime.session.sessionFile;
2971
+ return this.keyOf(workspaceId, file ? resolve4(file) : runtime.session.sessionId);
2972
+ }
2973
+ /** Public so the WS hub derives the exact same key for a returned runtime. */
2974
+ runtimeKeyFor(workspaceId, runtime) {
2975
+ return this.keyForRuntime(workspaceId, runtime);
2976
+ }
1184
2977
  getOrCreateSubscriberSet(workspaceId) {
1185
2978
  let set = this.subscribers.get(workspaceId);
1186
2979
  if (!set) {
@@ -1189,64 +2982,187 @@ var WorkspaceManager = class {
1189
2982
  }
1190
2983
  return set;
1191
2984
  }
1192
- async getOrCreate(workspaceId) {
1193
- const existing = this.states.get(workspaceId);
1194
- if (existing) return existing.runtime;
1195
- const inflight3 = this.pending.get(workspaceId);
1196
- if (inflight3) return (await inflight3).runtime;
1197
- const p = this.build(workspaceId);
1198
- this.pending.set(workspaceId, p);
1199
- try {
1200
- const state = await p;
1201
- this.states.set(workspaceId, state);
1202
- return state.runtime;
1203
- } finally {
1204
- this.pending.delete(workspaceId);
1205
- }
1206
- }
1207
- async build(workspaceId) {
1208
- const ws = await getWorkspace(workspaceId);
1209
- if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1210
- const sessionManager = SessionManager.continueRecent(ws.path);
2985
+ /**
2986
+ * Build (but do not register) a runtime for `workspaceId` from a
2987
+ * SessionManager factory. Binds the UI bridge + onError and runs ask_user
2988
+ * post-restart cleanup. The bridge broadcasts to the workspace's subscriber
2989
+ * set, resolved lazily so it works even if the set is created later.
2990
+ */
2991
+ async buildState(workspaceId, cwd, makeSessionManager) {
1211
2992
  const runtime = await createAgentSessionRuntime(createRuntime, {
1212
- cwd: ws.path,
1213
- agentDir: getAgentDir(),
1214
- sessionManager
2993
+ cwd,
2994
+ agentDir: getAgentDir2(),
2995
+ sessionManager: makeSessionManager()
1215
2996
  });
1216
- const subscribers = this.getOrCreateSubscriberSet(workspaceId);
1217
2997
  const bridge = new ExtensionUIBridge();
1218
- const onError = (err) => {
2998
+ await this.bindExtensions(workspaceId, runtime, bridge);
2999
+ reapplyToolPrefs(workspaceId, runtime.session);
3000
+ safeReconcileBuiltins(workspaceId, runtime.session.sessionManager);
3001
+ return {
3002
+ runtime,
3003
+ bridge,
3004
+ workspaceId,
3005
+ sessionPath: runtime.session.sessionFile ? resolve4(runtime.session.sessionFile) : null,
3006
+ touchedAt: ++this.touchSeq
3007
+ };
3008
+ }
3009
+ /** Bind (or re-bind, after a fork) the UI context + onError on a session. */
3010
+ async bindExtensions(workspaceId, runtime, bridge) {
3011
+ const onError = (err2) => {
1219
3012
  const msg = {
1220
3013
  type: "extension_error",
1221
3014
  workspaceId,
1222
- extensionPath: err.extensionPath,
1223
- event: err.event,
1224
- message: err.error
3015
+ extensionPath: err2.extensionPath,
3016
+ event: err2.event,
3017
+ message: err2.error
1225
3018
  };
1226
- broadcastTo(subscribers, msg);
3019
+ const set = this.subscribers.get(workspaceId);
3020
+ if (set) broadcastTo(set, msg);
1227
3021
  console.error(
1228
- `[ext-error] ${workspaceId} ${err.extensionPath}@${err.event}: ${err.error}` + (err.stack ? `
1229
- ${err.stack}` : "")
3022
+ `[ext-error] ${workspaceId} ${err2.extensionPath}@${err2.event}: ${err2.error}` + (err2.stack ? `
3023
+ ${err2.stack}` : "")
1230
3024
  );
1231
3025
  };
1232
3026
  await runtime.session.bindExtensions({ uiContext: bridge, onError });
1233
- safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
1234
- runtime.setRebindSession(async () => {
1235
- await runtime.session.bindExtensions({ uiContext: bridge, onError });
1236
- cancelPendingExcept(runtime.session.sessionFile ?? null);
1237
- safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
1238
- this.notifySessionReplaced(workspaceId);
1239
- });
1240
- return { runtime, bridge };
1241
3027
  }
3028
+ /** Register a freshly-built state under its session key, deduping against a
3029
+ * concurrent build of the same session. Returns the winning state. */
3030
+ async adopt(state) {
3031
+ const key = this.keyForRuntime(state.workspaceId, state.runtime);
3032
+ const existing = this.runtimes.get(key);
3033
+ if (existing) {
3034
+ await this.disposeState(state);
3035
+ return existing;
3036
+ }
3037
+ this.runtimes.set(key, state);
3038
+ this.evictIfOverCap(key);
3039
+ return state;
3040
+ }
3041
+ /**
3042
+ * Ensure a runtime exists for the target session and return it.
3043
+ *
3044
+ * - With `sessionPath`: opens that specific session (deduped by key).
3045
+ * - Without: returns the workspace's active/any live runtime, or builds the
3046
+ * "continue recent" default if none exists yet.
3047
+ *
3048
+ * Does NOT change the active pointer — the hub owns that via `setActive`.
3049
+ */
3050
+ async getOrCreate(workspaceId, sessionPath) {
3051
+ const ws = await getWorkspace(workspaceId);
3052
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
3053
+ if (sessionPath) {
3054
+ if (!isAbsolute2(sessionPath)) throw new Error("Session path must be absolute");
3055
+ const resolved = resolve4(sessionPath);
3056
+ const key = this.keyOf(workspaceId, resolved);
3057
+ const existing2 = this.runtimes.get(key);
3058
+ if (existing2) {
3059
+ existing2.touchedAt = ++this.touchSeq;
3060
+ return existing2.runtime;
3061
+ }
3062
+ const inflight4 = this.pending.get(key);
3063
+ if (inflight4) return (await inflight4).runtime;
3064
+ const p2 = this.buildState(
3065
+ workspaceId,
3066
+ ws.path,
3067
+ () => SessionManager.open(resolved, void 0, ws.path)
3068
+ ).then((s) => this.adopt(s));
3069
+ this.pending.set(key, p2);
3070
+ try {
3071
+ return (await p2).runtime;
3072
+ } finally {
3073
+ this.pending.delete(key);
3074
+ }
3075
+ }
3076
+ const existing = this.get(workspaceId);
3077
+ if (existing) return existing;
3078
+ const defaultKey = this.keyOf(workspaceId, "<default>");
3079
+ const inflight3 = this.pending.get(defaultKey);
3080
+ if (inflight3) return (await inflight3).runtime;
3081
+ const p = this.buildState(
3082
+ workspaceId,
3083
+ ws.path,
3084
+ () => SessionManager.continueRecent(ws.path)
3085
+ ).then((s) => this.adopt(s));
3086
+ this.pending.set(defaultKey, p);
3087
+ try {
3088
+ return (await p).runtime;
3089
+ } finally {
3090
+ this.pending.delete(defaultKey);
3091
+ }
3092
+ }
3093
+ /** Create a brand-new empty session + runtime for the workspace (does not
3094
+ * touch any existing runtime, so a streaming session keeps running). */
3095
+ async createSession(workspaceId) {
3096
+ const ws = await getWorkspace(workspaceId);
3097
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
3098
+ const state = await this.buildState(
3099
+ workspaceId,
3100
+ ws.path,
3101
+ () => SessionManager.create(ws.path)
3102
+ );
3103
+ return (await this.adopt(state)).runtime;
3104
+ }
3105
+ /**
3106
+ * Fork an existing session at `entryId` into a new branched session and
3107
+ * return a runtime bound to the branch. The source session's own runtime
3108
+ * (if any) is untouched. Returns `{ cancelled: true }` if pi cancelled the
3109
+ * fork (e.g. a `session_before_switch` veto).
3110
+ */
3111
+ async fork(workspaceId, sourceSessionPath, entryId) {
3112
+ const ws = await getWorkspace(workspaceId);
3113
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
3114
+ if (!isAbsolute2(sourceSessionPath)) throw new Error("Session path must be absolute");
3115
+ const state = await this.buildState(
3116
+ workspaceId,
3117
+ ws.path,
3118
+ () => SessionManager.open(resolve4(sourceSessionPath), void 0, ws.path)
3119
+ );
3120
+ let result;
3121
+ try {
3122
+ result = await state.runtime.fork(entryId);
3123
+ } catch (err2) {
3124
+ await this.disposeState(state);
3125
+ throw err2;
3126
+ }
3127
+ if (result.cancelled) {
3128
+ await this.disposeState(state);
3129
+ return { cancelled: true };
3130
+ }
3131
+ await this.bindExtensions(workspaceId, state.runtime, state.bridge);
3132
+ safeReconcileBuiltins(workspaceId, state.runtime.session.sessionManager);
3133
+ state.sessionPath = state.runtime.session.sessionFile ? resolve4(state.runtime.session.sessionFile) : null;
3134
+ const winner = await this.adopt(state);
3135
+ return { cancelled: false, runtime: winner.runtime };
3136
+ }
3137
+ /** The active session's runtime for this workspace (hub-designated), or any
3138
+ * live runtime for it, or undefined. Used by per-workspace REST routes. */
1242
3139
  get(workspaceId) {
1243
- return this.states.get(workspaceId)?.runtime;
3140
+ const activeKey = this.activeByWorkspace.get(workspaceId);
3141
+ if (activeKey) {
3142
+ const active = this.runtimes.get(activeKey);
3143
+ if (active) return active.runtime;
3144
+ }
3145
+ for (const state of this.runtimes.values()) {
3146
+ if (state.workspaceId === workspaceId) return state.runtime;
3147
+ }
3148
+ return void 0;
3149
+ }
3150
+ /** The runtime bound to a specific (workspace, session), if live. */
3151
+ getForSession(workspaceId, sessionPath) {
3152
+ return this.runtimes.get(this.keyOf(workspaceId, resolve4(sessionPath)))?.runtime;
3153
+ }
3154
+ /** Mark `runtime` as the active session for its workspace (hub on primary
3155
+ * bind), so per-workspace routes resolve to it. */
3156
+ setActive(workspaceId, runtime) {
3157
+ const key = this.keyForRuntime(workspaceId, runtime);
3158
+ this.activeByWorkspace.set(workspaceId, key);
3159
+ const state = this.runtimes.get(key);
3160
+ if (state) state.touchedAt = ++this.touchSeq;
1244
3161
  }
1245
3162
  /**
1246
- * Register a WS connection as a subscriber for `workspaceId`. Safe to
1247
- * call before `getOrCreate`; the set is lazily created so the bridge,
1248
- * when later built, sees the same Set instance and any pre-existing
1249
- * subscribers.
3163
+ * Register a WS connection as a subscriber for `workspaceId` (server-
3164
+ * initiated broadcasts: extension errors, context_usage). Safe to call
3165
+ * before any runtime build.
1250
3166
  */
1251
3167
  addSubscriber(workspaceId, ws) {
1252
3168
  this.getOrCreateSubscriberSet(workspaceId).add(ws);
@@ -1257,60 +3173,44 @@ ${err.stack}` : "")
1257
3173
  set.delete(ws);
1258
3174
  if (set.size === 0) this.subscribers.delete(workspaceId);
1259
3175
  }
1260
- /**
1261
- * Fan a server-initiated message out to every WS subscribed to the
1262
- * workspace. Used by API handlers that mutate runtime state and need
1263
- * to refresh derived snapshots (e.g. `context_usage` after `setModel`,
1264
- * which pi's event stream doesn't surface unless thinking-level also
1265
- * clamps).
1266
- */
3176
+ /** Fan a server-initiated message out to every WS subscribed to the
3177
+ * workspace (e.g. context_usage after setModel). */
1267
3178
  broadcast(workspaceId, msg) {
1268
3179
  const set = this.subscribers.get(workspaceId);
1269
3180
  if (!set || set.size === 0) return;
1270
3181
  broadcastTo(set, msg);
1271
3182
  }
1272
- onSessionReplaced(workspaceId, listener) {
1273
- let listeners = this.rebindListeners.get(workspaceId);
1274
- if (!listeners) {
1275
- listeners = /* @__PURE__ */ new Set();
1276
- this.rebindListeners.set(workspaceId, listeners);
1277
- }
1278
- listeners.add(listener);
1279
- return () => {
1280
- const current = this.rebindListeners.get(workspaceId);
1281
- if (!current) return;
1282
- current.delete(listener);
1283
- if (current.size === 0) {
1284
- this.rebindListeners.delete(workspaceId);
1285
- }
1286
- };
1287
- }
1288
- notifySessionReplaced(workspaceId) {
1289
- const listeners = this.rebindListeners.get(workspaceId);
1290
- if (!listeners) return;
1291
- for (const listener of [...listeners]) {
1292
- try {
1293
- listener();
1294
- } catch (e) {
1295
- console.error(`[wm] rebind listener for ${workspaceId} failed:`, e);
1296
- }
1297
- }
3183
+ /**
3184
+ * Check that `sessionPath` belongs to the given workspace's session list.
3185
+ * Returns an error string if validation fails, or `null` if the path is
3186
+ * owned by this workspace.
3187
+ */
3188
+ async validateSessionOwnership(workspaceId, sessionPath) {
3189
+ if (!isAbsolute2(sessionPath)) return "session path must be absolute";
3190
+ const ws = await getWorkspace(workspaceId);
3191
+ if (!ws) return `workspace not found: ${workspaceId}`;
3192
+ const sessions = await SessionManager.list(ws.path);
3193
+ const resolved = resolve4(sessionPath);
3194
+ const found = sessions.some((s) => resolve4(s.path) === resolved);
3195
+ if (!found) return `session not found in workspace: ${sessionPath}`;
3196
+ return null;
1298
3197
  }
1299
3198
  async listSessions(workspaceId) {
1300
3199
  const ws = await getWorkspace(workspaceId);
1301
3200
  if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
3201
+ const streaming = /* @__PURE__ */ new Set();
3202
+ for (const state of this.runtimes.values()) {
3203
+ if (state.workspaceId !== workspaceId) continue;
3204
+ if (state.sessionPath && state.runtime.session.isStreaming) {
3205
+ streaming.add(state.sessionPath);
3206
+ }
3207
+ }
1302
3208
  const sessions = await SessionManager.list(ws.path);
1303
- return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map(toSessionSummary);
3209
+ return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(resolve4(info.path))));
1304
3210
  }
1305
3211
  getSessionHistory(workspaceId, sessionPath) {
1306
- const runtime = this.states.get(workspaceId)?.runtime;
3212
+ const runtime = sessionPath ? this.getForSession(workspaceId, sessionPath) : this.get(workspaceId);
1307
3213
  if (!runtime) return { items: [], isStreaming: false };
1308
- if (sessionPath) {
1309
- const activeFile = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
1310
- if (activeFile !== resolve2(sessionPath)) {
1311
- return { items: [], isStreaming: false };
1312
- }
1313
- }
1314
3214
  const isStreaming = runtime.session.isStreaming ?? false;
1315
3215
  const branch = runtime.session.sessionManager.getBranch();
1316
3216
  const items = [];
@@ -1321,7 +3221,7 @@ ${err.stack}` : "")
1321
3221
  const role = msg.role;
1322
3222
  if (role === "user") {
1323
3223
  const text = extractUserText2(msg);
1324
- if (text) items.push({ kind: "user", text });
3224
+ if (text) items.push({ kind: "user", text, entryId: entry.id });
1325
3225
  } else if (role === "assistant") {
1326
3226
  const { text, thinking, toolCalls } = extractAssistantContent(
1327
3227
  msg
@@ -1359,15 +3259,13 @@ ${err.stack}` : "")
1359
3259
  /**
1360
3260
  * Delete a session JSONL file belonging to this workspace.
1361
3261
  *
1362
- * Errors are tagged with HTTP semantics via HttpError so the route layer
1363
- * can map them to the right status code:
3262
+ * HTTP-tagged errors (HttpError) map to status codes at the route layer:
1364
3263
  * - 400: sessionPath not absolute
1365
3264
  * - 404: workspace gone, or session not in this workspace's list
1366
- * - 409: file is the currently-active session (caller must switch first)
3265
+ * - 409: a live runtime is bound to it and is streaming (stop it first)
1367
3266
  *
1368
- * Idempotent on ENOENT: if the file is missing at unlink time (e.g. a
1369
- * concurrent external delete between list and unlink), we treat it as
1370
- * success — the goal state has been reached.
3267
+ * If a live but idle runtime is bound to the session, it is disposed before
3268
+ * the file is unlinked. Idempotent on ENOENT.
1371
3269
  */
1372
3270
  async deleteSession(workspaceId, sessionPath) {
1373
3271
  const ws = await getWorkspace(workspaceId);
@@ -1376,90 +3274,124 @@ ${err.stack}` : "")
1376
3274
  throw new HttpError(400, "Session path must be absolute");
1377
3275
  }
1378
3276
  const sessions = await SessionManager.list(ws.path);
1379
- const resolved = resolve2(sessionPath);
1380
- const target = sessions.find((session) => resolve2(session.path) === resolved);
3277
+ const resolved = resolve4(sessionPath);
3278
+ const target = sessions.find((session) => resolve4(session.path) === resolved);
1381
3279
  if (!target) {
1382
3280
  throw new HttpError(404, `Session not found: ${sessionPath}`);
1383
3281
  }
1384
- const runtime = this.states.get(workspaceId)?.runtime;
1385
- const activePath = runtime?.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
1386
- if (activePath === resolved) {
1387
- throw new HttpError(
1388
- 409,
1389
- "Cannot delete the currently active session \u2014 switch to another session first"
1390
- );
3282
+ const key = this.keyOf(workspaceId, resolved);
3283
+ const live = this.runtimes.get(key);
3284
+ if (live) {
3285
+ if (live.runtime.session.isStreaming) {
3286
+ throw new HttpError(
3287
+ 409,
3288
+ "Cannot delete a streaming session \u2014 stop it first"
3289
+ );
3290
+ }
3291
+ await this.disposeState(live, key);
1391
3292
  }
1392
3293
  try {
1393
3294
  await unlink2(resolved);
1394
- } catch (err) {
1395
- if (err?.code === "ENOENT") {
3295
+ } catch (err2) {
3296
+ if (err2?.code === "ENOENT") {
1396
3297
  console.warn(
1397
3298
  `[wm] deleteSession: ${resolved} was already gone at unlink time`
1398
3299
  );
3300
+ await forgetSessionToolPrefs(resolved);
1399
3301
  return;
1400
- }
1401
- throw err;
1402
- }
1403
- }
1404
- async switchSession(workspaceId, sessionPath) {
1405
- const ws = await getWorkspace(workspaceId);
1406
- if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1407
- if (!isAbsolute2(sessionPath)) {
1408
- throw new Error("Session path must be absolute");
1409
- }
1410
- const sessions = await SessionManager.list(ws.path);
1411
- const resolved = resolve2(sessionPath);
1412
- const target = sessions.find((session) => resolve2(session.path) === resolved);
1413
- if (!target) {
1414
- throw new Error(`Session not found: ${sessionPath}`);
1415
- }
1416
- const runtime = await this.getOrCreate(workspaceId);
1417
- const currentPath = runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : void 0;
1418
- if (currentPath === resolved) return false;
1419
- if (runtime.session.isStreaming) {
1420
- throw new Error("Cannot switch sessions while the agent is streaming");
3302
+ }
3303
+ throw err2;
1421
3304
  }
1422
- const result = await runtime.switchSession(resolved, { cwdOverride: ws.path });
1423
- return !result.cancelled;
3305
+ await forgetSessionToolPrefs(resolved);
1424
3306
  }
3307
+ /** Dispose every runtime for a workspace (e.g. when it's removed). */
1425
3308
  async dispose(workspaceId) {
1426
- const state = this.states.get(workspaceId);
1427
- if (!state) return;
1428
- this.states.delete(workspaceId);
1429
- this.rebindListeners.delete(workspaceId);
3309
+ this.activeByWorkspace.delete(workspaceId);
1430
3310
  this.subscribers.delete(workspaceId);
3311
+ const doomed = [...this.runtimes].filter(([, s]) => s.workspaceId === workspaceId);
3312
+ for (const [key, state] of doomed) {
3313
+ await this.disposeState(state, key);
3314
+ }
3315
+ }
3316
+ async disposeAll() {
3317
+ await Promise.allSettled([...this.pending.values()]);
3318
+ const states = [...this.runtimes.entries()];
3319
+ this.runtimes.clear();
3320
+ this.activeByWorkspace.clear();
3321
+ this.subscribers.clear();
3322
+ await Promise.all(states.map(([, state]) => this.disposeState(state)));
3323
+ }
3324
+ /** Tear down a single runtime + its bridge, releasing any ask_user Promises
3325
+ * bound to it first. Removes it from `runtimes` when `key` is given. */
3326
+ async disposeState(state, key) {
3327
+ if (key) this.runtimes.delete(key);
3328
+ if (key && this.activeByWorkspace.get(state.workspaceId) === key) {
3329
+ this.activeByWorkspace.delete(state.workspaceId);
3330
+ }
1431
3331
  cancelPendingForSession(state.runtime.session.sessionFile ?? null);
1432
3332
  try {
1433
3333
  state.bridge.dispose();
1434
3334
  } catch (e) {
1435
- console.error(`[wm] dispose bridge ${workspaceId} failed:`, e);
3335
+ console.error(`[wm] dispose bridge ${state.workspaceId} failed:`, e);
1436
3336
  }
1437
3337
  try {
1438
- state.runtime.session.dispose();
3338
+ await state.runtime.dispose();
1439
3339
  } catch (e) {
1440
- console.error(`[wm] dispose ${workspaceId} failed:`, e);
3340
+ console.error(`[wm] dispose ${state.workspaceId} failed:`, e);
3341
+ }
3342
+ const sweptChildren = killChildrenForSession(state.runtime.session.sessionFile ?? null);
3343
+ if (sweptChildren > 0) {
3344
+ console.warn(
3345
+ `[wm] killed ${sweptChildren} lingering subagent child(ren) for ${state.workspaceId}`
3346
+ );
1441
3347
  }
1442
3348
  }
1443
- async disposeAll() {
1444
- await Promise.allSettled([...this.pending.values()]);
1445
- const ids = [...this.states.keys()];
1446
- await Promise.all(ids.map((id) => this.dispose(id)));
3349
+ /**
3350
+ * Keep the live-runtime count under `MAX_LIVE_RUNTIMES` by disposing the
3351
+ * least-recently-touched IDLE runtime (never streaming, never the freshly-
3352
+ * registered one, never a workspace's active pointer). Best-effort: if every
3353
+ * runtime is busy we simply exceed the cap until one frees up.
3354
+ */
3355
+ evictIfOverCap(justRegistered) {
3356
+ while (this.runtimes.size > MAX_LIVE_RUNTIMES) {
3357
+ let victimKey;
3358
+ let victimTouched = Infinity;
3359
+ for (const [key, state] of this.runtimes) {
3360
+ if (key === justRegistered) continue;
3361
+ if (this.activeByWorkspace.get(state.workspaceId) === key) continue;
3362
+ if (state.runtime.session.isStreaming) continue;
3363
+ if (state.touchedAt < victimTouched) {
3364
+ victimTouched = state.touchedAt;
3365
+ victimKey = key;
3366
+ }
3367
+ }
3368
+ if (!victimKey) break;
3369
+ const victim = this.runtimes.get(victimKey);
3370
+ if (!victim) break;
3371
+ void this.disposeState(victim, victimKey);
3372
+ }
1447
3373
  }
1448
3374
  };
1449
- function safeReconcileAskUser(workspaceId, sm) {
3375
+ function safeReconcileBuiltins(workspaceId, sm) {
1450
3376
  try {
1451
3377
  reconcileAfterRestart(sm);
1452
3378
  } catch (e) {
1453
3379
  console.error(`[wm] ask_user cleanup for ${workspaceId} failed:`, e);
1454
3380
  }
3381
+ try {
3382
+ reconcileAfterRestart2(sm);
3383
+ } catch (e) {
3384
+ console.error(`[wm] subagent cleanup for ${workspaceId} failed:`, e);
3385
+ }
1455
3386
  }
1456
- function toSessionSummary(info) {
3387
+ function toSessionSummary(info, running2) {
1457
3388
  const preview = info.firstMessage.replace(/\s+/g, " ").trim();
1458
3389
  return {
1459
3390
  path: info.path,
1460
3391
  name: info.name,
1461
3392
  updatedAt: info.modified.toISOString(),
1462
- preview: preview ? preview.slice(0, 160) : void 0
3393
+ preview: preview ? preview.slice(0, 160) : void 0,
3394
+ ...running2 ? { running: true } : {}
1463
3395
  };
1464
3396
  }
1465
3397
  function extractUserText2(msg) {
@@ -1495,7 +3427,7 @@ function extractContentText(content) {
1495
3427
  }
1496
3428
  return parts.join("");
1497
3429
  }
1498
- var workspaceManager = new WorkspaceManager();
3430
+ var workspaceManager = new SessionRuntimeManager();
1499
3431
  function broadcastTo(subscribers, msg) {
1500
3432
  const wire = JSON.stringify(msg);
1501
3433
  for (const ws of subscribers) {
@@ -1508,6 +3440,9 @@ function broadcastTo(subscribers, msg) {
1508
3440
  }
1509
3441
 
1510
3442
  // src/api/config.ts
3443
+ var BUILTIN_TOOL_SOURCE = new Map(
3444
+ BUILTIN_EXTENSIONS.flatMap((d) => d.tools.map((tool) => [tool, d.name]))
3445
+ );
1511
3446
  function buildConfigResponse(workspaceId) {
1512
3447
  const runtime = workspaceManager.get(workspaceId);
1513
3448
  if (!runtime) throw new Error("runtime not initialized");
@@ -1525,10 +3460,14 @@ function buildConfigResponse(workspaceId) {
1525
3460
  name: m.name,
1526
3461
  reasoning: m.reasoning
1527
3462
  }));
1528
- const allTools = session.getAllTools().map((t) => ({
1529
- name: t.name,
1530
- description: t.description
1531
- }));
3463
+ const allTools = session.getAllTools().map((t) => {
3464
+ const builtinExtension = BUILTIN_TOOL_SOURCE.get(t.name);
3465
+ return {
3466
+ name: t.name,
3467
+ description: t.description,
3468
+ ...builtinExtension ? { builtinExtension } : {}
3469
+ };
3470
+ });
1532
3471
  return {
1533
3472
  currentModel,
1534
3473
  thinkingLevel: session.thinkingLevel,
@@ -1578,8 +3517,8 @@ function mountConfigRoutes(app2) {
1578
3517
  try {
1579
3518
  await workspaceManager.getOrCreate(id);
1580
3519
  return c.json(buildConfigResponse(id));
1581
- } catch (err) {
1582
- const message = err instanceof Error ? err.message : String(err);
3520
+ } catch (err2) {
3521
+ const message = err2 instanceof Error ? err2.message : String(err2);
1583
3522
  return c.json({ ok: false, error: message }, 500);
1584
3523
  }
1585
3524
  });
@@ -1605,8 +3544,8 @@ function mountConfigRoutes(app2) {
1605
3544
  await runtime.session.setModel(model);
1606
3545
  broadcastContextUsage(id, runtime);
1607
3546
  return c.json(buildConfigResponse(id));
1608
- } catch (err) {
1609
- const message = err instanceof Error ? err.message : String(err);
3547
+ } catch (err2) {
3548
+ const message = err2 instanceof Error ? err2.message : String(err2);
1610
3549
  return c.json({ ok: false, error: message }, 500);
1611
3550
  }
1612
3551
  });
@@ -1630,8 +3569,8 @@ function mountConfigRoutes(app2) {
1630
3569
  }
1631
3570
  runtime.session.setThinkingLevel(body.level);
1632
3571
  return c.json(buildConfigResponse(id));
1633
- } catch (err) {
1634
- const message = err instanceof Error ? err.message : String(err);
3572
+ } catch (err2) {
3573
+ const message = err2 instanceof Error ? err2.message : String(err2);
1635
3574
  return c.json({ ok: false, error: message }, 500);
1636
3575
  }
1637
3576
  });
@@ -1652,21 +3591,50 @@ function mountConfigRoutes(app2) {
1652
3591
  if (!runtime) {
1653
3592
  return c.json({ ok: false, error: "runtime not initialized" }, 500);
1654
3593
  }
1655
- runtime.session.setActiveToolsByName(body.tools);
3594
+ await persistActiveTools(id, runtime.session, body.tools);
1656
3595
  return c.json(buildConfigResponse(id));
1657
- } catch (err) {
1658
- const message = err instanceof Error ? err.message : String(err);
3596
+ } catch (err2) {
3597
+ const message = err2 instanceof Error ? err2.message : String(err2);
3598
+ return c.json({ ok: false, error: message }, 500);
3599
+ }
3600
+ });
3601
+ app2.put("/:id/config/session-name", async (c) => {
3602
+ const id = c.req.param("id");
3603
+ const exists2 = await requireWorkspace(c, id);
3604
+ if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
3605
+ const body = await c.req.json();
3606
+ if (typeof body?.name !== "string") {
3607
+ return c.json({ ok: false, error: "name is required" }, 400);
3608
+ }
3609
+ const trimmed = body.name.trim();
3610
+ if (!trimmed) {
3611
+ return c.json({ ok: false, error: "name must not be empty" }, 400);
3612
+ }
3613
+ try {
3614
+ const sessionPath = c.req.query("sessionPath");
3615
+ if (sessionPath) {
3616
+ const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
3617
+ if (err2) return c.json({ ok: false, error: err2 }, 404);
3618
+ }
3619
+ const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
3620
+ if (runtime.session.isStreaming) {
3621
+ return c.json({ ok: false, error: "cannot rename while the agent is streaming" }, 409);
3622
+ }
3623
+ runtime.session.setSessionName(trimmed);
3624
+ return c.json({ name: trimmed });
3625
+ } catch (err2) {
3626
+ const message = err2 instanceof Error ? err2.message : String(err2);
1659
3627
  return c.json({ ok: false, error: message }, 500);
1660
3628
  }
1661
3629
  });
1662
3630
  }
1663
3631
 
1664
3632
  // src/api/files.ts
1665
- import { execFile as execFile2 } from "child_process";
1666
- import { readdir } from "fs/promises";
1667
- import { join as join5, relative, sep as sep2 } from "path";
3633
+ import { execFile as execFile3 } from "child_process";
3634
+ import { readdir as readdir2 } from "fs/promises";
3635
+ import { join as join12, relative, sep as sep2 } from "path";
1668
3636
  import { promisify as promisify2 } from "util";
1669
- var exec2 = promisify2(execFile2);
3637
+ var exec2 = promisify2(execFile3);
1670
3638
  var LIST_TTL_MS = 1e4;
1671
3639
  var MAX_CACHED_WORKSPACES = 16;
1672
3640
  var MAX_FILES_TRACKED = 2e4;
@@ -1694,8 +3662,8 @@ var listCache = /* @__PURE__ */ new Map();
1694
3662
  var inflight2 = /* @__PURE__ */ new Map();
1695
3663
  async function getFileList(workspacePath) {
1696
3664
  const now = Date.now();
1697
- const cached = listCache.get(workspacePath);
1698
- if (cached && cached.expiresAt > now) return cached.files;
3665
+ const cached2 = listCache.get(workspacePath);
3666
+ if (cached2 && cached2.expiresAt > now) return cached2.files;
1699
3667
  const pending2 = inflight2.get(workspacePath);
1700
3668
  if (pending2) return (await pending2).files;
1701
3669
  const probe = probeFileList(workspacePath).then((files) => {
@@ -1746,14 +3714,14 @@ async function walkDir(root, dir, depth, out) {
1746
3714
  if (depth > WALK_MAX_DEPTH) return;
1747
3715
  let dirents;
1748
3716
  try {
1749
- dirents = await readdir(dir, { withFileTypes: true });
3717
+ dirents = await readdir2(dir, { withFileTypes: true });
1750
3718
  } catch {
1751
3719
  return;
1752
3720
  }
1753
3721
  for (const d of dirents) {
1754
3722
  if (out.length >= MAX_FILES_TRACKED) return;
1755
3723
  if (WALK_IGNORES.has(d.name)) continue;
1756
- const abs = join5(dir, d.name);
3724
+ const abs = join12(dir, d.name);
1757
3725
  if (d.isDirectory()) {
1758
3726
  await walkDir(root, abs, depth + 1, out);
1759
3727
  } else if (d.isFile()) {
@@ -1793,7 +3761,7 @@ function mountFilesRoute(app2) {
1793
3761
  if (!qRaw) {
1794
3762
  const slice = all.slice(0, limit);
1795
3763
  entries = slice.map((relPath) => ({
1796
- path: join5(workspacePath, relPath),
3764
+ path: join12(workspacePath, relPath),
1797
3765
  relPath
1798
3766
  }));
1799
3767
  truncated = all.length > limit;
@@ -1810,25 +3778,25 @@ function mountFilesRoute(app2) {
1810
3778
  scored.sort((a, b) => b.score - a.score);
1811
3779
  const top = scored.slice(0, limit);
1812
3780
  entries = top.map((e) => ({
1813
- path: join5(workspacePath, e.relPath),
3781
+ path: join12(workspacePath, e.relPath),
1814
3782
  relPath: e.relPath
1815
3783
  }));
1816
3784
  truncated = matchCount > limit;
1817
3785
  }
1818
3786
  const body = { workspacePath, entries, truncated };
1819
3787
  return c.json(body);
1820
- } catch (err) {
1821
- const message = err instanceof Error ? err.message : String(err);
1822
- console.error(`[api/files] search for ${id} failed:`, err);
3788
+ } catch (err2) {
3789
+ const message = err2 instanceof Error ? err2.message : String(err2);
3790
+ console.error(`[api/files] search for ${id} failed:`, err2);
1823
3791
  return c.json({ ok: false, error: message }, 500);
1824
3792
  }
1825
3793
  });
1826
3794
  }
1827
3795
 
1828
3796
  // src/api/resources.ts
1829
- import { readdir as readdir2 } from "fs/promises";
1830
- import { join as join6 } from "path";
1831
- import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
3797
+ import { readdir as readdir3 } from "fs/promises";
3798
+ import { join as join13 } from "path";
3799
+ import { getAgentDir as getAgentDir3 } from "@earendil-works/pi-coding-agent";
1832
3800
  function toResourceSource(info) {
1833
3801
  return {
1834
3802
  scope: info.scope,
@@ -1837,16 +3805,16 @@ function toResourceSource(info) {
1837
3805
  };
1838
3806
  }
1839
3807
  async function scanExtensionDirs(workspaceCwd) {
1840
- const dirs = [join6(getAgentDir2(), "extensions"), join6(workspaceCwd, ".pi", "extensions")];
3808
+ const dirs = [join13(getAgentDir3(), "extensions"), join13(workspaceCwd, ".pi", "extensions")];
1841
3809
  const found = [];
1842
3810
  for (const dir of dirs) {
1843
3811
  try {
1844
- const entries = await readdir2(dir, { withFileTypes: true });
3812
+ const entries = await readdir3(dir, { withFileTypes: true });
1845
3813
  for (const entry of entries) {
1846
3814
  if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
1847
- found.push(join6(dir, entry.name));
3815
+ found.push(join13(dir, entry.name));
1848
3816
  } else if (entry.isDirectory()) {
1849
- found.push(join6(dir, entry.name));
3817
+ found.push(join13(dir, entry.name));
1850
3818
  }
1851
3819
  }
1852
3820
  } catch {
@@ -1901,9 +3869,9 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
1901
3869
  shortcuts: [...e.shortcuts.keys()]
1902
3870
  };
1903
3871
  });
1904
- const extensionErrors = extResult.errors.map((err) => ({
1905
- path: err.path,
1906
- error: err.error
3872
+ const extensionErrors = extResult.errors.map((err2) => ({
3873
+ path: err2.path,
3874
+ error: err2.error
1907
3875
  }));
1908
3876
  const disabledExtensions = EXTENSIONS_ENABLED ? [] : await scanExtensionDirs(workspaceCwd);
1909
3877
  const disabledBuiltins = new Set(getDisabledBuiltins());
@@ -1928,15 +3896,15 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
1928
3896
  async function rootsFor(workspaceId) {
1929
3897
  const ws = await getWorkspace(workspaceId);
1930
3898
  if (!ws) throw new HttpError(404, "workspace not found");
1931
- const roots = resolveResourceRoots({ agentDir: getAgentDir2(), workspaceCwd: ws.path });
3899
+ const roots = resolveResourceRoots({ agentDir: getAgentDir3(), workspaceCwd: ws.path });
1932
3900
  return { roots, workspaceCwd: ws.path };
1933
3901
  }
1934
- function respondError(c, err) {
1935
- if (err instanceof HttpError) {
1936
- return c.json({ ok: false, error: err.message }, err.status);
3902
+ function respondError(c, err2) {
3903
+ if (err2 instanceof HttpError) {
3904
+ return c.json({ ok: false, error: err2.message }, err2.status);
1937
3905
  }
1938
- const message = err instanceof Error ? err.message : String(err);
1939
- console.error(`[api/resources] unexpected error:`, err);
3906
+ const message = err2 instanceof Error ? err2.message : String(err2);
3907
+ console.error(`[api/resources] unexpected error:`, err2);
1940
3908
  return c.json({ ok: false, error: message }, 500);
1941
3909
  }
1942
3910
  async function reload(workspaceId) {
@@ -1953,8 +3921,8 @@ function mountResourcesRoute(app2) {
1953
3921
  await workspaceManager.getOrCreate(id);
1954
3922
  const { roots, workspaceCwd } = await rootsFor(id);
1955
3923
  return c.json(await snapshot(id, roots, workspaceCwd));
1956
- } catch (err) {
1957
- return respondError(c, err);
3924
+ } catch (err2) {
3925
+ return respondError(c, err2);
1958
3926
  }
1959
3927
  });
1960
3928
  app2.post("/:id/resources/reload", async (c) => {
@@ -1966,8 +3934,8 @@ function mountResourcesRoute(app2) {
1966
3934
  await reload(id);
1967
3935
  const { roots, workspaceCwd } = await rootsFor(id);
1968
3936
  return c.json(await snapshot(id, roots, workspaceCwd));
1969
- } catch (err) {
1970
- return respondError(c, err);
3937
+ } catch (err2) {
3938
+ return respondError(c, err2);
1971
3939
  }
1972
3940
  });
1973
3941
  app2.get("/:id/resources/skill", async (c) => {
@@ -1992,8 +3960,8 @@ function mountResourcesRoute(app2) {
1992
3960
  body: data.body
1993
3961
  };
1994
3962
  return c.json(body);
1995
- } catch (err) {
1996
- return respondError(c, err);
3963
+ } catch (err2) {
3964
+ return respondError(c, err2);
1997
3965
  }
1998
3966
  });
1999
3967
  app2.post("/:id/resources/skills", async (c) => {
@@ -2017,8 +3985,8 @@ function mountResourcesRoute(app2) {
2017
3985
  });
2018
3986
  await reload(id);
2019
3987
  return c.json(await snapshot(id, roots, workspaceCwd));
2020
- } catch (err) {
2021
- return respondError(c, err);
3988
+ } catch (err2) {
3989
+ return respondError(c, err2);
2022
3990
  }
2023
3991
  });
2024
3992
  app2.put("/:id/resources/skills", async (c) => {
@@ -2042,8 +4010,8 @@ function mountResourcesRoute(app2) {
2042
4010
  });
2043
4011
  await reload(id);
2044
4012
  return c.json(await snapshot(id, roots, workspaceCwd));
2045
- } catch (err) {
2046
- return respondError(c, err);
4013
+ } catch (err2) {
4014
+ return respondError(c, err2);
2047
4015
  }
2048
4016
  });
2049
4017
  app2.delete("/:id/resources/skills", async (c) => {
@@ -2058,8 +4026,8 @@ function mountResourcesRoute(app2) {
2058
4026
  await deleteSkill(filePath, roots);
2059
4027
  await reload(id);
2060
4028
  return c.json(await snapshot(id, roots, workspaceCwd));
2061
- } catch (err) {
2062
- return respondError(c, err);
4029
+ } catch (err2) {
4030
+ return respondError(c, err2);
2063
4031
  }
2064
4032
  });
2065
4033
  app2.get("/:id/resources/prompt", async (c) => {
@@ -2084,8 +4052,8 @@ function mountResourcesRoute(app2) {
2084
4052
  body: data.body
2085
4053
  };
2086
4054
  return c.json(body);
2087
- } catch (err) {
2088
- return respondError(c, err);
4055
+ } catch (err2) {
4056
+ return respondError(c, err2);
2089
4057
  }
2090
4058
  });
2091
4059
  app2.post("/:id/resources/prompts", async (c) => {
@@ -2109,8 +4077,8 @@ function mountResourcesRoute(app2) {
2109
4077
  });
2110
4078
  await reload(id);
2111
4079
  return c.json(await snapshot(id, roots, workspaceCwd));
2112
- } catch (err) {
2113
- return respondError(c, err);
4080
+ } catch (err2) {
4081
+ return respondError(c, err2);
2114
4082
  }
2115
4083
  });
2116
4084
  app2.put("/:id/resources/prompts", async (c) => {
@@ -2134,8 +4102,8 @@ function mountResourcesRoute(app2) {
2134
4102
  });
2135
4103
  await reload(id);
2136
4104
  return c.json(await snapshot(id, roots, workspaceCwd));
2137
- } catch (err) {
2138
- return respondError(c, err);
4105
+ } catch (err2) {
4106
+ return respondError(c, err2);
2139
4107
  }
2140
4108
  });
2141
4109
  app2.delete("/:id/resources/prompts", async (c) => {
@@ -2150,8 +4118,8 @@ function mountResourcesRoute(app2) {
2150
4118
  await deletePrompt(filePath, roots);
2151
4119
  await reload(id);
2152
4120
  return c.json(await snapshot(id, roots, workspaceCwd));
2153
- } catch (err) {
2154
- return respondError(c, err);
4121
+ } catch (err2) {
4122
+ return respondError(c, err2);
2155
4123
  }
2156
4124
  });
2157
4125
  app2.put("/:id/resources/builtin-extensions", async (c) => {
@@ -2175,10 +4143,11 @@ function mountResourcesRoute(app2) {
2175
4143
  }
2176
4144
  await setBuiltinEnabled(body.id, body.enabled);
2177
4145
  await runtime.session.reload();
4146
+ reapplyToolPrefs(id, runtime.session);
2178
4147
  const { roots, workspaceCwd } = await rootsFor(id);
2179
4148
  return c.json(await snapshot(id, roots, workspaceCwd));
2180
- } catch (err) {
2181
- return respondError(c, err);
4149
+ } catch (err2) {
4150
+ return respondError(c, err2);
2182
4151
  }
2183
4152
  });
2184
4153
  }
@@ -2186,14 +4155,182 @@ function isScope(value) {
2186
4155
  return value === "user" || value === "project";
2187
4156
  }
2188
4157
 
4158
+ // src/api/tree.ts
4159
+ import { Hono } from "hono";
4160
+ var treeRoute = new Hono();
4161
+ treeRoute.get("/", async (c) => {
4162
+ const id = c.req.param("id") ?? "";
4163
+ const existed = await getWorkspace(id);
4164
+ if (!existed) return c.json({ ok: false, error: "not found" }, 404);
4165
+ try {
4166
+ const runtime = await workspaceManager.getOrCreate(id);
4167
+ const sm = runtime.session.sessionManager;
4168
+ const tree = sm.getTree();
4169
+ const leafId = sm.getLeafId();
4170
+ const activeIds = /* @__PURE__ */ new Set();
4171
+ if (leafId) {
4172
+ const branch = sm.getBranch(leafId);
4173
+ for (const entry of branch) {
4174
+ activeIds.add(entry.id);
4175
+ }
4176
+ }
4177
+ const nodes = [];
4178
+ flattenTree(tree, leafId, activeIds, nodes, 0);
4179
+ const body = { nodes, leafId };
4180
+ return c.json(body);
4181
+ } catch (err2) {
4182
+ const message = err2 instanceof Error ? err2.message : String(err2);
4183
+ console.error(`[api] tree for ${id} failed:`, err2);
4184
+ return c.json({ ok: false, error: message }, 500);
4185
+ }
4186
+ });
4187
+ function flattenTree(nodes, leafId, activeIds, out, depth) {
4188
+ const sorted = [...nodes].sort(
4189
+ (a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
4190
+ );
4191
+ for (const node of sorted) {
4192
+ const entry = node.entry;
4193
+ const id = entry.id;
4194
+ out.push({
4195
+ id,
4196
+ parentId: entry.parentId,
4197
+ depth,
4198
+ entryType: mapEntryType(entry.type),
4199
+ messageRole: entry.type === "message" ? mapMessageRole(entry["message"]) : void 0,
4200
+ timestamp: entry.timestamp,
4201
+ preview: extractPreview(entry),
4202
+ active: activeIds.has(id),
4203
+ isLeaf: id === leafId,
4204
+ childCount: node.children.length,
4205
+ label: node.label
4206
+ });
4207
+ if (node.children.length > 0) {
4208
+ flattenTree(node.children, leafId, activeIds, out, depth + 1);
4209
+ }
4210
+ }
4211
+ }
4212
+ function mapEntryType(type) {
4213
+ switch (type) {
4214
+ case "message":
4215
+ return "message";
4216
+ case "compaction":
4217
+ return "compaction";
4218
+ case "branch_summary":
4219
+ return "branch_summary";
4220
+ case "model_change":
4221
+ return "model_change";
4222
+ case "thinking_level_change":
4223
+ return "thinking_level_change";
4224
+ case "custom":
4225
+ return "custom";
4226
+ case "custom_message":
4227
+ return "custom_message";
4228
+ case "label":
4229
+ return "label";
4230
+ case "session_info":
4231
+ return "session_info";
4232
+ default:
4233
+ console.warn(`[tree] unknown entry type "${type}" \u2014 mapping to "custom". pi SDK may have added a type tree.ts doesn't handle yet.`);
4234
+ return "custom";
4235
+ }
4236
+ }
4237
+ function mapMessageRole(msg) {
4238
+ if (!msg || typeof msg !== "object") return void 0;
4239
+ const role = msg.role;
4240
+ switch (role) {
4241
+ case "user":
4242
+ return "user";
4243
+ case "assistant":
4244
+ return "assistant";
4245
+ case "toolResult":
4246
+ return "toolResult";
4247
+ case "bashExecution":
4248
+ return "bashExecution";
4249
+ case "custom":
4250
+ return "custom";
4251
+ case "branchSummary":
4252
+ return "branchSummary";
4253
+ case "compactionSummary":
4254
+ return "compactionSummary";
4255
+ default:
4256
+ return void 0;
4257
+ }
4258
+ }
4259
+ var PREVIEW_MAX = 120;
4260
+ function extractPreview(entry) {
4261
+ switch (entry.type) {
4262
+ case "message":
4263
+ return extractMessagePreview(entry["message"]);
4264
+ case "compaction":
4265
+ return truncate2(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
4266
+ case "branch_summary":
4267
+ return truncate2(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
4268
+ case "model_change":
4269
+ return `${entry["provider"] ?? ""}/${entry["modelId"] ?? ""}`;
4270
+ case "thinking_level_change":
4271
+ return `Thinking: ${entry["thinkingLevel"] ?? ""}`;
4272
+ case "session_info":
4273
+ return entry["name"] ? `Name: ${entry["name"]}` : "Session info";
4274
+ case "custom_message":
4275
+ return truncate2(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
4276
+ case "custom":
4277
+ return `Custom: ${entry["customType"] ?? ""}`;
4278
+ case "label":
4279
+ return "Label";
4280
+ default:
4281
+ return "";
4282
+ }
4283
+ }
4284
+ function extractMessagePreview(msg) {
4285
+ if (!msg || typeof msg !== "object") return "";
4286
+ const m = msg;
4287
+ if (m.role === "bashExecution") {
4288
+ return truncate2(`$ ${m.command ?? ""}`, PREVIEW_MAX);
4289
+ }
4290
+ return truncate2(extractContentText2(m.content), PREVIEW_MAX);
4291
+ }
4292
+ function extractContentText2(content) {
4293
+ if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
4294
+ if (!Array.isArray(content)) return "";
4295
+ const parts = [];
4296
+ for (const block of content) {
4297
+ if (!block || typeof block !== "object") continue;
4298
+ const b = block;
4299
+ if (b.type === "text" && typeof b.text === "string") {
4300
+ parts.push(b.text);
4301
+ } else if (b.type === "thinking" && typeof b.thinking === "string") {
4302
+ parts.push(b.thinking);
4303
+ }
4304
+ }
4305
+ return parts.join(" ").replace(/\s+/g, " ").trim();
4306
+ }
4307
+ function truncate2(text, max) {
4308
+ if (text.length <= max) return text;
4309
+ return text.slice(0, max) + "\u2026";
4310
+ }
4311
+
2189
4312
  // src/api/workspaces.ts
2190
- var workspacesRoute = new Hono();
4313
+ var workspacesRoute = new Hono2();
2191
4314
  workspacesRoute.get("/", async (c) => {
2192
4315
  const raw = await listWorkspaces();
2193
4316
  const workspaces = await Promise.all(raw.map(enrichWorkspace));
2194
4317
  const body = { workspaces };
2195
4318
  return c.json(body);
2196
4319
  });
4320
+ workspacesRoute.put("/reorder", async (c) => {
4321
+ const body = await c.req.json();
4322
+ if (!body?.ids || !Array.isArray(body.ids) || body.ids.length === 0 || !body.ids.every((id) => typeof id === "string")) {
4323
+ return c.json(
4324
+ { ok: false, error: "ids must be a non-empty array of strings" },
4325
+ 400
4326
+ );
4327
+ }
4328
+ await reorderWorkspaces(body.ids);
4329
+ const raw = await listWorkspaces();
4330
+ const workspaces = await Promise.all(raw.map(enrichWorkspace));
4331
+ const resBody = { workspaces };
4332
+ return c.json(resBody);
4333
+ });
2197
4334
  workspacesRoute.get("/:id/sessions", async (c) => {
2198
4335
  const id = c.req.param("id");
2199
4336
  const existed = await getWorkspace(id);
@@ -2202,9 +4339,9 @@ workspacesRoute.get("/:id/sessions", async (c) => {
2202
4339
  const sessions = await workspaceManager.listSessions(id);
2203
4340
  const body = { sessions };
2204
4341
  return c.json(body);
2205
- } catch (err) {
2206
- const message = err instanceof Error ? err.message : String(err);
2207
- console.error(`[api] list sessions for ${id} failed:`, err);
4342
+ } catch (err2) {
4343
+ const message = err2 instanceof Error ? err2.message : String(err2);
4344
+ console.error(`[api] list sessions for ${id} failed:`, err2);
2208
4345
  return c.json({ ok: false, error: message }, 500);
2209
4346
  }
2210
4347
  });
@@ -2220,15 +4357,15 @@ workspacesRoute.delete("/:id/sessions", async (c) => {
2220
4357
  await workspaceManager.deleteSession(id, sessionPath);
2221
4358
  const body = { ok: true };
2222
4359
  return c.json(body);
2223
- } catch (err) {
2224
- if (err instanceof HttpError) {
4360
+ } catch (err2) {
4361
+ if (err2 instanceof HttpError) {
2225
4362
  return c.json(
2226
- { ok: false, error: err.message },
2227
- err.status
4363
+ { ok: false, error: err2.message },
4364
+ err2.status
2228
4365
  );
2229
4366
  }
2230
- const message = err instanceof Error ? err.message : String(err);
2231
- console.error(`[api] delete session for ${id} failed:`, err);
4367
+ const message = err2 instanceof Error ? err2.message : String(err2);
4368
+ console.error(`[api] delete session for ${id} failed:`, err2);
2232
4369
  return c.json({ ok: false, error: message }, 500);
2233
4370
  }
2234
4371
  });
@@ -2245,9 +4382,31 @@ workspacesRoute.get("/:id/fork-points", async (c) => {
2245
4382
  }));
2246
4383
  const body = { points };
2247
4384
  return c.json(body);
2248
- } catch (err) {
2249
- const message = err instanceof Error ? err.message : String(err);
2250
- console.error(`[api] fork-points for ${id} failed:`, err);
4385
+ } catch (err2) {
4386
+ const message = err2 instanceof Error ? err2.message : String(err2);
4387
+ console.error(`[api] fork-points for ${id} failed:`, err2);
4388
+ return c.json({ ok: false, error: message }, 500);
4389
+ }
4390
+ });
4391
+ workspacesRoute.get("/:id/export", async (c) => {
4392
+ const id = c.req.param("id");
4393
+ const existed = await getWorkspace(id);
4394
+ if (!existed) return c.json({ ok: false, error: "not found" }, 404);
4395
+ try {
4396
+ const sessionPath = c.req.query("sessionPath");
4397
+ if (sessionPath) {
4398
+ const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
4399
+ if (err2) return c.json({ ok: false, error: err2 }, 404);
4400
+ }
4401
+ const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
4402
+ const outputPath = await runtime.session.exportToHtml();
4403
+ const html = await readFile7(outputPath, "utf-8");
4404
+ const filename = basename2(outputPath);
4405
+ const body = { html, filename };
4406
+ return c.json(body);
4407
+ } catch (err2) {
4408
+ const message = err2 instanceof Error ? err2.message : String(err2);
4409
+ console.error(`[api] export for ${id} failed:`, err2);
2251
4410
  return c.json({ ok: false, error: message }, 500);
2252
4411
  }
2253
4412
  });
@@ -2260,9 +4419,9 @@ workspacesRoute.get("/:id/history", async (c) => {
2260
4419
  const sessionPath = c.req.query("sessionPath");
2261
4420
  const body = workspaceManager.getSessionHistory(id, sessionPath);
2262
4421
  return c.json(body);
2263
- } catch (err) {
2264
- const message = err instanceof Error ? err.message : String(err);
2265
- console.error(`[api] history for ${id} failed:`, err);
4422
+ } catch (err2) {
4423
+ const message = err2 instanceof Error ? err2.message : String(err2);
4424
+ console.error(`[api] history for ${id} failed:`, err2);
2266
4425
  return c.json({ ok: false, error: message }, 500);
2267
4426
  }
2268
4427
  });
@@ -2274,7 +4433,7 @@ workspacesRoute.post("/", async (c) => {
2274
4433
  if (!isAbsolute3(body.path)) {
2275
4434
  return c.json({ ok: false, error: "path must be absolute" }, 400);
2276
4435
  }
2277
- const resolved = resolve3(body.path);
4436
+ const resolved = resolve5(body.path);
2278
4437
  try {
2279
4438
  const st = await stat2(resolved);
2280
4439
  if (!st.isDirectory()) {
@@ -2300,35 +4459,47 @@ workspacesRoute.delete("/:id", async (c) => {
2300
4459
  const body = { ok: true };
2301
4460
  return c.json(body);
2302
4461
  });
4462
+ workspacesRoute.patch("/:id", async (c) => {
4463
+ const id = c.req.param("id");
4464
+ const body = await c.req.json();
4465
+ if (typeof body?.trustProjectAgents !== "boolean") {
4466
+ return c.json({ ok: false, error: "trustProjectAgents must be a boolean" }, 400);
4467
+ }
4468
+ const updated = await setWorkspaceTrustProjectAgents(id, body.trustProjectAgents);
4469
+ if (!updated) return c.json({ ok: false, error: "not found" }, 404);
4470
+ const res = { workspace: await enrichWorkspace(updated) };
4471
+ return c.json(res);
4472
+ });
2303
4473
  mountConfigRoutes(workspacesRoute);
2304
4474
  mountResourcesRoute(workspacesRoute);
2305
4475
  mountFilesRoute(workspacesRoute);
4476
+ workspacesRoute.route("/:id/tree", treeRoute);
2306
4477
 
2307
4478
  // src/api/fs.ts
2308
- import { readdir as readdir3 } from "fs/promises";
2309
- import { homedir as homedir2 } from "os";
2310
- import { dirname as dirname4, isAbsolute as isAbsolute4, join as join7, resolve as resolve4 } from "path";
2311
- import { Hono as Hono2 } from "hono";
2312
- var fsRoute = new Hono2();
4479
+ import { readdir as readdir4 } from "fs/promises";
4480
+ import { homedir as homedir3 } from "os";
4481
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "path";
4482
+ import { Hono as Hono3 } from "hono";
4483
+ var fsRoute = new Hono3();
2313
4484
  fsRoute.get("/browse", async (c) => {
2314
4485
  const rawPath = c.req.query("path");
2315
4486
  const showHidden = c.req.query("showHidden") === "1";
2316
- const target = rawPath && isAbsolute4(rawPath) ? resolve4(rawPath) : homedir2();
4487
+ const target = rawPath && isAbsolute4(rawPath) ? resolve6(rawPath) : homedir3();
2317
4488
  let dirents;
2318
4489
  try {
2319
- dirents = await readdir3(target, { withFileTypes: true });
2320
- } catch (err) {
2321
- const code = err.code;
4490
+ dirents = await readdir4(target, { withFileTypes: true });
4491
+ } catch (err2) {
4492
+ const code = err2.code;
2322
4493
  const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
2323
4494
  return c.json({ ok: false, error: msg, path: target }, 400);
2324
4495
  }
2325
4496
  const entries = dirents.filter((d) => d.isDirectory()).filter((d) => showHidden || !d.name.startsWith(".")).map((d) => ({
2326
4497
  name: d.name,
2327
- path: join7(target, d.name),
4498
+ path: join14(target, d.name),
2328
4499
  type: "dir"
2329
4500
  })).sort((a, b) => a.name.localeCompare(b.name));
2330
4501
  const parent = (() => {
2331
- const p = dirname4(target);
4502
+ const p = dirname5(target);
2332
4503
  return p === target ? null : p;
2333
4504
  })();
2334
4505
  const body = { path: target, parent, entries };
@@ -2336,13 +4507,49 @@ fsRoute.get("/browse", async (c) => {
2336
4507
  });
2337
4508
 
2338
4509
  // src/api/model-configs.ts
2339
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
2340
- import { dirname as dirname5, join as join8 } from "path";
2341
- import { Hono as Hono3 } from "hono";
4510
+ import { readFile as readFile8 } from "fs/promises";
4511
+ import { join as join15 } from "path";
4512
+ import { Hono as Hono4 } from "hono";
2342
4513
  import {
2343
- getAgentDir as getAgentDir3
4514
+ getAgentDir as getAgentDir4
2344
4515
  } from "@earendil-works/pi-coding-agent";
2345
- var modelConfigsRoute = new Hono3();
4516
+
4517
+ // src/api/model-config-keys.ts
4518
+ var MASKED_KEY_RE = /^….{1,8}$/;
4519
+ function maskApiKey(key) {
4520
+ return `\u2026${key.slice(-4)}`;
4521
+ }
4522
+ function isPreservedApiKey(key) {
4523
+ return key === "" || MASKED_KEY_RE.test(key);
4524
+ }
4525
+ function maskConfigForResponse(config2) {
4526
+ const providers = {};
4527
+ for (const [name, provider] of Object.entries(config2.providers)) {
4528
+ providers[name] = {
4529
+ ...provider,
4530
+ apiKey: provider.apiKey ? maskApiKey(provider.apiKey) : ""
4531
+ };
4532
+ }
4533
+ return { providers };
4534
+ }
4535
+ function preserveApiKeys(incoming, existing) {
4536
+ const providers = { ...incoming.providers };
4537
+ for (const [name, provider] of Object.entries(providers)) {
4538
+ if (!isPreservedApiKey(provider.apiKey)) continue;
4539
+ const prev = existing.providers[name]?.apiKey;
4540
+ if (prev) {
4541
+ providers[name] = { ...provider, apiKey: prev };
4542
+ }
4543
+ }
4544
+ return { providers };
4545
+ }
4546
+ function resolveUpsertApiKey(incomingKey, existingKey) {
4547
+ if (!isPreservedApiKey(incomingKey)) return incomingKey;
4548
+ return existingKey || void 0;
4549
+ }
4550
+
4551
+ // src/api/model-configs.ts
4552
+ var modelConfigsRoute = new Hono4();
2346
4553
  var writeLock = Promise.resolve();
2347
4554
  function withWriteLock(fn) {
2348
4555
  const next = writeLock.then(fn, fn);
@@ -2352,28 +4559,26 @@ function withWriteLock(fn) {
2352
4559
  return next;
2353
4560
  }
2354
4561
  function modelsPath() {
2355
- return join8(getAgentDir3(), "models.json");
4562
+ return join15(getAgentDir4(), "models.json");
2356
4563
  }
2357
4564
  async function readModelsJson() {
2358
4565
  try {
2359
- const raw = await readFile4(modelsPath(), "utf-8");
4566
+ const raw = await readFile8(modelsPath(), "utf-8");
2360
4567
  return JSON.parse(raw);
2361
- } catch (err) {
2362
- if (err?.code === "ENOENT") {
4568
+ } catch (err2) {
4569
+ if (err2?.code === "ENOENT") {
2363
4570
  return { providers: {} };
2364
4571
  }
2365
- throw err;
4572
+ throw err2;
2366
4573
  }
2367
4574
  }
2368
4575
  async function writeModelsJson(config2) {
2369
- const p = modelsPath();
2370
- await mkdir4(dirname5(p), { recursive: true });
2371
- await writeFile4(p, JSON.stringify(config2, null, 2), "utf-8");
4576
+ await writeJsonAtomic(modelsPath(), config2, { mode: 384 });
2372
4577
  }
2373
4578
  var ValidationError = class extends Error {
2374
- constructor(message, status) {
4579
+ constructor(message, status2) {
2375
4580
  super(message);
2376
- this.status = status;
4581
+ this.status = status2;
2377
4582
  }
2378
4583
  status;
2379
4584
  };
@@ -2391,10 +4596,10 @@ function refreshRegistry(workspaceId) {
2391
4596
  modelConfigsRoute.get("/", async (c) => {
2392
4597
  try {
2393
4598
  const config2 = await readModelsJson();
2394
- const body = { config: config2 };
4599
+ const body = { config: maskConfigForResponse(config2) };
2395
4600
  return c.json(body);
2396
- } catch (err) {
2397
- const message = err instanceof Error ? err.message : String(err);
4601
+ } catch (err2) {
4602
+ const message = err2 instanceof Error ? err2.message : String(err2);
2398
4603
  return c.json({ ok: false, error: message }, 500);
2399
4604
  }
2400
4605
  });
@@ -2404,15 +4609,18 @@ modelConfigsRoute.put("/", async (c) => {
2404
4609
  return c.json({ ok: false, error: "config.providers is required" }, 400);
2405
4610
  }
2406
4611
  try {
2407
- await withWriteLock(async () => {
2408
- await writeModelsJson(body.config);
4612
+ const config2 = await withWriteLock(async () => {
4613
+ const existing = await readModelsJson();
4614
+ const merged = preserveApiKeys(body.config, existing);
4615
+ await writeModelsJson(merged);
4616
+ return merged;
2409
4617
  });
2410
4618
  const workspaceId = c.req.query("workspaceId");
2411
4619
  refreshRegistry(workspaceId ?? void 0);
2412
- const resp = { config: body.config };
4620
+ const resp = { config: maskConfigForResponse(config2) };
2413
4621
  return c.json(resp);
2414
- } catch (err) {
2415
- const message = err instanceof Error ? err.message : String(err);
4622
+ } catch (err2) {
4623
+ const message = err2 instanceof Error ? err2.message : String(err2);
2416
4624
  return c.json({ ok: false, error: message }, 500);
2417
4625
  }
2418
4626
  });
@@ -2421,8 +4629,8 @@ modelConfigsRoute.post("/providers", async (c) => {
2421
4629
  if (!body?.name || !body?.provider) {
2422
4630
  return c.json({ ok: false, error: "name and provider are required" }, 400);
2423
4631
  }
2424
- if (!body.provider.baseUrl || !body.provider.api || !body.provider.apiKey) {
2425
- return c.json({ ok: false, error: "provider must have baseUrl, api, and apiKey" }, 400);
4632
+ if (!body.provider.baseUrl || !body.provider.api) {
4633
+ return c.json({ ok: false, error: "provider must have baseUrl and api" }, 400);
2426
4634
  }
2427
4635
  if (!Array.isArray(body.provider.models)) {
2428
4636
  return c.json({ ok: false, error: "provider.models must be an array" }, 400);
@@ -2430,16 +4638,23 @@ modelConfigsRoute.post("/providers", async (c) => {
2430
4638
  try {
2431
4639
  const config2 = await withWriteLock(async () => {
2432
4640
  const cfg = await readModelsJson();
2433
- cfg.providers[body.name] = body.provider;
4641
+ const apiKey2 = resolveUpsertApiKey(body.provider.apiKey, cfg.providers[body.name]?.apiKey);
4642
+ if (!apiKey2) {
4643
+ throw new ValidationError("apiKey is required for a new provider", 400);
4644
+ }
4645
+ cfg.providers[body.name] = { ...body.provider, apiKey: apiKey2 };
2434
4646
  await writeModelsJson(cfg);
2435
4647
  return cfg;
2436
4648
  });
2437
4649
  const workspaceId = c.req.query("workspaceId");
2438
4650
  refreshRegistry(workspaceId ?? void 0);
2439
- const resp = { config: config2 };
4651
+ const resp = { config: maskConfigForResponse(config2) };
2440
4652
  return c.json(resp);
2441
- } catch (err) {
2442
- const message = err instanceof Error ? err.message : String(err);
4653
+ } catch (err2) {
4654
+ if (err2 instanceof ValidationError) {
4655
+ return c.json({ ok: false, error: err2.message }, err2.status);
4656
+ }
4657
+ const message = err2 instanceof Error ? err2.message : String(err2);
2443
4658
  return c.json({ ok: false, error: message }, 500);
2444
4659
  }
2445
4660
  });
@@ -2460,13 +4675,13 @@ modelConfigsRoute.delete("/providers", async (c) => {
2460
4675
  });
2461
4676
  const workspaceId = c.req.query("workspaceId");
2462
4677
  refreshRegistry(workspaceId ?? void 0);
2463
- const resp = { config: config2 };
4678
+ const resp = { config: maskConfigForResponse(config2) };
2464
4679
  return c.json(resp);
2465
- } catch (err) {
2466
- if (err instanceof ValidationError) {
2467
- return c.json({ ok: false, error: err.message }, err.status);
4680
+ } catch (err2) {
4681
+ if (err2 instanceof ValidationError) {
4682
+ return c.json({ ok: false, error: err2.message }, err2.status);
2468
4683
  }
2469
- const message = err instanceof Error ? err.message : String(err);
4684
+ const message = err2 instanceof Error ? err2.message : String(err2);
2470
4685
  return c.json({ ok: false, error: message }, 500);
2471
4686
  }
2472
4687
  });
@@ -2492,13 +4707,13 @@ modelConfigsRoute.post("/providers/:provider/models", async (c) => {
2492
4707
  });
2493
4708
  const workspaceId = c.req.query("workspaceId");
2494
4709
  refreshRegistry(workspaceId ?? void 0);
2495
- const resp = { config: config2 };
4710
+ const resp = { config: maskConfigForResponse(config2) };
2496
4711
  return c.json(resp);
2497
- } catch (err) {
2498
- if (err instanceof ValidationError) {
2499
- return c.json({ ok: false, error: err.message }, err.status);
4712
+ } catch (err2) {
4713
+ if (err2 instanceof ValidationError) {
4714
+ return c.json({ ok: false, error: err2.message }, err2.status);
2500
4715
  }
2501
- const message = err instanceof Error ? err.message : String(err);
4716
+ const message = err2 instanceof Error ? err2.message : String(err2);
2502
4717
  return c.json({ ok: false, error: message }, 500);
2503
4718
  }
2504
4719
  });
@@ -2528,13 +4743,13 @@ modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
2528
4743
  });
2529
4744
  const workspaceId = c.req.query("workspaceId");
2530
4745
  refreshRegistry(workspaceId ?? void 0);
2531
- const resp = { config: config2 };
4746
+ const resp = { config: maskConfigForResponse(config2) };
2532
4747
  return c.json(resp);
2533
- } catch (err) {
2534
- if (err instanceof ValidationError) {
2535
- return c.json({ ok: false, error: err.message }, err.status);
4748
+ } catch (err2) {
4749
+ if (err2 instanceof ValidationError) {
4750
+ return c.json({ ok: false, error: err2.message }, err2.status);
2536
4751
  }
2537
- const message = err instanceof Error ? err.message : String(err);
4752
+ const message = err2 instanceof Error ? err2.message : String(err2);
2538
4753
  return c.json({ ok: false, error: message }, 500);
2539
4754
  }
2540
4755
  });
@@ -2557,19 +4772,83 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
2557
4772
  });
2558
4773
  const workspaceId = c.req.query("workspaceId");
2559
4774
  refreshRegistry(workspaceId ?? void 0);
2560
- const resp = { config: config2 };
4775
+ const resp = { config: maskConfigForResponse(config2) };
2561
4776
  return c.json(resp);
2562
- } catch (err) {
2563
- if (err instanceof ValidationError) {
2564
- return c.json({ ok: false, error: err.message }, err.status);
4777
+ } catch (err2) {
4778
+ if (err2 instanceof ValidationError) {
4779
+ return c.json({ ok: false, error: err2.message }, err2.status);
2565
4780
  }
2566
- const message = err instanceof Error ? err.message : String(err);
4781
+ const message = err2 instanceof Error ? err2.message : String(err2);
4782
+ return c.json({ ok: false, error: message }, 500);
4783
+ }
4784
+ });
4785
+
4786
+ // src/api/web-search.ts
4787
+ import { Hono as Hono5 } from "hono";
4788
+ var webSearchRoute = new Hono5();
4789
+ function status() {
4790
+ return getKeyStatus();
4791
+ }
4792
+ webSearchRoute.get("/", (c) => c.json(status()));
4793
+ webSearchRoute.put("/", async (c) => {
4794
+ const body = await c.req.json().catch(() => null);
4795
+ if (!body || typeof body.apiKey !== "string") {
4796
+ return c.json({ ok: false, error: "apiKey (string) is required" }, 400);
4797
+ }
4798
+ try {
4799
+ await setTavilyApiKey(body.apiKey);
4800
+ return c.json(status());
4801
+ } catch (err2) {
4802
+ const message = err2 instanceof Error ? err2.message : String(err2);
4803
+ return c.json({ ok: false, error: message }, 500);
4804
+ }
4805
+ });
4806
+ webSearchRoute.delete("/", async (c) => {
4807
+ try {
4808
+ await clearTavilyApiKey();
4809
+ return c.json(status());
4810
+ } catch (err2) {
4811
+ const message = err2 instanceof Error ? err2.message : String(err2);
2567
4812
  return c.json({ ok: false, error: message }, 500);
2568
4813
  }
2569
4814
  });
2570
4815
 
2571
4816
  // src/ws/hub.ts
2572
4817
  import { WebSocketServer } from "ws";
4818
+
4819
+ // src/security.ts
4820
+ var LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost"];
4821
+ function buildAllowedHosts() {
4822
+ const ports = /* @__PURE__ */ new Set([config.port, 5173]);
4823
+ const hosts = /* @__PURE__ */ new Set();
4824
+ for (const name of LOOPBACK_HOSTNAMES) {
4825
+ for (const port of ports) {
4826
+ hosts.add(`${name}:${port}`);
4827
+ }
4828
+ }
4829
+ return hosts;
4830
+ }
4831
+ function buildAllowedWsOrigins() {
4832
+ const origins = /* @__PURE__ */ new Set([config.corsOrigin]);
4833
+ for (const name of LOOPBACK_HOSTNAMES) {
4834
+ origins.add(`http://${name}:${config.port}`);
4835
+ origins.add(`http://${name}:5173`);
4836
+ }
4837
+ return origins;
4838
+ }
4839
+ var allowedHosts = buildAllowedHosts();
4840
+ var allowedWsOrigins = buildAllowedWsOrigins();
4841
+ function isAllowedHost(host) {
4842
+ if (!host) return false;
4843
+ return allowedHosts.has(host.toLowerCase());
4844
+ }
4845
+ function isAllowedWsOrigin(origin) {
4846
+ if (!origin) return false;
4847
+ return allowedWsOrigins.has(origin);
4848
+ }
4849
+
4850
+ // src/ws/hub.ts
4851
+ var BACKGROUND_CAP = 4;
2573
4852
  var replacementLocks = /* @__PURE__ */ new Map();
2574
4853
  function withReplacementLock(workspaceId, fn) {
2575
4854
  const prev = replacementLocks.get(workspaceId) ?? Promise.resolve();
@@ -2584,23 +4863,40 @@ function withReplacementLock(workspaceId, fn) {
2584
4863
  return next;
2585
4864
  }
2586
4865
  function attachWsHub(httpServer) {
2587
- const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
2588
- wss.on("connection", (ws) => {
2589
- const state = {};
2590
- ws.on("message", async (raw) => {
2591
- let msg;
2592
- try {
2593
- msg = JSON.parse(raw.toString());
2594
- } catch {
2595
- send(ws, { type: "error", message: "invalid JSON" });
4866
+ const wss = new WebSocketServer({
4867
+ server: httpServer,
4868
+ path: "/ws",
4869
+ verifyClient: (info, cb) => {
4870
+ const host = info.req.headers.host;
4871
+ const origin = info.origin;
4872
+ if (!isAllowedHost(host) || !isAllowedWsOrigin(origin)) {
4873
+ cb(false, 403, "Forbidden");
2596
4874
  return;
2597
4875
  }
2598
- try {
2599
- await handle(ws, state, msg);
2600
- } catch (err) {
2601
- const message = err instanceof Error ? err.message : String(err);
2602
- send(ws, { type: "error", message, command: msg.type });
2603
- }
4876
+ cb(true);
4877
+ }
4878
+ });
4879
+ wss.on("connection", (ws) => {
4880
+ const state = { background: /* @__PURE__ */ new Map() };
4881
+ let inbound = Promise.resolve();
4882
+ ws.on("message", (raw) => {
4883
+ inbound = inbound.then(async () => {
4884
+ let msg;
4885
+ try {
4886
+ msg = JSON.parse(raw.toString());
4887
+ } catch {
4888
+ send(ws, { type: "error", message: "invalid JSON" });
4889
+ return;
4890
+ }
4891
+ try {
4892
+ await handle(ws, state, msg);
4893
+ } catch (err2) {
4894
+ const message = err2 instanceof Error ? err2.message : String(err2);
4895
+ send(ws, { type: "error", message, command: msg.type });
4896
+ }
4897
+ }).catch((err2) => {
4898
+ console.error("[ws] inbound chain error:", err2);
4899
+ });
2604
4900
  });
2605
4901
  ws.on("close", () => {
2606
4902
  detach(state, ws);
@@ -2611,124 +4907,162 @@ function attachWsHub(httpServer) {
2611
4907
  async function handle(ws, state, msg) {
2612
4908
  switch (msg.type) {
2613
4909
  case "subscribe": {
2614
- const hadCurrentSubscription = state.workspaceId === msg.workspaceId && !!state.unsubscribeSession;
2615
- ensureRebindListener(ws, state, msg.workspaceId);
2616
- await workspaceManager.getOrCreate(msg.workspaceId);
2617
- let switched = false;
2618
- let switchError;
2619
- if (msg.sessionPath) {
2620
- await withReplacementLock(msg.workspaceId, async () => {
2621
- try {
2622
- switched = await workspaceManager.switchSession(msg.workspaceId, msg.sessionPath);
2623
- } catch (err) {
2624
- switchError = err instanceof Error ? err.message : String(err);
2625
- }
2626
- });
2627
- }
2628
- if (!switched && !hadCurrentSubscription) {
2629
- bindCurrentSession(ws, state, msg.workspaceId);
2630
- }
2631
- if (switchError) {
2632
- send(ws, { type: "error", message: switchError, command: "subscribe" });
4910
+ let runtime;
4911
+ try {
4912
+ runtime = await workspaceManager.getOrCreate(msg.workspaceId, msg.sessionPath);
4913
+ } catch (err2) {
4914
+ const message = err2 instanceof Error ? err2.message : String(err2);
4915
+ send(ws, { type: "error", message, command: "subscribe" });
4916
+ send(ws, { type: "ack", command: "subscribe" });
4917
+ return;
2633
4918
  }
4919
+ promoteToPrimary(ws, state, msg.workspaceId, runtime);
2634
4920
  send(ws, { type: "ack", command: "subscribe" });
2635
4921
  return;
2636
4922
  }
4923
+ case "unsubscribe": {
4924
+ for (const [key, bg] of [...state.background]) {
4925
+ if (bg.workspaceId === msg.workspaceId) teardownBackground(state, key, ws);
4926
+ }
4927
+ return;
4928
+ }
2637
4929
  case "prompt": {
2638
- const wsId = state.workspaceId;
2639
- if (!wsId) {
4930
+ const primary = state.primary;
4931
+ if (!primary) {
2640
4932
  send(ws, { type: "error", message: "not subscribed", command: "prompt" });
2641
4933
  return;
2642
4934
  }
2643
- if (replacementLocks.has(wsId)) {
4935
+ if (replacementLocks.has(primary.workspaceId)) {
2644
4936
  send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
2645
4937
  return;
2646
4938
  }
2647
- const runtime = workspaceManager.get(wsId);
2648
- if (!runtime) {
2649
- send(ws, { type: "error", message: "runtime gone", command: "prompt" });
2650
- return;
2651
- }
2652
- void runtime.session.prompt(msg.message, {
2653
- streamingBehavior: msg.streamingBehavior
2654
- }).catch((err) => {
2655
- const message = err instanceof Error ? err.message : String(err);
4939
+ void primary.runtime.session.prompt(msg.message, { streamingBehavior: msg.streamingBehavior }).catch((err2) => {
4940
+ const message = err2 instanceof Error ? err2.message : String(err2);
2656
4941
  send(ws, { type: "error", message, command: "prompt" });
2657
4942
  });
2658
4943
  return;
2659
4944
  }
2660
4945
  case "abort": {
2661
- const wsId = state.workspaceId;
2662
- if (!wsId) {
4946
+ const primary = state.primary;
4947
+ if (!primary) {
2663
4948
  send(ws, { type: "error", message: "not subscribed", command: "abort" });
2664
4949
  return;
2665
4950
  }
2666
- if (replacementLocks.has(wsId)) {
2667
- send(ws, { type: "error", message: "session switching in progress", command: "abort" });
2668
- return;
2669
- }
2670
- const runtime = workspaceManager.get(wsId);
2671
- if (!runtime) return;
2672
- await runtime.session.abort();
4951
+ await primary.runtime.session.abort();
2673
4952
  return;
2674
4953
  }
2675
4954
  case "new_session": {
2676
- const wsId = state.workspaceId;
2677
- if (!wsId) {
2678
- send(ws, { type: "error", message: "not subscribed", command: "new_session" });
2679
- return;
2680
- }
2681
- await withReplacementLock(msg.workspaceId, async () => {
2682
- const runtime = workspaceManager.get(wsId);
2683
- if (!runtime) {
2684
- send(ws, { type: "error", message: "runtime gone", command: "new_session" });
2685
- return;
2686
- }
2687
- if (runtime.session.isStreaming) {
2688
- send(ws, { type: "error", message: "cannot create session while streaming", command: "new_session" });
4955
+ const workspaceId = msg.workspaceId;
4956
+ await withReplacementLock(workspaceId, async () => {
4957
+ let runtime;
4958
+ try {
4959
+ runtime = await workspaceManager.createSession(workspaceId);
4960
+ } catch (err2) {
4961
+ const message = err2 instanceof Error ? err2.message : String(err2);
4962
+ send(ws, { type: "error", message, command: "new_session" });
2689
4963
  return;
2690
4964
  }
2691
- const result = await runtime.newSession();
2692
- if (result.cancelled) {
2693
- send(ws, { type: "error", message: "new session cancelled", command: "new_session" });
2694
- }
4965
+ promoteToPrimary(ws, state, workspaceId, runtime);
2695
4966
  });
2696
4967
  return;
2697
4968
  }
2698
4969
  case "fork": {
2699
- const wsId = state.workspaceId;
2700
- if (!wsId) {
4970
+ const primary = state.primary;
4971
+ if (!primary) {
2701
4972
  send(ws, { type: "error", message: "not subscribed", command: "fork" });
2702
4973
  return;
2703
4974
  }
2704
- await withReplacementLock(msg.workspaceId, async () => {
2705
- const runtime = workspaceManager.get(wsId);
2706
- if (!runtime) {
2707
- send(ws, { type: "error", message: "runtime gone", command: "fork" });
4975
+ const workspaceId = primary.workspaceId;
4976
+ await withReplacementLock(workspaceId, async () => {
4977
+ const source = state.primary;
4978
+ if (!source) {
4979
+ send(ws, { type: "error", message: "not subscribed", command: "fork" });
2708
4980
  return;
2709
4981
  }
2710
- if (runtime.session.isStreaming) {
4982
+ if (!source.sessionPath) {
4983
+ send(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
4984
+ return;
4985
+ }
4986
+ if (source.runtime.session.isStreaming) {
2711
4987
  send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
2712
4988
  return;
2713
4989
  }
2714
- const result = await runtime.fork(msg.entryId);
2715
- if (result.cancelled) {
4990
+ let result;
4991
+ try {
4992
+ result = await workspaceManager.fork(workspaceId, source.sessionPath, msg.entryId);
4993
+ } catch (err2) {
4994
+ const message = err2 instanceof Error ? err2.message : String(err2);
4995
+ send(ws, { type: "error", message, command: "fork" });
4996
+ return;
4997
+ }
4998
+ if (result.cancelled || !result.runtime) {
2716
4999
  send(ws, { type: "error", message: "fork cancelled", command: "fork" });
5000
+ return;
2717
5001
  }
5002
+ promoteToPrimary(ws, state, workspaceId, result.runtime);
2718
5003
  });
2719
5004
  return;
2720
5005
  }
2721
5006
  case "answer_question": {
2722
- const wsId = state.workspaceId;
2723
- if (!wsId) {
5007
+ const primary = state.primary;
5008
+ if (!primary) {
2724
5009
  send(ws, { type: "error", message: "not subscribed", command: "answer_question" });
2725
5010
  return;
2726
5011
  }
2727
- if (replacementLocks.has(wsId)) return;
2728
- const runtime = workspaceManager.get(wsId);
2729
- if (!runtime) return;
2730
- const activeFile = runtime.session.sessionFile ?? null;
2731
- resolveAnswer(msg.toolCallId, msg.answer, activeFile);
5012
+ resolveAnswer(msg.toolCallId, msg.answer, primary.runtime.session.sessionFile ?? null);
5013
+ return;
5014
+ }
5015
+ case "navigate_tree": {
5016
+ const primary = state.primary;
5017
+ if (!primary) {
5018
+ send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5019
+ return;
5020
+ }
5021
+ if (msg.workspaceId !== primary.workspaceId) {
5022
+ send(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
5023
+ return;
5024
+ }
5025
+ await withReplacementLock(primary.workspaceId, async () => {
5026
+ const current = state.primary;
5027
+ if (!current) {
5028
+ send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5029
+ return;
5030
+ }
5031
+ if (current.runtime.session.isStreaming) {
5032
+ send(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
5033
+ return;
5034
+ }
5035
+ const result = await current.runtime.session.navigateTree(msg.targetId, {
5036
+ summarize: msg.summarize,
5037
+ customInstructions: msg.customInstructions
5038
+ });
5039
+ send(ws, {
5040
+ type: "navigate_tree_result",
5041
+ workspaceId: current.workspaceId,
5042
+ editorText: result.editorText,
5043
+ cancelled: result.cancelled
5044
+ });
5045
+ });
5046
+ return;
5047
+ }
5048
+ case "compact": {
5049
+ const primary = state.primary;
5050
+ if (!primary) {
5051
+ send(ws, { type: "error", message: "not subscribed", command: "compact" });
5052
+ return;
5053
+ }
5054
+ if (primary.runtime.session.isStreaming) {
5055
+ send(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
5056
+ return;
5057
+ }
5058
+ if (primary.runtime.session.isCompacting) {
5059
+ send(ws, { type: "error", message: "compaction already in progress", command: "compact" });
5060
+ return;
5061
+ }
5062
+ primary.runtime.session.compact().catch((err2) => {
5063
+ const message = err2 instanceof Error ? err2.message : String(err2);
5064
+ send(ws, { type: "error", message, command: "compact" });
5065
+ });
2732
5066
  return;
2733
5067
  }
2734
5068
  default: {
@@ -2738,28 +5072,26 @@ async function handle(ws, state, msg) {
2738
5072
  }
2739
5073
  }
2740
5074
  }
2741
- function ensureRebindListener(ws, state, workspaceId) {
2742
- if (state.workspaceId === workspaceId && state.unsubscribeRebind) return;
2743
- detach(state, ws);
2744
- state.workspaceId = workspaceId;
2745
- workspaceManager.addSubscriber(workspaceId, ws);
2746
- state.unsubscribeRebind = workspaceManager.onSessionReplaced(workspaceId, () => {
2747
- if (state.workspaceId !== workspaceId) return;
2748
- bindCurrentSession(ws, state, workspaceId);
2749
- });
2750
- }
2751
- function bindCurrentSession(ws, state, workspaceId) {
2752
- const runtime = workspaceManager.get(workspaceId);
2753
- if (!runtime) {
2754
- send(ws, { type: "error", message: "runtime gone", command: "subscribe" });
5075
+ function promoteToPrimary(ws, state, workspaceId, runtime) {
5076
+ const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
5077
+ if (state.primary?.runtimeKey === runtimeKey) {
5078
+ workspaceManager.setActive(workspaceId, runtime);
5079
+ sendSubscribed(ws, workspaceId, runtime);
2755
5080
  return;
2756
5081
  }
2757
- state.unsubscribeSession?.();
5082
+ if (state.primary) demotePrimaryToBackground(ws, state);
5083
+ if (state.background.has(runtimeKey)) teardownBackground(state, runtimeKey, ws);
5084
+ bindPrimary(ws, state, workspaceId, runtime);
5085
+ }
5086
+ function bindPrimary(ws, state, workspaceId, runtime) {
5087
+ workspaceManager.addSubscriber(workspaceId, ws);
5088
+ workspaceManager.setActive(workspaceId, runtime);
2758
5089
  const session = runtime.session;
2759
5090
  const sessionPath = session.sessionFile ?? null;
5091
+ const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
2760
5092
  let assistantStartAt;
2761
5093
  let assistantFirstTokenAt;
2762
- state.unsubscribeSession = session.subscribe((ev) => {
5094
+ const unsubscribe = session.subscribe((ev) => {
2763
5095
  const payload = translatePiEvent(ev);
2764
5096
  if (!payload) return;
2765
5097
  if (payload.kind === "message_start" && payload.role === "assistant") {
@@ -2768,12 +5100,7 @@ function bindCurrentSession(ws, state, workspaceId) {
2768
5100
  } else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
2769
5101
  assistantFirstTokenAt = performance.now();
2770
5102
  }
2771
- send(ws, {
2772
- type: "event",
2773
- workspaceId,
2774
- sessionPath,
2775
- payload
2776
- });
5103
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2777
5104
  if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
2778
5105
  const now = performance.now();
2779
5106
  const timing = {
@@ -2781,12 +5108,7 @@ function bindCurrentSession(ws, state, workspaceId) {
2781
5108
  firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
2782
5109
  totalMs: Math.round(now - assistantStartAt)
2783
5110
  };
2784
- send(ws, {
2785
- type: "event",
2786
- workspaceId,
2787
- sessionPath,
2788
- payload: timing
2789
- });
5111
+ send(ws, { type: "event", workspaceId, sessionPath, payload: timing });
2790
5112
  assistantStartAt = void 0;
2791
5113
  assistantFirstTokenAt = void 0;
2792
5114
  }
@@ -2794,33 +5116,80 @@ function bindCurrentSession(ws, state, workspaceId) {
2794
5116
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
2795
5117
  }
2796
5118
  });
2797
- send(ws, {
2798
- type: "subscribed",
2799
- workspaceId,
2800
- sessionPath,
2801
- sessionId: session.sessionId
2802
- });
2803
- const inFlight = inFlightAssistantSnapshot(runtime.session.state.streamingMessage);
5119
+ state.primary = { runtimeKey, workspaceId, sessionPath, runtime, unsubscribe };
5120
+ sendSubscribed(ws, workspaceId, runtime);
5121
+ const streamingMessage = runtime.session.state.streamingMessage;
5122
+ const scanMessages = streamingMessage ? [...runtime.session.messages, streamingMessage] : runtime.session.messages;
5123
+ for (const payload of inFlightRunningToolsSnapshot(
5124
+ runtime.session.state.pendingToolCalls,
5125
+ scanMessages
5126
+ )) {
5127
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
5128
+ }
5129
+ const inFlight = inFlightAssistantSnapshot(streamingMessage);
2804
5130
  if (inFlight) {
2805
5131
  for (const payload of inFlight) {
2806
- send(ws, {
2807
- type: "event",
2808
- workspaceId,
2809
- sessionPath,
2810
- payload
2811
- });
5132
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2812
5133
  }
2813
5134
  }
2814
5135
  for (const payload of inFlightToolCallsSnapshot(sessionPath)) {
2815
- send(ws, {
2816
- type: "event",
2817
- workspaceId,
2818
- sessionPath,
2819
- payload
2820
- });
5136
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2821
5137
  }
2822
5138
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
2823
5139
  }
5140
+ function demotePrimaryToBackground(ws, state) {
5141
+ const primary = state.primary;
5142
+ if (!primary) return;
5143
+ primary.unsubscribe();
5144
+ state.primary = void 0;
5145
+ const session = primary.runtime.session;
5146
+ const sessionPath = primary.sessionPath;
5147
+ const unsubscribeSession = session.subscribe((ev) => {
5148
+ const payload = translatePiEvent(ev);
5149
+ if (!payload) return;
5150
+ send(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
5151
+ if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
5152
+ sendContextUsage(ws, primary.runtime, primary.workspaceId, sessionPath);
5153
+ }
5154
+ });
5155
+ state.background.set(primary.runtimeKey, {
5156
+ workspaceId: primary.workspaceId,
5157
+ sessionPath,
5158
+ unsubscribeSession
5159
+ });
5160
+ while (state.background.size > BACKGROUND_CAP) {
5161
+ const oldestKey = state.background.keys().next().value;
5162
+ if (oldestKey === void 0) break;
5163
+ const evicted = state.background.get(oldestKey);
5164
+ teardownBackground(state, oldestKey, ws);
5165
+ if (evicted) {
5166
+ send(ws, {
5167
+ type: "background_evicted",
5168
+ workspaceId: evicted.workspaceId,
5169
+ sessionPath: evicted.sessionPath
5170
+ });
5171
+ }
5172
+ }
5173
+ }
5174
+ function teardownBackground(state, runtimeKey, ws) {
5175
+ const bg = state.background.get(runtimeKey);
5176
+ if (!bg) return;
5177
+ bg.unsubscribeSession();
5178
+ state.background.delete(runtimeKey);
5179
+ if (ws) unrefWorkspaceSubscriber(state, bg.workspaceId, ws);
5180
+ }
5181
+ function sendSubscribed(ws, workspaceId, runtime) {
5182
+ send(ws, {
5183
+ type: "subscribed",
5184
+ workspaceId,
5185
+ sessionPath: runtime.session.sessionFile ?? null,
5186
+ sessionId: runtime.session.sessionId
5187
+ });
5188
+ }
5189
+ function unrefWorkspaceSubscriber(state, workspaceId, ws) {
5190
+ const stillUsed = state.primary?.workspaceId === workspaceId || [...state.background.values()].some((b) => b.workspaceId === workspaceId);
5191
+ if (!stillUsed) workspaceManager.removeSubscriber(workspaceId, ws);
5192
+ }
2824
5193
  function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
2825
5194
  const usage = runtime.session.getContextUsage();
2826
5195
  if (!usage) return;
@@ -2830,22 +5199,20 @@ function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
2830
5199
  contextWindow: usage.contextWindow,
2831
5200
  percent: usage.percent
2832
5201
  };
2833
- send(ws, {
2834
- type: "event",
2835
- workspaceId,
2836
- sessionPath,
2837
- payload
2838
- });
5202
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
5203
+ }
5204
+ function detachPrimary(state, ws) {
5205
+ const primary = state.primary;
5206
+ if (!primary) return;
5207
+ primary.unsubscribe();
5208
+ state.primary = void 0;
5209
+ if (ws) unrefWorkspaceSubscriber(state, primary.workspaceId, ws);
2839
5210
  }
2840
5211
  function detach(state, ws) {
2841
- state.unsubscribeSession?.();
2842
- state.unsubscribeSession = void 0;
2843
- state.unsubscribeRebind?.();
2844
- state.unsubscribeRebind = void 0;
2845
- if (state.workspaceId && ws) {
2846
- workspaceManager.removeSubscriber(state.workspaceId, ws);
5212
+ detachPrimary(state, ws);
5213
+ for (const runtimeKey of [...state.background.keys()]) {
5214
+ teardownBackground(state, runtimeKey, ws);
2847
5215
  }
2848
- state.workspaceId = void 0;
2849
5216
  }
2850
5217
  function send(ws, msg) {
2851
5218
  if (ws.readyState !== ws.OPEN) return;
@@ -2854,10 +5221,10 @@ function send(ws, msg) {
2854
5221
 
2855
5222
  // src/index.ts
2856
5223
  configureHttpProxy();
2857
- var app = new Hono4();
2858
- var distDir = dirname6(fileURLToPath(import.meta.url));
2859
- var webRoot = resolve5(process.env.PI_PILOT_WEB_ROOT ?? join9(distDir, "..", "public"));
2860
- var webIndexPath = join9(webRoot, "index.html");
5224
+ var app = new Hono6();
5225
+ var distDir = dirname6(fileURLToPath2(import.meta.url));
5226
+ var webRoot = resolve7(process.env.PI_PILOT_WEB_ROOT ?? join16(distDir, "..", "public"));
5227
+ var webIndexPath = join16(webRoot, "index.html");
2861
5228
  var mimeTypes = {
2862
5229
  ".css": "text/css; charset=utf-8",
2863
5230
  ".html": "text/html; charset=utf-8",
@@ -2883,7 +5250,7 @@ function safeResolveWebPath(pathname) {
2883
5250
  return void 0;
2884
5251
  }
2885
5252
  const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
2886
- const candidate = resolve5(webRoot, relativePath);
5253
+ const candidate = resolve7(webRoot, relativePath);
2887
5254
  if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
2888
5255
  return void 0;
2889
5256
  }
@@ -2891,11 +5258,11 @@ function safeResolveWebPath(pathname) {
2891
5258
  }
2892
5259
  async function readWebFile(path) {
2893
5260
  try {
2894
- return await readFile5(path);
2895
- } catch (err) {
2896
- const code = err.code;
5261
+ return await readFile9(path);
5262
+ } catch (err2) {
5263
+ const code = err2.code;
2897
5264
  if (code === "ENOENT" || code === "EISDIR") return void 0;
2898
- throw err;
5265
+ throw err2;
2899
5266
  }
2900
5267
  }
2901
5268
  async function serveWeb(c) {
@@ -2904,7 +5271,7 @@ async function serveWeb(c) {
2904
5271
  const assetPath = safeResolveWebPath(pathname);
2905
5272
  if (!assetPath) return c.text("invalid asset path", 400);
2906
5273
  const asset = await readWebFile(assetPath);
2907
- const body = asset ?? await readFile5(webIndexPath);
5274
+ const body = asset ?? await readFile9(webIndexPath);
2908
5275
  const filePath = asset ? assetPath : webIndexPath;
2909
5276
  const headers = {
2910
5277
  "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",
@@ -2912,6 +5279,12 @@ async function serveWeb(c) {
2912
5279
  };
2913
5280
  return new Response(body, { headers });
2914
5281
  }
5282
+ app.use("*", async (c, next) => {
5283
+ if (!isAllowedHost(c.req.header("host"))) {
5284
+ return c.text("Forbidden", 403);
5285
+ }
5286
+ await next();
5287
+ });
2915
5288
  app.use(
2916
5289
  "/api/*",
2917
5290
  cors({
@@ -2923,7 +5296,8 @@ app.get("/api/health", (c) => c.json({ ok: true }));
2923
5296
  app.route("/api/workspaces", workspacesRoute);
2924
5297
  app.route("/api/fs", fsRoute);
2925
5298
  app.route("/api/model-configs", modelConfigsRoute);
2926
- if (existsSync(webIndexPath)) {
5299
+ app.route("/api/web-search", webSearchRoute);
5300
+ if (existsSync2(webIndexPath)) {
2927
5301
  app.get("*", serveWeb);
2928
5302
  } else {
2929
5303
  app.get(
@@ -2935,6 +5309,9 @@ if (existsSync(webIndexPath)) {
2935
5309
  );
2936
5310
  }
2937
5311
  await loadBuiltinPrefs();
5312
+ await loadSessionToolPrefs();
5313
+ await loadWebSearchPrefs();
5314
+ await sweepOrphanedChildrenOnBoot();
2938
5315
  var server = serve(
2939
5316
  {
2940
5317
  fetch: app.fetch,
@@ -2953,9 +5330,20 @@ async function shutdown(reason) {
2953
5330
  } catch (e) {
2954
5331
  console.error("[pi-pilot] disposeAll error:", e);
2955
5332
  }
5333
+ const sweptChildren = killAllChildren();
5334
+ if (sweptChildren > 0) {
5335
+ console.warn(`[pi-pilot] killed ${sweptChildren} lingering subagent child(ren)`);
5336
+ }
2956
5337
  server.close(() => process.exit(0));
2957
5338
  setTimeout(() => process.exit(1), 3e3).unref();
2958
5339
  }
5340
+ process.on("unhandledRejection", (reason) => {
5341
+ console.error("[pi-pilot] unhandled rejection (process kept alive):", reason);
5342
+ });
5343
+ process.on("uncaughtException", (err2) => {
5344
+ console.error("[pi-pilot] uncaught exception:", err2);
5345
+ void shutdown("uncaughtException");
5346
+ });
2959
5347
  process.on("SIGINT", () => void shutdown("SIGINT"));
2960
5348
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2961
5349
  //# sourceMappingURL=index.js.map