@jyork0828/pi-pilot 0.0.5 → 0.0.6

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
@@ -2,11 +2,11 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { existsSync } from "fs";
5
- import { readFile as readFile5 } from "fs/promises";
5
+ import { readFile as readFile6 } from "fs/promises";
6
6
  import { dirname as dirname6, extname, join as join9, resolve as resolve5, sep as sep3 } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { serve } from "@hono/node-server";
9
- import { Hono as Hono4 } from "hono";
9
+ import { Hono as Hono5 } 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";
44
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
45
45
  import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve3 } from "path";
46
- import { Hono } from "hono";
46
+ import { Hono as Hono2 } from "hono";
47
47
 
48
48
  // src/storage/resource-writer.ts
49
49
  import {
@@ -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
  }
@@ -351,17 +351,22 @@ import { dirname as dirname2, join as join3 } from "path";
351
351
  import { randomUUID } from "crypto";
352
352
  var REGISTRY_PATH = join3(config.dataDir, "workspaces.json");
353
353
  var cache;
354
+ var writeChain = Promise.resolve();
355
+ function serializedWrite(fn) {
356
+ writeChain = writeChain.then(fn, fn);
357
+ return writeChain;
358
+ }
354
359
  async function load() {
355
360
  if (cache) return cache;
356
361
  try {
357
362
  const raw = await readFile2(REGISTRY_PATH, "utf8");
358
363
  cache = JSON.parse(raw);
359
364
  if (!Array.isArray(cache.workspaces)) cache = { workspaces: [] };
360
- } catch (err) {
361
- if (err.code === "ENOENT") {
365
+ } catch (err2) {
366
+ if (err2.code === "ENOENT") {
362
367
  cache = { workspaces: [] };
363
368
  } else {
364
- throw err;
369
+ throw err2;
365
370
  }
366
371
  }
367
372
  return cache;
@@ -380,26 +385,57 @@ async function getWorkspace(id) {
380
385
  return r.workspaces.find((w) => w.id === id);
381
386
  }
382
387
  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;
388
+ let result;
389
+ await serializedWrite(async () => {
390
+ const r = await load();
391
+ const existing = r.workspaces.find((w) => w.path === input.path);
392
+ if (existing) {
393
+ result = existing;
394
+ return;
395
+ }
396
+ const ws = {
397
+ id: randomUUID(),
398
+ name: input.name,
399
+ path: input.path,
400
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
401
+ };
402
+ r.workspaces.push(ws);
403
+ await save();
404
+ result = ws;
405
+ });
406
+ return result;
395
407
  }
396
408
  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;
409
+ let removed = false;
410
+ await serializedWrite(async () => {
411
+ const r = await load();
412
+ const before = r.workspaces.length;
413
+ r.workspaces = r.workspaces.filter((w) => w.id !== id);
414
+ if (r.workspaces.length === before) return;
415
+ removed = true;
416
+ await save();
417
+ });
418
+ return removed;
419
+ }
420
+ async function reorderWorkspaces(ids) {
421
+ await serializedWrite(async () => {
422
+ const r = await load();
423
+ const byId = new Map(r.workspaces.map((w) => [w.id, w]));
424
+ const reordered = [];
425
+ const seen = /* @__PURE__ */ new Set();
426
+ for (const id of ids) {
427
+ const ws = byId.get(id);
428
+ if (ws && !seen.has(id)) {
429
+ reordered.push(ws);
430
+ seen.add(id);
431
+ }
432
+ }
433
+ for (const ws of r.workspaces) {
434
+ if (!seen.has(ws.id)) reordered.push(ws);
435
+ }
436
+ r.workspaces = reordered;
437
+ await save();
438
+ });
403
439
  }
404
440
 
405
441
  // src/storage/workspace-stats.ts
@@ -502,57 +538,232 @@ import {
502
538
  SessionManager
503
539
  } from "@earendil-works/pi-coding-agent";
504
540
 
505
- // src/extensions/plan/schema.ts
541
+ // src/extensions/todo/schema.ts
506
542
  import { Type } from "typebox";
507
- var planItemStatusSchema = Type.Union([
543
+ var EMPTY_STATE = { tasks: [], nextId: 1 };
544
+ var VALID_TRANSITIONS = {
545
+ pending: /* @__PURE__ */ new Set(["in_progress", "completed", "deleted"]),
546
+ in_progress: /* @__PURE__ */ new Set(["pending", "completed", "deleted"]),
547
+ completed: /* @__PURE__ */ new Set(["deleted"]),
548
+ deleted: /* @__PURE__ */ new Set()
549
+ };
550
+ function isTransitionValid(from, to) {
551
+ if (from === to) return true;
552
+ return VALID_TRANSITIONS[from].has(to);
553
+ }
554
+ var ActionEnum = Type.Union([
555
+ Type.Literal("create"),
556
+ Type.Literal("update"),
557
+ Type.Literal("list"),
558
+ Type.Literal("get"),
559
+ Type.Literal("delete"),
560
+ Type.Literal("clear")
561
+ ]);
562
+ var StatusEnum = Type.Union([
508
563
  Type.Literal("pending"),
509
564
  Type.Literal("in_progress"),
510
- Type.Literal("completed")
565
+ Type.Literal("completed"),
566
+ Type.Literal("deleted")
511
567
  ]);
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
- })
568
+ var todoParamsSchema = Type.Object({
569
+ action: ActionEnum,
570
+ subject: Type.Optional(Type.String({ description: "Task subject line (required for create)" })),
571
+ description: Type.Optional(Type.String({ description: "Long-form task description" })),
572
+ status: Type.Optional(StatusEnum),
573
+ id: Type.Optional(Type.Number({ description: "Task id (required for update, get, delete)" })),
574
+ includeDeleted: Type.Optional(Type.Boolean({
575
+ description: "If true, list action returns deleted (tombstoned) tasks as well. Default: false."
576
+ }))
522
577
  });
523
578
 
524
- // src/extensions/plan/factory.ts
525
- var planExtensionFactory = (pi) => {
579
+ // src/extensions/todo/reducer.ts
580
+ function err(state, message) {
581
+ return { state, text: `Error: ${message}`, error: message };
582
+ }
583
+ function formatListLine(t) {
584
+ return `[${t.status}] #${t.id} ${t.subject}`;
585
+ }
586
+ function applyTodoAction(state, action, params) {
587
+ switch (action) {
588
+ case "create": {
589
+ if (!params.subject?.trim()) {
590
+ return err(state, "subject required for create");
591
+ }
592
+ const task = {
593
+ id: state.nextId,
594
+ subject: params.subject,
595
+ status: "pending"
596
+ };
597
+ if (params.description) task.description = params.description;
598
+ const newTasks = [...state.tasks, task];
599
+ return {
600
+ state: { tasks: newTasks, nextId: state.nextId + 1 },
601
+ text: `Created #${task.id}: ${task.subject} (pending)`
602
+ };
603
+ }
604
+ case "update": {
605
+ if (params.id === void 0) return err(state, "id required for update");
606
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
607
+ if (idx === -1) return err(state, `#${params.id} not found`);
608
+ const current = state.tasks[idx];
609
+ const hasMutation = params.subject !== void 0 || params.description !== void 0 || params.status !== void 0;
610
+ if (!hasMutation) return err(state, "update requires at least one mutable field");
611
+ let newStatus = current.status;
612
+ if (params.status !== void 0) {
613
+ if (!isTransitionValid(current.status, params.status)) {
614
+ return err(state, `illegal transition ${current.status} \u2192 ${params.status}`);
615
+ }
616
+ newStatus = params.status;
617
+ }
618
+ const updated = { ...current, status: newStatus };
619
+ if (params.subject !== void 0) updated.subject = params.subject;
620
+ if (params.description !== void 0) updated.description = params.description;
621
+ const newTasks = [...state.tasks];
622
+ newTasks[idx] = updated;
623
+ const transition = current.status !== newStatus ? ` (${current.status} \u2192 ${newStatus})` : "";
624
+ return {
625
+ state: { tasks: newTasks, nextId: state.nextId },
626
+ text: `Updated #${updated.id}${transition}`
627
+ };
628
+ }
629
+ case "list": {
630
+ let view = state.tasks;
631
+ if (!params.includeDeleted) view = view.filter((t) => t.status !== "deleted");
632
+ if (params.status) view = view.filter((t) => t.status === params.status);
633
+ return {
634
+ state,
635
+ text: view.length === 0 ? "No tasks" : view.map(formatListLine).join("\n")
636
+ };
637
+ }
638
+ case "get": {
639
+ if (params.id === void 0) return err(state, "id required for get");
640
+ const task = state.tasks.find((t) => t.id === params.id);
641
+ if (!task) return err(state, `#${params.id} not found`);
642
+ const lines = [`#${task.id} [${task.status}] ${task.subject}`];
643
+ if (task.description) lines.push(` description: ${task.description}`);
644
+ return { state, text: lines.join("\n") };
645
+ }
646
+ case "delete": {
647
+ if (params.id === void 0) return err(state, "id required for delete");
648
+ const idx = state.tasks.findIndex((t) => t.id === params.id);
649
+ if (idx === -1) return err(state, `#${params.id} not found`);
650
+ const current = state.tasks[idx];
651
+ if (current.status === "deleted") return err(state, `#${current.id} is already deleted`);
652
+ const updated = { ...current, status: "deleted" };
653
+ const newTasks = [...state.tasks];
654
+ newTasks[idx] = updated;
655
+ return {
656
+ state: { tasks: newTasks, nextId: state.nextId },
657
+ text: `Deleted #${updated.id}: ${updated.subject}`
658
+ };
659
+ }
660
+ case "clear": {
661
+ const count = state.tasks.length;
662
+ return {
663
+ state: { tasks: [], nextId: 1 },
664
+ text: `Cleared ${count} tasks`
665
+ };
666
+ }
667
+ }
668
+ }
669
+
670
+ // src/extensions/todo/factory.ts
671
+ var TOOL_NAME = "todo";
672
+ function replayFromBranch(ctx) {
673
+ let result = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
674
+ for (const entry of ctx.sessionManager.getBranch()) {
675
+ const e = entry;
676
+ if (e.type !== "message") continue;
677
+ const msg = e.message;
678
+ if (msg?.role !== "toolResult" || msg.toolName !== TOOL_NAME) continue;
679
+ if (msg.isError) continue;
680
+ const details = msg.details;
681
+ if (!details || !Array.isArray(details.tasks) || typeof details.nextId !== "number") continue;
682
+ if (details.error) continue;
683
+ result = {
684
+ tasks: details.tasks.map((t) => ({ ...t })),
685
+ nextId: details.nextId
686
+ };
687
+ }
688
+ return result;
689
+ }
690
+ var todoExtensionFactory = (pi) => {
691
+ let state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
526
692
  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.",
693
+ name: TOOL_NAME,
694
+ label: "Todo",
695
+ 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.",
696
+ promptSnippet: "Manage a task list to track multi-step progress.",
532
697
  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."
698
+ "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.",
699
+ "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.",
700
+ "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
701
  ],
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
- })
702
+ parameters: todoParamsSchema,
703
+ async execute(_toolCallId, params) {
704
+ const result = applyTodoAction(state, params.action, params);
705
+ state = result.state;
706
+ const details = {
707
+ action: params.action,
708
+ tasks: state.tasks,
709
+ nextId: state.nextId,
710
+ ...result.error ? { error: result.error } : {}
711
+ };
712
+ return {
713
+ content: [{ type: "text", text: result.text }],
714
+ details
715
+ };
716
+ }
547
717
  });
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);
718
+ pi.registerCommand("todos", {
719
+ description: "Show current todo list grouped by status.",
720
+ handler: async () => {
721
+ const visible = state.tasks.filter((t) => t.status !== "deleted");
722
+ if (visible.length === 0) {
723
+ pi.sendUserMessage("Show the current todo list.");
724
+ return;
725
+ }
726
+ const pending2 = visible.filter((t) => t.status === "pending");
727
+ const inProgress = visible.filter((t) => t.status === "in_progress");
728
+ const completed = visible.filter((t) => t.status === "completed");
729
+ const lines = [];
730
+ const total = visible.length;
731
+ const doneCount = completed.length;
732
+ lines.push(`Todos (${doneCount}/${total})`);
733
+ if (inProgress.length > 0) {
734
+ lines.push("\u2500\u2500 In Progress \u2500\u2500");
735
+ for (const t of inProgress) lines.push(` \u25D0 #${t.id} ${t.subject}`);
736
+ }
737
+ if (pending2.length > 0) {
738
+ lines.push("\u2500\u2500 Pending \u2500\u2500");
739
+ for (const t of pending2) lines.push(` \u25CB #${t.id} ${t.subject}`);
740
+ }
741
+ if (completed.length > 0) {
742
+ lines.push("\u2500\u2500 Completed \u2500\u2500");
743
+ for (const t of completed) lines.push(` \u2713 #${t.id} ${t.subject}`);
744
+ }
745
+ pi.sendUserMessage(`Current todos:
746
+ ${lines.join("\n")}`);
554
747
  }
555
748
  });
749
+ pi.on("session_start", async (_event, ctx) => {
750
+ state = replayFromBranch(ctx);
751
+ });
752
+ pi.on("session_compact", async (_event, ctx) => {
753
+ try {
754
+ state = replayFromBranch(ctx);
755
+ } catch {
756
+ }
757
+ });
758
+ pi.on("session_tree", async (_event, ctx) => {
759
+ try {
760
+ state = replayFromBranch(ctx);
761
+ } catch {
762
+ }
763
+ });
764
+ pi.on("session_shutdown", async () => {
765
+ state = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
766
+ });
556
767
  };
557
768
 
558
769
  // src/extensions/ask_user/schema.ts
@@ -616,13 +827,6 @@ function resolveAnswer(toolCallId, answer, expectedSessionFile) {
616
827
  entry.resolve(answer);
617
828
  return true;
618
829
  }
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
830
  function cancelPendingForSession(sessionFile) {
627
831
  for (const [id, entry] of pending) {
628
832
  if (entry.sessionFile !== sessionFile) continue;
@@ -687,11 +891,11 @@ function waitForAnswer({
687
891
  cleanup();
688
892
  resolve6(a);
689
893
  };
690
- const finishErr = (err) => {
894
+ const finishErr = (err2) => {
691
895
  if (settled) return;
692
896
  settled = true;
693
897
  cleanup();
694
- reject(err);
898
+ reject(err2);
695
899
  };
696
900
  const onAbort = () => finishErr(new Error("Aborted by user"));
697
901
  if (signal?.aborted) {
@@ -709,7 +913,7 @@ function waitForAnswer({
709
913
  args: params,
710
914
  sessionFile,
711
915
  resolve: (answer) => finishOk(answer),
712
- reject: (err) => finishErr(err)
916
+ reject: (err2) => finishErr(err2)
713
917
  });
714
918
  });
715
919
  }
@@ -759,6 +963,70 @@ function descriptionSuffix(params, index) {
759
963
  return desc ? ` \u2014 ${desc}` : "";
760
964
  }
761
965
 
966
+ // src/extensions/artifact/schema.ts
967
+ import { Type as Type3 } from "typebox";
968
+ var TypeEnum = Type3.Union(
969
+ [
970
+ Type3.Literal("html"),
971
+ Type3.Literal("svg"),
972
+ Type3.Literal("markdown"),
973
+ Type3.Literal("code")
974
+ ],
975
+ {
976
+ description: 'How to render the content: "html" (a self-contained HTML document or fragment, run in a sandboxed iframe), "svg" (SVG markup), "markdown" (rich text), or "code" (a source file shown with syntax highlighting).'
977
+ }
978
+ );
979
+ var createArtifactParamsSchema = Type3.Object({
980
+ id: Type3.Optional(
981
+ Type3.String({
982
+ description: 'Stable identifier. Omit on first creation. To REVISE an existing artifact, pass the same id you used before \u2014 that records a new version instead of a separate artifact. Use a short slug like "landing-page".'
983
+ })
984
+ ),
985
+ type: TypeEnum,
986
+ title: Type3.String({
987
+ description: "Short human-readable title shown in the artifact panel."
988
+ }),
989
+ content: Type3.String({
990
+ description: "The full artifact content \u2014 the complete document, markup, or source."
991
+ }),
992
+ language: Type3.Optional(
993
+ Type3.String({
994
+ description: 'For type="code", the language id for syntax highlighting (e.g. "python", "typescript").'
995
+ })
996
+ )
997
+ });
998
+
999
+ // src/extensions/artifact/factory.ts
1000
+ var TOOL_NAME2 = "create_artifact";
1001
+ var artifactExtensionFactory = (pi) => {
1002
+ pi.registerTool({
1003
+ name: TOOL_NAME2,
1004
+ label: "Create artifact",
1005
+ description: 'Publish a substantial, self-contained piece of content as an "artifact" the user can view and iterate on in a dedicated side panel: a web page (html), an SVG diagram (svg), a document (markdown), or a source file (code). Reuse the same `id` to revise an existing artifact (records a new version).',
1006
+ promptSnippet: "create_artifact: render substantial, self-contained content (web page / SVG / document / code file) in a side panel the user can view and iterate on.",
1007
+ promptGuidelines: [
1008
+ "Use create_artifact for substantial, self-contained, reusable content the user will want to view, keep, or iterate on \u2014 a runnable HTML page, an SVG diagram, a full document, or a standalone code file. Do NOT use it for short snippets, command output, or your normal conversational answer; a fenced code block in your reply is better for those.",
1009
+ "To revise an artifact, call create_artifact again with the SAME id and the full updated content \u2014 this records a new version the user can step through. Don't spawn a near-duplicate artifact under a new id.",
1010
+ 'Put the entire content in `content`, give it a concise `title`, and pick the `type` that matches how it should render. For type="code", set `language`.',
1011
+ "After creating an artifact, keep your chat reply short \u2014 the content lives in the panel, so don't paste it again in prose."
1012
+ ],
1013
+ parameters: createArtifactParamsSchema,
1014
+ execute: async (toolCallId, params) => {
1015
+ const id = params.id?.trim() || toolCallId;
1016
+ const details = {
1017
+ id,
1018
+ type: params.type,
1019
+ title: params.title
1020
+ };
1021
+ const text = `Artifact "${params.title}" (${params.type}) is now shown to the user in the Artifacts panel. Its id is "${id}" \u2014 pass that same id to create_artifact to revise it.`;
1022
+ return {
1023
+ content: [{ type: "text", text }],
1024
+ details
1025
+ };
1026
+ }
1027
+ });
1028
+ };
1029
+
762
1030
  // src/storage/builtin-extension-prefs.ts
763
1031
  import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
764
1032
  import { dirname as dirname3, join as join4 } from "path";
@@ -769,15 +1037,17 @@ async function loadBuiltinPrefs() {
769
1037
  const raw = await readFile3(PREFS_PATH, "utf8");
770
1038
  const parsed = JSON.parse(raw);
771
1039
  cache3 = { disabled: Array.isArray(parsed.disabled) ? parsed.disabled : [] };
772
- } catch (err) {
1040
+ } catch (err2) {
773
1041
  cache3 = { disabled: [] };
774
- if (err.code !== "ENOENT") {
775
- console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH}:`, err);
1042
+ if (err2.code !== "ENOENT") {
1043
+ console.warn(`[builtin-prefs] ignoring unreadable ${PREFS_PATH}:`, err2);
776
1044
  }
777
1045
  }
778
1046
  }
779
1047
  function isBuiltinDisabled(id) {
780
- return cache3.disabled.includes(id);
1048
+ if (cache3.disabled.includes(id)) return true;
1049
+ if (id === "todo" && cache3.disabled.includes("plan")) return true;
1050
+ return false;
781
1051
  }
782
1052
  function getDisabledBuiltins() {
783
1053
  return [...cache3.disabled];
@@ -797,12 +1067,12 @@ async function save2() {
797
1067
  // src/extensions/index.ts
798
1068
  var BUILTIN_EXTENSIONS = [
799
1069
  {
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
1070
+ id: "todo",
1071
+ name: "Todo",
1072
+ description: "A CRUD task list for tracking multi-step work \u2014 adds the todo tool and the /todos command.",
1073
+ tools: ["todo"],
1074
+ commands: ["todos"],
1075
+ factory: todoExtensionFactory
806
1076
  },
807
1077
  {
808
1078
  id: "ask_user",
@@ -811,6 +1081,14 @@ var BUILTIN_EXTENSIONS = [
811
1081
  tools: ["ask_user"],
812
1082
  commands: [],
813
1083
  factory: askUserExtensionFactory
1084
+ },
1085
+ {
1086
+ id: "artifact",
1087
+ name: "Artifacts",
1088
+ description: "Lets the agent publish substantial, self-contained content \u2014 web pages, SVG diagrams, documents, code files \u2014 as versioned artifacts rendered in a side panel. Adds the create_artifact tool.",
1089
+ tools: ["create_artifact"],
1090
+ commands: [],
1091
+ factory: artifactExtensionFactory
814
1092
  }
815
1093
  ];
816
1094
  function gate(def) {
@@ -1036,6 +1314,37 @@ function inFlightAssistantSnapshot(streamingMessage) {
1036
1314
  }
1037
1315
  return events;
1038
1316
  }
1317
+ function inFlightRunningToolsSnapshot(pendingToolCalls, messages) {
1318
+ const pending2 = new Set(pendingToolCalls);
1319
+ if (pending2.size === 0) return [];
1320
+ const infoById = /* @__PURE__ */ new Map();
1321
+ for (const message of messages) {
1322
+ if (!message || typeof message !== "object") continue;
1323
+ if (message.role !== "assistant") continue;
1324
+ const content = message.content;
1325
+ if (!Array.isArray(content)) continue;
1326
+ for (const block of content) {
1327
+ if (!block || typeof block !== "object") continue;
1328
+ const b = block;
1329
+ if (b.type === "toolCall" && typeof b.id === "string" && pending2.has(b.id)) {
1330
+ infoById.set(b.id, { name: typeof b.name === "string" ? b.name : "tool", args: b.arguments });
1331
+ }
1332
+ }
1333
+ }
1334
+ const events = [];
1335
+ for (const toolCallId of pending2) {
1336
+ const info = infoById.get(toolCallId);
1337
+ if (!info) continue;
1338
+ if (info.name === "ask_user") continue;
1339
+ events.push({
1340
+ kind: "tool_execution_start",
1341
+ toolCallId,
1342
+ toolName: info.name,
1343
+ args: info.args
1344
+ });
1345
+ }
1346
+ return events;
1347
+ }
1039
1348
  function inFlightToolCallsSnapshot(sessionFile) {
1040
1349
  const pending2 = snapshotForSession(sessionFile);
1041
1350
  return pending2.map((p) => ({
@@ -1045,7 +1354,7 @@ function inFlightToolCallsSnapshot(sessionFile) {
1045
1354
  args: p.args
1046
1355
  }));
1047
1356
  }
1048
- var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set(["ask_user"]);
1357
+ var DETAILS_FORWARD_WHITELIST = /* @__PURE__ */ new Set(["ask_user", "todo"]);
1049
1358
  function shouldForwardDetails(toolName) {
1050
1359
  return DETAILS_FORWARD_WHITELIST.has(toolName);
1051
1360
  }
@@ -1146,6 +1455,7 @@ var ExtensionUIBridge = class {
1146
1455
 
1147
1456
  // src/workspace-manager.ts
1148
1457
  var EXTENSIONS_ENABLED = process.env.PI_PILOT_ENABLE_EXTENSIONS === "1";
1458
+ var MAX_LIVE_RUNTIMES = 12;
1149
1459
  var createRuntime = async ({
1150
1460
  cwd,
1151
1461
  sessionManager,
@@ -1169,18 +1479,36 @@ var createRuntime = async ({
1169
1479
  diagnostics: services.diagnostics
1170
1480
  };
1171
1481
  };
1172
- var WorkspaceManager = class {
1173
- states = /* @__PURE__ */ new Map();
1482
+ var KEY_SEP = "\0";
1483
+ var SessionRuntimeManager = class {
1484
+ /** All live runtimes, keyed by `runtimeKey`. */
1485
+ runtimes = /* @__PURE__ */ new Map();
1486
+ /** Per-build lock keyed by `runtimeKey` to serialize concurrent creations. */
1487
+ pending = /* @__PURE__ */ new Map();
1488
+ /** The runtime the hub last made primary, per workspace. Drives `get`. */
1489
+ activeByWorkspace = /* @__PURE__ */ new Map();
1174
1490
  /**
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).
1491
+ * WS subscribers, keyed by workspaceId (not runtimeKey): a connection
1492
+ * viewing any session of a workspace receives that workspace's
1493
+ * server-initiated broadcasts (extension errors, context_usage). Owned by
1494
+ * the manager so it can pre-exist any runtime build (extensions may fire
1495
+ * `onError` from `session_start` before any client subscribed).
1179
1496
  */
1180
1497
  subscribers = /* @__PURE__ */ new Map();
1181
- /** Per-workspace lock to serialize concurrent creations. */
1182
- pending = /* @__PURE__ */ new Map();
1183
- rebindListeners = /* @__PURE__ */ new Map();
1498
+ touchSeq = 0;
1499
+ /** `runtimeKey` for a (workspace, session identity). */
1500
+ keyOf(workspaceId, sessionIdentity) {
1501
+ return `${workspaceId}${KEY_SEP}${sessionIdentity}`;
1502
+ }
1503
+ /** `runtimeKey` for a built runtime, from its session file (or sessionId). */
1504
+ keyForRuntime(workspaceId, runtime) {
1505
+ const file = runtime.session.sessionFile;
1506
+ return this.keyOf(workspaceId, file ? resolve2(file) : runtime.session.sessionId);
1507
+ }
1508
+ /** Public so the WS hub derives the exact same key for a returned runtime. */
1509
+ runtimeKeyFor(workspaceId, runtime) {
1510
+ return this.keyForRuntime(workspaceId, runtime);
1511
+ }
1184
1512
  getOrCreateSubscriberSet(workspaceId) {
1185
1513
  let set = this.subscribers.get(workspaceId);
1186
1514
  if (!set) {
@@ -1189,64 +1517,186 @@ var WorkspaceManager = class {
1189
1517
  }
1190
1518
  return set;
1191
1519
  }
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);
1520
+ /**
1521
+ * Build (but do not register) a runtime for `workspaceId` from a
1522
+ * SessionManager factory. Binds the UI bridge + onError and runs ask_user
1523
+ * post-restart cleanup. The bridge broadcasts to the workspace's subscriber
1524
+ * set, resolved lazily so it works even if the set is created later.
1525
+ */
1526
+ async buildState(workspaceId, cwd, makeSessionManager) {
1211
1527
  const runtime = await createAgentSessionRuntime(createRuntime, {
1212
- cwd: ws.path,
1528
+ cwd,
1213
1529
  agentDir: getAgentDir(),
1214
- sessionManager
1530
+ sessionManager: makeSessionManager()
1215
1531
  });
1216
- const subscribers = this.getOrCreateSubscriberSet(workspaceId);
1217
1532
  const bridge = new ExtensionUIBridge();
1218
- const onError = (err) => {
1533
+ await this.bindExtensions(workspaceId, runtime, bridge);
1534
+ safeReconcileAskUser(workspaceId, runtime.session.sessionManager);
1535
+ return {
1536
+ runtime,
1537
+ bridge,
1538
+ workspaceId,
1539
+ sessionPath: runtime.session.sessionFile ? resolve2(runtime.session.sessionFile) : null,
1540
+ touchedAt: ++this.touchSeq
1541
+ };
1542
+ }
1543
+ /** Bind (or re-bind, after a fork) the UI context + onError on a session. */
1544
+ async bindExtensions(workspaceId, runtime, bridge) {
1545
+ const onError = (err2) => {
1219
1546
  const msg = {
1220
1547
  type: "extension_error",
1221
1548
  workspaceId,
1222
- extensionPath: err.extensionPath,
1223
- event: err.event,
1224
- message: err.error
1549
+ extensionPath: err2.extensionPath,
1550
+ event: err2.event,
1551
+ message: err2.error
1225
1552
  };
1226
- broadcastTo(subscribers, msg);
1553
+ const set = this.subscribers.get(workspaceId);
1554
+ if (set) broadcastTo(set, msg);
1227
1555
  console.error(
1228
- `[ext-error] ${workspaceId} ${err.extensionPath}@${err.event}: ${err.error}` + (err.stack ? `
1229
- ${err.stack}` : "")
1556
+ `[ext-error] ${workspaceId} ${err2.extensionPath}@${err2.event}: ${err2.error}` + (err2.stack ? `
1557
+ ${err2.stack}` : "")
1230
1558
  );
1231
1559
  };
1232
1560
  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
1561
  }
1562
+ /** Register a freshly-built state under its session key, deduping against a
1563
+ * concurrent build of the same session. Returns the winning state. */
1564
+ async adopt(state) {
1565
+ const key = this.keyForRuntime(state.workspaceId, state.runtime);
1566
+ const existing = this.runtimes.get(key);
1567
+ if (existing) {
1568
+ await this.disposeState(state);
1569
+ return existing;
1570
+ }
1571
+ this.runtimes.set(key, state);
1572
+ this.evictIfOverCap(key);
1573
+ return state;
1574
+ }
1575
+ /**
1576
+ * Ensure a runtime exists for the target session and return it.
1577
+ *
1578
+ * - With `sessionPath`: opens that specific session (deduped by key).
1579
+ * - Without: returns the workspace's active/any live runtime, or builds the
1580
+ * "continue recent" default if none exists yet.
1581
+ *
1582
+ * Does NOT change the active pointer — the hub owns that via `setActive`.
1583
+ */
1584
+ async getOrCreate(workspaceId, sessionPath) {
1585
+ const ws = await getWorkspace(workspaceId);
1586
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1587
+ if (sessionPath) {
1588
+ if (!isAbsolute2(sessionPath)) throw new Error("Session path must be absolute");
1589
+ const resolved = resolve2(sessionPath);
1590
+ const key = this.keyOf(workspaceId, resolved);
1591
+ const existing2 = this.runtimes.get(key);
1592
+ if (existing2) {
1593
+ existing2.touchedAt = ++this.touchSeq;
1594
+ return existing2.runtime;
1595
+ }
1596
+ const inflight4 = this.pending.get(key);
1597
+ if (inflight4) return (await inflight4).runtime;
1598
+ const p2 = this.buildState(
1599
+ workspaceId,
1600
+ ws.path,
1601
+ () => SessionManager.open(resolved, void 0, ws.path)
1602
+ ).then((s) => this.adopt(s));
1603
+ this.pending.set(key, p2);
1604
+ try {
1605
+ return (await p2).runtime;
1606
+ } finally {
1607
+ this.pending.delete(key);
1608
+ }
1609
+ }
1610
+ const existing = this.get(workspaceId);
1611
+ if (existing) return existing;
1612
+ const defaultKey = this.keyOf(workspaceId, "<default>");
1613
+ const inflight3 = this.pending.get(defaultKey);
1614
+ if (inflight3) return (await inflight3).runtime;
1615
+ const p = this.buildState(
1616
+ workspaceId,
1617
+ ws.path,
1618
+ () => SessionManager.continueRecent(ws.path)
1619
+ ).then((s) => this.adopt(s));
1620
+ this.pending.set(defaultKey, p);
1621
+ try {
1622
+ return (await p).runtime;
1623
+ } finally {
1624
+ this.pending.delete(defaultKey);
1625
+ }
1626
+ }
1627
+ /** Create a brand-new empty session + runtime for the workspace (does not
1628
+ * touch any existing runtime, so a streaming session keeps running). */
1629
+ async createSession(workspaceId) {
1630
+ const ws = await getWorkspace(workspaceId);
1631
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1632
+ const state = await this.buildState(
1633
+ workspaceId,
1634
+ ws.path,
1635
+ () => SessionManager.create(ws.path)
1636
+ );
1637
+ return (await this.adopt(state)).runtime;
1638
+ }
1639
+ /**
1640
+ * Fork an existing session at `entryId` into a new branched session and
1641
+ * return a runtime bound to the branch. The source session's own runtime
1642
+ * (if any) is untouched. Returns `{ cancelled: true }` if pi cancelled the
1643
+ * fork (e.g. a `session_before_switch` veto).
1644
+ */
1645
+ async fork(workspaceId, sourceSessionPath, entryId) {
1646
+ const ws = await getWorkspace(workspaceId);
1647
+ if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1648
+ if (!isAbsolute2(sourceSessionPath)) throw new Error("Session path must be absolute");
1649
+ const state = await this.buildState(
1650
+ workspaceId,
1651
+ ws.path,
1652
+ () => SessionManager.open(resolve2(sourceSessionPath), void 0, ws.path)
1653
+ );
1654
+ let result;
1655
+ try {
1656
+ result = await state.runtime.fork(entryId);
1657
+ } catch (err2) {
1658
+ await this.disposeState(state);
1659
+ throw err2;
1660
+ }
1661
+ if (result.cancelled) {
1662
+ await this.disposeState(state);
1663
+ return { cancelled: true };
1664
+ }
1665
+ await this.bindExtensions(workspaceId, state.runtime, state.bridge);
1666
+ safeReconcileAskUser(workspaceId, state.runtime.session.sessionManager);
1667
+ state.sessionPath = state.runtime.session.sessionFile ? resolve2(state.runtime.session.sessionFile) : null;
1668
+ const winner = await this.adopt(state);
1669
+ return { cancelled: false, runtime: winner.runtime };
1670
+ }
1671
+ /** The active session's runtime for this workspace (hub-designated), or any
1672
+ * live runtime for it, or undefined. Used by per-workspace REST routes. */
1242
1673
  get(workspaceId) {
1243
- return this.states.get(workspaceId)?.runtime;
1674
+ const activeKey = this.activeByWorkspace.get(workspaceId);
1675
+ if (activeKey) {
1676
+ const active = this.runtimes.get(activeKey);
1677
+ if (active) return active.runtime;
1678
+ }
1679
+ for (const state of this.runtimes.values()) {
1680
+ if (state.workspaceId === workspaceId) return state.runtime;
1681
+ }
1682
+ return void 0;
1683
+ }
1684
+ /** The runtime bound to a specific (workspace, session), if live. */
1685
+ getForSession(workspaceId, sessionPath) {
1686
+ return this.runtimes.get(this.keyOf(workspaceId, resolve2(sessionPath)))?.runtime;
1687
+ }
1688
+ /** Mark `runtime` as the active session for its workspace (hub on primary
1689
+ * bind), so per-workspace routes resolve to it. */
1690
+ setActive(workspaceId, runtime) {
1691
+ const key = this.keyForRuntime(workspaceId, runtime);
1692
+ this.activeByWorkspace.set(workspaceId, key);
1693
+ const state = this.runtimes.get(key);
1694
+ if (state) state.touchedAt = ++this.touchSeq;
1244
1695
  }
1245
1696
  /**
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.
1697
+ * Register a WS connection as a subscriber for `workspaceId` (server-
1698
+ * initiated broadcasts: extension errors, context_usage). Safe to call
1699
+ * before any runtime build.
1250
1700
  */
1251
1701
  addSubscriber(workspaceId, ws) {
1252
1702
  this.getOrCreateSubscriberSet(workspaceId).add(ws);
@@ -1257,60 +1707,44 @@ ${err.stack}` : "")
1257
1707
  set.delete(ws);
1258
1708
  if (set.size === 0) this.subscribers.delete(workspaceId);
1259
1709
  }
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
- */
1710
+ /** Fan a server-initiated message out to every WS subscribed to the
1711
+ * workspace (e.g. context_usage after setModel). */
1267
1712
  broadcast(workspaceId, msg) {
1268
1713
  const set = this.subscribers.get(workspaceId);
1269
1714
  if (!set || set.size === 0) return;
1270
1715
  broadcastTo(set, msg);
1271
1716
  }
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
- }
1717
+ /**
1718
+ * Check that `sessionPath` belongs to the given workspace's session list.
1719
+ * Returns an error string if validation fails, or `null` if the path is
1720
+ * owned by this workspace.
1721
+ */
1722
+ async validateSessionOwnership(workspaceId, sessionPath) {
1723
+ if (!isAbsolute2(sessionPath)) return "session path must be absolute";
1724
+ const ws = await getWorkspace(workspaceId);
1725
+ if (!ws) return `workspace not found: ${workspaceId}`;
1726
+ const sessions = await SessionManager.list(ws.path);
1727
+ const resolved = resolve2(sessionPath);
1728
+ const found = sessions.some((s) => resolve2(s.path) === resolved);
1729
+ if (!found) return `session not found in workspace: ${sessionPath}`;
1730
+ return null;
1298
1731
  }
1299
1732
  async listSessions(workspaceId) {
1300
1733
  const ws = await getWorkspace(workspaceId);
1301
1734
  if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
1735
+ const streaming = /* @__PURE__ */ new Set();
1736
+ for (const state of this.runtimes.values()) {
1737
+ if (state.workspaceId !== workspaceId) continue;
1738
+ if (state.sessionPath && state.runtime.session.isStreaming) {
1739
+ streaming.add(state.sessionPath);
1740
+ }
1741
+ }
1302
1742
  const sessions = await SessionManager.list(ws.path);
1303
- return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map(toSessionSummary);
1743
+ return sessions.slice().sort((a, b) => b.modified.getTime() - a.modified.getTime()).map((info) => toSessionSummary(info, streaming.has(resolve2(info.path))));
1304
1744
  }
1305
1745
  getSessionHistory(workspaceId, sessionPath) {
1306
- const runtime = this.states.get(workspaceId)?.runtime;
1746
+ const runtime = sessionPath ? this.getForSession(workspaceId, sessionPath) : this.get(workspaceId);
1307
1747
  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
1748
  const isStreaming = runtime.session.isStreaming ?? false;
1315
1749
  const branch = runtime.session.sessionManager.getBranch();
1316
1750
  const items = [];
@@ -1321,7 +1755,7 @@ ${err.stack}` : "")
1321
1755
  const role = msg.role;
1322
1756
  if (role === "user") {
1323
1757
  const text = extractUserText2(msg);
1324
- if (text) items.push({ kind: "user", text });
1758
+ if (text) items.push({ kind: "user", text, entryId: entry.id });
1325
1759
  } else if (role === "assistant") {
1326
1760
  const { text, thinking, toolCalls } = extractAssistantContent(
1327
1761
  msg
@@ -1359,15 +1793,13 @@ ${err.stack}` : "")
1359
1793
  /**
1360
1794
  * Delete a session JSONL file belonging to this workspace.
1361
1795
  *
1362
- * Errors are tagged with HTTP semantics via HttpError so the route layer
1363
- * can map them to the right status code:
1796
+ * HTTP-tagged errors (HttpError) map to status codes at the route layer:
1364
1797
  * - 400: sessionPath not absolute
1365
1798
  * - 404: workspace gone, or session not in this workspace's list
1366
- * - 409: file is the currently-active session (caller must switch first)
1799
+ * - 409: a live runtime is bound to it and is streaming (stop it first)
1367
1800
  *
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.
1801
+ * If a live but idle runtime is bound to the session, it is disposed before
1802
+ * the file is unlinked. Idempotent on ENOENT.
1371
1803
  */
1372
1804
  async deleteSession(workspaceId, sessionPath) {
1373
1805
  const ws = await getWorkspace(workspaceId);
@@ -1381,69 +1813,89 @@ ${err.stack}` : "")
1381
1813
  if (!target) {
1382
1814
  throw new HttpError(404, `Session not found: ${sessionPath}`);
1383
1815
  }
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
- );
1816
+ const key = this.keyOf(workspaceId, resolved);
1817
+ const live = this.runtimes.get(key);
1818
+ if (live) {
1819
+ if (live.runtime.session.isStreaming) {
1820
+ throw new HttpError(
1821
+ 409,
1822
+ "Cannot delete a streaming session \u2014 stop it first"
1823
+ );
1824
+ }
1825
+ await this.disposeState(live, key);
1391
1826
  }
1392
1827
  try {
1393
1828
  await unlink2(resolved);
1394
- } catch (err) {
1395
- if (err?.code === "ENOENT") {
1829
+ } catch (err2) {
1830
+ if (err2?.code === "ENOENT") {
1396
1831
  console.warn(
1397
1832
  `[wm] deleteSession: ${resolved} was already gone at unlink time`
1398
1833
  );
1399
1834
  return;
1400
1835
  }
1401
- throw err;
1836
+ throw err2;
1402
1837
  }
1403
1838
  }
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");
1421
- }
1422
- const result = await runtime.switchSession(resolved, { cwdOverride: ws.path });
1423
- return !result.cancelled;
1424
- }
1839
+ /** Dispose every runtime for a workspace (e.g. when it's removed). */
1425
1840
  async dispose(workspaceId) {
1426
- const state = this.states.get(workspaceId);
1427
- if (!state) return;
1428
- this.states.delete(workspaceId);
1429
- this.rebindListeners.delete(workspaceId);
1841
+ this.activeByWorkspace.delete(workspaceId);
1430
1842
  this.subscribers.delete(workspaceId);
1843
+ const doomed = [...this.runtimes].filter(([, s]) => s.workspaceId === workspaceId);
1844
+ for (const [key, state] of doomed) {
1845
+ await this.disposeState(state, key);
1846
+ }
1847
+ }
1848
+ async disposeAll() {
1849
+ await Promise.allSettled([...this.pending.values()]);
1850
+ const states = [...this.runtimes.entries()];
1851
+ this.runtimes.clear();
1852
+ this.activeByWorkspace.clear();
1853
+ this.subscribers.clear();
1854
+ await Promise.all(states.map(([, state]) => this.disposeState(state)));
1855
+ }
1856
+ /** Tear down a single runtime + its bridge, releasing any ask_user Promises
1857
+ * bound to it first. Removes it from `runtimes` when `key` is given. */
1858
+ async disposeState(state, key) {
1859
+ if (key) this.runtimes.delete(key);
1860
+ if (key && this.activeByWorkspace.get(state.workspaceId) === key) {
1861
+ this.activeByWorkspace.delete(state.workspaceId);
1862
+ }
1431
1863
  cancelPendingForSession(state.runtime.session.sessionFile ?? null);
1432
1864
  try {
1433
1865
  state.bridge.dispose();
1434
1866
  } catch (e) {
1435
- console.error(`[wm] dispose bridge ${workspaceId} failed:`, e);
1867
+ console.error(`[wm] dispose bridge ${state.workspaceId} failed:`, e);
1436
1868
  }
1437
1869
  try {
1438
- state.runtime.session.dispose();
1870
+ await state.runtime.dispose();
1439
1871
  } catch (e) {
1440
- console.error(`[wm] dispose ${workspaceId} failed:`, e);
1872
+ console.error(`[wm] dispose ${state.workspaceId} failed:`, e);
1441
1873
  }
1442
1874
  }
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)));
1875
+ /**
1876
+ * Keep the live-runtime count under `MAX_LIVE_RUNTIMES` by disposing the
1877
+ * least-recently-touched IDLE runtime (never streaming, never the freshly-
1878
+ * registered one, never a workspace's active pointer). Best-effort: if every
1879
+ * runtime is busy we simply exceed the cap until one frees up.
1880
+ */
1881
+ evictIfOverCap(justRegistered) {
1882
+ while (this.runtimes.size > MAX_LIVE_RUNTIMES) {
1883
+ let victimKey;
1884
+ let victimTouched = Infinity;
1885
+ for (const [key, state] of this.runtimes) {
1886
+ if (key === justRegistered) continue;
1887
+ if (this.activeByWorkspace.get(state.workspaceId) === key) continue;
1888
+ if (state.runtime.session.isStreaming) continue;
1889
+ if (state.touchedAt < victimTouched) {
1890
+ victimTouched = state.touchedAt;
1891
+ victimKey = key;
1892
+ }
1893
+ }
1894
+ if (!victimKey) break;
1895
+ const victim = this.runtimes.get(victimKey);
1896
+ if (!victim) break;
1897
+ void this.disposeState(victim, victimKey);
1898
+ }
1447
1899
  }
1448
1900
  };
1449
1901
  function safeReconcileAskUser(workspaceId, sm) {
@@ -1453,13 +1905,14 @@ function safeReconcileAskUser(workspaceId, sm) {
1453
1905
  console.error(`[wm] ask_user cleanup for ${workspaceId} failed:`, e);
1454
1906
  }
1455
1907
  }
1456
- function toSessionSummary(info) {
1908
+ function toSessionSummary(info, running) {
1457
1909
  const preview = info.firstMessage.replace(/\s+/g, " ").trim();
1458
1910
  return {
1459
1911
  path: info.path,
1460
1912
  name: info.name,
1461
1913
  updatedAt: info.modified.toISOString(),
1462
- preview: preview ? preview.slice(0, 160) : void 0
1914
+ preview: preview ? preview.slice(0, 160) : void 0,
1915
+ ...running ? { running: true } : {}
1463
1916
  };
1464
1917
  }
1465
1918
  function extractUserText2(msg) {
@@ -1495,7 +1948,7 @@ function extractContentText(content) {
1495
1948
  }
1496
1949
  return parts.join("");
1497
1950
  }
1498
- var workspaceManager = new WorkspaceManager();
1951
+ var workspaceManager = new SessionRuntimeManager();
1499
1952
  function broadcastTo(subscribers, msg) {
1500
1953
  const wire = JSON.stringify(msg);
1501
1954
  for (const ws of subscribers) {
@@ -1578,8 +2031,8 @@ function mountConfigRoutes(app2) {
1578
2031
  try {
1579
2032
  await workspaceManager.getOrCreate(id);
1580
2033
  return c.json(buildConfigResponse(id));
1581
- } catch (err) {
1582
- const message = err instanceof Error ? err.message : String(err);
2034
+ } catch (err2) {
2035
+ const message = err2 instanceof Error ? err2.message : String(err2);
1583
2036
  return c.json({ ok: false, error: message }, 500);
1584
2037
  }
1585
2038
  });
@@ -1605,8 +2058,8 @@ function mountConfigRoutes(app2) {
1605
2058
  await runtime.session.setModel(model);
1606
2059
  broadcastContextUsage(id, runtime);
1607
2060
  return c.json(buildConfigResponse(id));
1608
- } catch (err) {
1609
- const message = err instanceof Error ? err.message : String(err);
2061
+ } catch (err2) {
2062
+ const message = err2 instanceof Error ? err2.message : String(err2);
1610
2063
  return c.json({ ok: false, error: message }, 500);
1611
2064
  }
1612
2065
  });
@@ -1630,8 +2083,8 @@ function mountConfigRoutes(app2) {
1630
2083
  }
1631
2084
  runtime.session.setThinkingLevel(body.level);
1632
2085
  return c.json(buildConfigResponse(id));
1633
- } catch (err) {
1634
- const message = err instanceof Error ? err.message : String(err);
2086
+ } catch (err2) {
2087
+ const message = err2 instanceof Error ? err2.message : String(err2);
1635
2088
  return c.json({ ok: false, error: message }, 500);
1636
2089
  }
1637
2090
  });
@@ -1654,8 +2107,37 @@ function mountConfigRoutes(app2) {
1654
2107
  }
1655
2108
  runtime.session.setActiveToolsByName(body.tools);
1656
2109
  return c.json(buildConfigResponse(id));
1657
- } catch (err) {
1658
- const message = err instanceof Error ? err.message : String(err);
2110
+ } catch (err2) {
2111
+ const message = err2 instanceof Error ? err2.message : String(err2);
2112
+ return c.json({ ok: false, error: message }, 500);
2113
+ }
2114
+ });
2115
+ app2.put("/:id/config/session-name", async (c) => {
2116
+ const id = c.req.param("id");
2117
+ const exists2 = await requireWorkspace(c, id);
2118
+ if (!exists2) return c.json({ ok: false, error: "not found" }, 404);
2119
+ const body = await c.req.json();
2120
+ if (typeof body?.name !== "string") {
2121
+ return c.json({ ok: false, error: "name is required" }, 400);
2122
+ }
2123
+ const trimmed = body.name.trim();
2124
+ if (!trimmed) {
2125
+ return c.json({ ok: false, error: "name must not be empty" }, 400);
2126
+ }
2127
+ try {
2128
+ const sessionPath = c.req.query("sessionPath");
2129
+ if (sessionPath) {
2130
+ const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
2131
+ if (err2) return c.json({ ok: false, error: err2 }, 404);
2132
+ }
2133
+ const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
2134
+ if (runtime.session.isStreaming) {
2135
+ return c.json({ ok: false, error: "cannot rename while the agent is streaming" }, 409);
2136
+ }
2137
+ runtime.session.setSessionName(trimmed);
2138
+ return c.json({ name: trimmed });
2139
+ } catch (err2) {
2140
+ const message = err2 instanceof Error ? err2.message : String(err2);
1659
2141
  return c.json({ ok: false, error: message }, 500);
1660
2142
  }
1661
2143
  });
@@ -1817,9 +2299,9 @@ function mountFilesRoute(app2) {
1817
2299
  }
1818
2300
  const body = { workspacePath, entries, truncated };
1819
2301
  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);
2302
+ } catch (err2) {
2303
+ const message = err2 instanceof Error ? err2.message : String(err2);
2304
+ console.error(`[api/files] search for ${id} failed:`, err2);
1823
2305
  return c.json({ ok: false, error: message }, 500);
1824
2306
  }
1825
2307
  });
@@ -1901,9 +2383,9 @@ async function snapshot(workspaceId, roots, workspaceCwd) {
1901
2383
  shortcuts: [...e.shortcuts.keys()]
1902
2384
  };
1903
2385
  });
1904
- const extensionErrors = extResult.errors.map((err) => ({
1905
- path: err.path,
1906
- error: err.error
2386
+ const extensionErrors = extResult.errors.map((err2) => ({
2387
+ path: err2.path,
2388
+ error: err2.error
1907
2389
  }));
1908
2390
  const disabledExtensions = EXTENSIONS_ENABLED ? [] : await scanExtensionDirs(workspaceCwd);
1909
2391
  const disabledBuiltins = new Set(getDisabledBuiltins());
@@ -1931,12 +2413,12 @@ async function rootsFor(workspaceId) {
1931
2413
  const roots = resolveResourceRoots({ agentDir: getAgentDir2(), workspaceCwd: ws.path });
1932
2414
  return { roots, workspaceCwd: ws.path };
1933
2415
  }
1934
- function respondError(c, err) {
1935
- if (err instanceof HttpError) {
1936
- return c.json({ ok: false, error: err.message }, err.status);
2416
+ function respondError(c, err2) {
2417
+ if (err2 instanceof HttpError) {
2418
+ return c.json({ ok: false, error: err2.message }, err2.status);
1937
2419
  }
1938
- const message = err instanceof Error ? err.message : String(err);
1939
- console.error(`[api/resources] unexpected error:`, err);
2420
+ const message = err2 instanceof Error ? err2.message : String(err2);
2421
+ console.error(`[api/resources] unexpected error:`, err2);
1940
2422
  return c.json({ ok: false, error: message }, 500);
1941
2423
  }
1942
2424
  async function reload(workspaceId) {
@@ -1953,8 +2435,8 @@ function mountResourcesRoute(app2) {
1953
2435
  await workspaceManager.getOrCreate(id);
1954
2436
  const { roots, workspaceCwd } = await rootsFor(id);
1955
2437
  return c.json(await snapshot(id, roots, workspaceCwd));
1956
- } catch (err) {
1957
- return respondError(c, err);
2438
+ } catch (err2) {
2439
+ return respondError(c, err2);
1958
2440
  }
1959
2441
  });
1960
2442
  app2.post("/:id/resources/reload", async (c) => {
@@ -1966,8 +2448,8 @@ function mountResourcesRoute(app2) {
1966
2448
  await reload(id);
1967
2449
  const { roots, workspaceCwd } = await rootsFor(id);
1968
2450
  return c.json(await snapshot(id, roots, workspaceCwd));
1969
- } catch (err) {
1970
- return respondError(c, err);
2451
+ } catch (err2) {
2452
+ return respondError(c, err2);
1971
2453
  }
1972
2454
  });
1973
2455
  app2.get("/:id/resources/skill", async (c) => {
@@ -1992,8 +2474,8 @@ function mountResourcesRoute(app2) {
1992
2474
  body: data.body
1993
2475
  };
1994
2476
  return c.json(body);
1995
- } catch (err) {
1996
- return respondError(c, err);
2477
+ } catch (err2) {
2478
+ return respondError(c, err2);
1997
2479
  }
1998
2480
  });
1999
2481
  app2.post("/:id/resources/skills", async (c) => {
@@ -2017,8 +2499,8 @@ function mountResourcesRoute(app2) {
2017
2499
  });
2018
2500
  await reload(id);
2019
2501
  return c.json(await snapshot(id, roots, workspaceCwd));
2020
- } catch (err) {
2021
- return respondError(c, err);
2502
+ } catch (err2) {
2503
+ return respondError(c, err2);
2022
2504
  }
2023
2505
  });
2024
2506
  app2.put("/:id/resources/skills", async (c) => {
@@ -2042,8 +2524,8 @@ function mountResourcesRoute(app2) {
2042
2524
  });
2043
2525
  await reload(id);
2044
2526
  return c.json(await snapshot(id, roots, workspaceCwd));
2045
- } catch (err) {
2046
- return respondError(c, err);
2527
+ } catch (err2) {
2528
+ return respondError(c, err2);
2047
2529
  }
2048
2530
  });
2049
2531
  app2.delete("/:id/resources/skills", async (c) => {
@@ -2058,8 +2540,8 @@ function mountResourcesRoute(app2) {
2058
2540
  await deleteSkill(filePath, roots);
2059
2541
  await reload(id);
2060
2542
  return c.json(await snapshot(id, roots, workspaceCwd));
2061
- } catch (err) {
2062
- return respondError(c, err);
2543
+ } catch (err2) {
2544
+ return respondError(c, err2);
2063
2545
  }
2064
2546
  });
2065
2547
  app2.get("/:id/resources/prompt", async (c) => {
@@ -2084,8 +2566,8 @@ function mountResourcesRoute(app2) {
2084
2566
  body: data.body
2085
2567
  };
2086
2568
  return c.json(body);
2087
- } catch (err) {
2088
- return respondError(c, err);
2569
+ } catch (err2) {
2570
+ return respondError(c, err2);
2089
2571
  }
2090
2572
  });
2091
2573
  app2.post("/:id/resources/prompts", async (c) => {
@@ -2109,8 +2591,8 @@ function mountResourcesRoute(app2) {
2109
2591
  });
2110
2592
  await reload(id);
2111
2593
  return c.json(await snapshot(id, roots, workspaceCwd));
2112
- } catch (err) {
2113
- return respondError(c, err);
2594
+ } catch (err2) {
2595
+ return respondError(c, err2);
2114
2596
  }
2115
2597
  });
2116
2598
  app2.put("/:id/resources/prompts", async (c) => {
@@ -2134,8 +2616,8 @@ function mountResourcesRoute(app2) {
2134
2616
  });
2135
2617
  await reload(id);
2136
2618
  return c.json(await snapshot(id, roots, workspaceCwd));
2137
- } catch (err) {
2138
- return respondError(c, err);
2619
+ } catch (err2) {
2620
+ return respondError(c, err2);
2139
2621
  }
2140
2622
  });
2141
2623
  app2.delete("/:id/resources/prompts", async (c) => {
@@ -2150,8 +2632,8 @@ function mountResourcesRoute(app2) {
2150
2632
  await deletePrompt(filePath, roots);
2151
2633
  await reload(id);
2152
2634
  return c.json(await snapshot(id, roots, workspaceCwd));
2153
- } catch (err) {
2154
- return respondError(c, err);
2635
+ } catch (err2) {
2636
+ return respondError(c, err2);
2155
2637
  }
2156
2638
  });
2157
2639
  app2.put("/:id/resources/builtin-extensions", async (c) => {
@@ -2177,8 +2659,8 @@ function mountResourcesRoute(app2) {
2177
2659
  await runtime.session.reload();
2178
2660
  const { roots, workspaceCwd } = await rootsFor(id);
2179
2661
  return c.json(await snapshot(id, roots, workspaceCwd));
2180
- } catch (err) {
2181
- return respondError(c, err);
2662
+ } catch (err2) {
2663
+ return respondError(c, err2);
2182
2664
  }
2183
2665
  });
2184
2666
  }
@@ -2186,14 +2668,182 @@ function isScope(value) {
2186
2668
  return value === "user" || value === "project";
2187
2669
  }
2188
2670
 
2671
+ // src/api/tree.ts
2672
+ import { Hono } from "hono";
2673
+ var treeRoute = new Hono();
2674
+ treeRoute.get("/", async (c) => {
2675
+ const id = c.req.param("id") ?? "";
2676
+ const existed = await getWorkspace(id);
2677
+ if (!existed) return c.json({ ok: false, error: "not found" }, 404);
2678
+ try {
2679
+ const runtime = await workspaceManager.getOrCreate(id);
2680
+ const sm = runtime.session.sessionManager;
2681
+ const tree = sm.getTree();
2682
+ const leafId = sm.getLeafId();
2683
+ const activeIds = /* @__PURE__ */ new Set();
2684
+ if (leafId) {
2685
+ const branch = sm.getBranch(leafId);
2686
+ for (const entry of branch) {
2687
+ activeIds.add(entry.id);
2688
+ }
2689
+ }
2690
+ const nodes = [];
2691
+ flattenTree(tree, leafId, activeIds, nodes, 0);
2692
+ const body = { nodes, leafId };
2693
+ return c.json(body);
2694
+ } catch (err2) {
2695
+ const message = err2 instanceof Error ? err2.message : String(err2);
2696
+ console.error(`[api] tree for ${id} failed:`, err2);
2697
+ return c.json({ ok: false, error: message }, 500);
2698
+ }
2699
+ });
2700
+ function flattenTree(nodes, leafId, activeIds, out, depth) {
2701
+ const sorted = [...nodes].sort(
2702
+ (a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
2703
+ );
2704
+ for (const node of sorted) {
2705
+ const entry = node.entry;
2706
+ const id = entry.id;
2707
+ out.push({
2708
+ id,
2709
+ parentId: entry.parentId,
2710
+ depth,
2711
+ entryType: mapEntryType(entry.type),
2712
+ messageRole: entry.type === "message" ? mapMessageRole(entry["message"]) : void 0,
2713
+ timestamp: entry.timestamp,
2714
+ preview: extractPreview(entry),
2715
+ active: activeIds.has(id),
2716
+ isLeaf: id === leafId,
2717
+ childCount: node.children.length,
2718
+ label: node.label
2719
+ });
2720
+ if (node.children.length > 0) {
2721
+ flattenTree(node.children, leafId, activeIds, out, depth + 1);
2722
+ }
2723
+ }
2724
+ }
2725
+ function mapEntryType(type) {
2726
+ switch (type) {
2727
+ case "message":
2728
+ return "message";
2729
+ case "compaction":
2730
+ return "compaction";
2731
+ case "branch_summary":
2732
+ return "branch_summary";
2733
+ case "model_change":
2734
+ return "model_change";
2735
+ case "thinking_level_change":
2736
+ return "thinking_level_change";
2737
+ case "custom":
2738
+ return "custom";
2739
+ case "custom_message":
2740
+ return "custom_message";
2741
+ case "label":
2742
+ return "label";
2743
+ case "session_info":
2744
+ return "session_info";
2745
+ default:
2746
+ console.warn(`[tree] unknown entry type "${type}" \u2014 mapping to "custom". pi SDK may have added a type tree.ts doesn't handle yet.`);
2747
+ return "custom";
2748
+ }
2749
+ }
2750
+ function mapMessageRole(msg) {
2751
+ if (!msg || typeof msg !== "object") return void 0;
2752
+ const role = msg.role;
2753
+ switch (role) {
2754
+ case "user":
2755
+ return "user";
2756
+ case "assistant":
2757
+ return "assistant";
2758
+ case "toolResult":
2759
+ return "toolResult";
2760
+ case "bashExecution":
2761
+ return "bashExecution";
2762
+ case "custom":
2763
+ return "custom";
2764
+ case "branchSummary":
2765
+ return "branchSummary";
2766
+ case "compactionSummary":
2767
+ return "compactionSummary";
2768
+ default:
2769
+ return void 0;
2770
+ }
2771
+ }
2772
+ var PREVIEW_MAX = 120;
2773
+ function extractPreview(entry) {
2774
+ switch (entry.type) {
2775
+ case "message":
2776
+ return extractMessagePreview(entry["message"]);
2777
+ case "compaction":
2778
+ return truncate(String(entry["summary"] ?? "Compaction"), PREVIEW_MAX);
2779
+ case "branch_summary":
2780
+ return truncate(String(entry["summary"] ?? "Branch summary"), PREVIEW_MAX);
2781
+ case "model_change":
2782
+ return `${entry["provider"] ?? ""}/${entry["modelId"] ?? ""}`;
2783
+ case "thinking_level_change":
2784
+ return `Thinking: ${entry["thinkingLevel"] ?? ""}`;
2785
+ case "session_info":
2786
+ return entry["name"] ? `Name: ${entry["name"]}` : "Session info";
2787
+ case "custom_message":
2788
+ return truncate(extractContentText2(entry["content"]), PREVIEW_MAX) || "Extension message";
2789
+ case "custom":
2790
+ return `Custom: ${entry["customType"] ?? ""}`;
2791
+ case "label":
2792
+ return "Label";
2793
+ default:
2794
+ return "";
2795
+ }
2796
+ }
2797
+ function extractMessagePreview(msg) {
2798
+ if (!msg || typeof msg !== "object") return "";
2799
+ const m = msg;
2800
+ if (m.role === "bashExecution") {
2801
+ return truncate(`$ ${m.command ?? ""}`, PREVIEW_MAX);
2802
+ }
2803
+ return truncate(extractContentText2(m.content), PREVIEW_MAX);
2804
+ }
2805
+ function extractContentText2(content) {
2806
+ if (typeof content === "string") return content.replace(/\s+/g, " ").trim();
2807
+ if (!Array.isArray(content)) return "";
2808
+ const parts = [];
2809
+ for (const block of content) {
2810
+ if (!block || typeof block !== "object") continue;
2811
+ const b = block;
2812
+ if (b.type === "text" && typeof b.text === "string") {
2813
+ parts.push(b.text);
2814
+ } else if (b.type === "thinking" && typeof b.thinking === "string") {
2815
+ parts.push(b.thinking);
2816
+ }
2817
+ }
2818
+ return parts.join(" ").replace(/\s+/g, " ").trim();
2819
+ }
2820
+ function truncate(text, max) {
2821
+ if (text.length <= max) return text;
2822
+ return text.slice(0, max) + "\u2026";
2823
+ }
2824
+
2189
2825
  // src/api/workspaces.ts
2190
- var workspacesRoute = new Hono();
2826
+ var workspacesRoute = new Hono2();
2191
2827
  workspacesRoute.get("/", async (c) => {
2192
2828
  const raw = await listWorkspaces();
2193
2829
  const workspaces = await Promise.all(raw.map(enrichWorkspace));
2194
2830
  const body = { workspaces };
2195
2831
  return c.json(body);
2196
2832
  });
2833
+ workspacesRoute.put("/reorder", async (c) => {
2834
+ const body = await c.req.json();
2835
+ if (!body?.ids || !Array.isArray(body.ids) || body.ids.length === 0 || !body.ids.every((id) => typeof id === "string")) {
2836
+ return c.json(
2837
+ { ok: false, error: "ids must be a non-empty array of strings" },
2838
+ 400
2839
+ );
2840
+ }
2841
+ await reorderWorkspaces(body.ids);
2842
+ const raw = await listWorkspaces();
2843
+ const workspaces = await Promise.all(raw.map(enrichWorkspace));
2844
+ const resBody = { workspaces };
2845
+ return c.json(resBody);
2846
+ });
2197
2847
  workspacesRoute.get("/:id/sessions", async (c) => {
2198
2848
  const id = c.req.param("id");
2199
2849
  const existed = await getWorkspace(id);
@@ -2202,9 +2852,9 @@ workspacesRoute.get("/:id/sessions", async (c) => {
2202
2852
  const sessions = await workspaceManager.listSessions(id);
2203
2853
  const body = { sessions };
2204
2854
  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);
2855
+ } catch (err2) {
2856
+ const message = err2 instanceof Error ? err2.message : String(err2);
2857
+ console.error(`[api] list sessions for ${id} failed:`, err2);
2208
2858
  return c.json({ ok: false, error: message }, 500);
2209
2859
  }
2210
2860
  });
@@ -2220,15 +2870,15 @@ workspacesRoute.delete("/:id/sessions", async (c) => {
2220
2870
  await workspaceManager.deleteSession(id, sessionPath);
2221
2871
  const body = { ok: true };
2222
2872
  return c.json(body);
2223
- } catch (err) {
2224
- if (err instanceof HttpError) {
2873
+ } catch (err2) {
2874
+ if (err2 instanceof HttpError) {
2225
2875
  return c.json(
2226
- { ok: false, error: err.message },
2227
- err.status
2876
+ { ok: false, error: err2.message },
2877
+ err2.status
2228
2878
  );
2229
2879
  }
2230
- const message = err instanceof Error ? err.message : String(err);
2231
- console.error(`[api] delete session for ${id} failed:`, err);
2880
+ const message = err2 instanceof Error ? err2.message : String(err2);
2881
+ console.error(`[api] delete session for ${id} failed:`, err2);
2232
2882
  return c.json({ ok: false, error: message }, 500);
2233
2883
  }
2234
2884
  });
@@ -2245,9 +2895,31 @@ workspacesRoute.get("/:id/fork-points", async (c) => {
2245
2895
  }));
2246
2896
  const body = { points };
2247
2897
  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);
2898
+ } catch (err2) {
2899
+ const message = err2 instanceof Error ? err2.message : String(err2);
2900
+ console.error(`[api] fork-points for ${id} failed:`, err2);
2901
+ return c.json({ ok: false, error: message }, 500);
2902
+ }
2903
+ });
2904
+ workspacesRoute.get("/:id/export", async (c) => {
2905
+ const id = c.req.param("id");
2906
+ const existed = await getWorkspace(id);
2907
+ if (!existed) return c.json({ ok: false, error: "not found" }, 404);
2908
+ try {
2909
+ const sessionPath = c.req.query("sessionPath");
2910
+ if (sessionPath) {
2911
+ const err2 = await workspaceManager.validateSessionOwnership(id, sessionPath);
2912
+ if (err2) return c.json({ ok: false, error: err2 }, 404);
2913
+ }
2914
+ const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
2915
+ const outputPath = await runtime.session.exportToHtml();
2916
+ const html = await readFile4(outputPath, "utf-8");
2917
+ const filename = basename2(outputPath);
2918
+ const body = { html, filename };
2919
+ return c.json(body);
2920
+ } catch (err2) {
2921
+ const message = err2 instanceof Error ? err2.message : String(err2);
2922
+ console.error(`[api] export for ${id} failed:`, err2);
2251
2923
  return c.json({ ok: false, error: message }, 500);
2252
2924
  }
2253
2925
  });
@@ -2260,9 +2932,9 @@ workspacesRoute.get("/:id/history", async (c) => {
2260
2932
  const sessionPath = c.req.query("sessionPath");
2261
2933
  const body = workspaceManager.getSessionHistory(id, sessionPath);
2262
2934
  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);
2935
+ } catch (err2) {
2936
+ const message = err2 instanceof Error ? err2.message : String(err2);
2937
+ console.error(`[api] history for ${id} failed:`, err2);
2266
2938
  return c.json({ ok: false, error: message }, 500);
2267
2939
  }
2268
2940
  });
@@ -2303,13 +2975,14 @@ workspacesRoute.delete("/:id", async (c) => {
2303
2975
  mountConfigRoutes(workspacesRoute);
2304
2976
  mountResourcesRoute(workspacesRoute);
2305
2977
  mountFilesRoute(workspacesRoute);
2978
+ workspacesRoute.route("/:id/tree", treeRoute);
2306
2979
 
2307
2980
  // src/api/fs.ts
2308
2981
  import { readdir as readdir3 } from "fs/promises";
2309
2982
  import { homedir as homedir2 } from "os";
2310
2983
  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();
2984
+ import { Hono as Hono3 } from "hono";
2985
+ var fsRoute = new Hono3();
2313
2986
  fsRoute.get("/browse", async (c) => {
2314
2987
  const rawPath = c.req.query("path");
2315
2988
  const showHidden = c.req.query("showHidden") === "1";
@@ -2317,8 +2990,8 @@ fsRoute.get("/browse", async (c) => {
2317
2990
  let dirents;
2318
2991
  try {
2319
2992
  dirents = await readdir3(target, { withFileTypes: true });
2320
- } catch (err) {
2321
- const code = err.code;
2993
+ } catch (err2) {
2994
+ const code = err2.code;
2322
2995
  const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
2323
2996
  return c.json({ ok: false, error: msg, path: target }, 400);
2324
2997
  }
@@ -2336,13 +3009,13 @@ fsRoute.get("/browse", async (c) => {
2336
3009
  });
2337
3010
 
2338
3011
  // src/api/model-configs.ts
2339
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
3012
+ import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
2340
3013
  import { dirname as dirname5, join as join8 } from "path";
2341
- import { Hono as Hono3 } from "hono";
3014
+ import { Hono as Hono4 } from "hono";
2342
3015
  import {
2343
3016
  getAgentDir as getAgentDir3
2344
3017
  } from "@earendil-works/pi-coding-agent";
2345
- var modelConfigsRoute = new Hono3();
3018
+ var modelConfigsRoute = new Hono4();
2346
3019
  var writeLock = Promise.resolve();
2347
3020
  function withWriteLock(fn) {
2348
3021
  const next = writeLock.then(fn, fn);
@@ -2356,13 +3029,13 @@ function modelsPath() {
2356
3029
  }
2357
3030
  async function readModelsJson() {
2358
3031
  try {
2359
- const raw = await readFile4(modelsPath(), "utf-8");
3032
+ const raw = await readFile5(modelsPath(), "utf-8");
2360
3033
  return JSON.parse(raw);
2361
- } catch (err) {
2362
- if (err?.code === "ENOENT") {
3034
+ } catch (err2) {
3035
+ if (err2?.code === "ENOENT") {
2363
3036
  return { providers: {} };
2364
3037
  }
2365
- throw err;
3038
+ throw err2;
2366
3039
  }
2367
3040
  }
2368
3041
  async function writeModelsJson(config2) {
@@ -2393,8 +3066,8 @@ modelConfigsRoute.get("/", async (c) => {
2393
3066
  const config2 = await readModelsJson();
2394
3067
  const body = { config: config2 };
2395
3068
  return c.json(body);
2396
- } catch (err) {
2397
- const message = err instanceof Error ? err.message : String(err);
3069
+ } catch (err2) {
3070
+ const message = err2 instanceof Error ? err2.message : String(err2);
2398
3071
  return c.json({ ok: false, error: message }, 500);
2399
3072
  }
2400
3073
  });
@@ -2411,8 +3084,8 @@ modelConfigsRoute.put("/", async (c) => {
2411
3084
  refreshRegistry(workspaceId ?? void 0);
2412
3085
  const resp = { config: body.config };
2413
3086
  return c.json(resp);
2414
- } catch (err) {
2415
- const message = err instanceof Error ? err.message : String(err);
3087
+ } catch (err2) {
3088
+ const message = err2 instanceof Error ? err2.message : String(err2);
2416
3089
  return c.json({ ok: false, error: message }, 500);
2417
3090
  }
2418
3091
  });
@@ -2438,8 +3111,8 @@ modelConfigsRoute.post("/providers", async (c) => {
2438
3111
  refreshRegistry(workspaceId ?? void 0);
2439
3112
  const resp = { config: config2 };
2440
3113
  return c.json(resp);
2441
- } catch (err) {
2442
- const message = err instanceof Error ? err.message : String(err);
3114
+ } catch (err2) {
3115
+ const message = err2 instanceof Error ? err2.message : String(err2);
2443
3116
  return c.json({ ok: false, error: message }, 500);
2444
3117
  }
2445
3118
  });
@@ -2462,11 +3135,11 @@ modelConfigsRoute.delete("/providers", async (c) => {
2462
3135
  refreshRegistry(workspaceId ?? void 0);
2463
3136
  const resp = { config: config2 };
2464
3137
  return c.json(resp);
2465
- } catch (err) {
2466
- if (err instanceof ValidationError) {
2467
- return c.json({ ok: false, error: err.message }, err.status);
3138
+ } catch (err2) {
3139
+ if (err2 instanceof ValidationError) {
3140
+ return c.json({ ok: false, error: err2.message }, err2.status);
2468
3141
  }
2469
- const message = err instanceof Error ? err.message : String(err);
3142
+ const message = err2 instanceof Error ? err2.message : String(err2);
2470
3143
  return c.json({ ok: false, error: message }, 500);
2471
3144
  }
2472
3145
  });
@@ -2494,11 +3167,11 @@ modelConfigsRoute.post("/providers/:provider/models", async (c) => {
2494
3167
  refreshRegistry(workspaceId ?? void 0);
2495
3168
  const resp = { config: config2 };
2496
3169
  return c.json(resp);
2497
- } catch (err) {
2498
- if (err instanceof ValidationError) {
2499
- return c.json({ ok: false, error: err.message }, err.status);
3170
+ } catch (err2) {
3171
+ if (err2 instanceof ValidationError) {
3172
+ return c.json({ ok: false, error: err2.message }, err2.status);
2500
3173
  }
2501
- const message = err instanceof Error ? err.message : String(err);
3174
+ const message = err2 instanceof Error ? err2.message : String(err2);
2502
3175
  return c.json({ ok: false, error: message }, 500);
2503
3176
  }
2504
3177
  });
@@ -2530,11 +3203,11 @@ modelConfigsRoute.put("/providers/:provider/models/:modelId", async (c) => {
2530
3203
  refreshRegistry(workspaceId ?? void 0);
2531
3204
  const resp = { config: config2 };
2532
3205
  return c.json(resp);
2533
- } catch (err) {
2534
- if (err instanceof ValidationError) {
2535
- return c.json({ ok: false, error: err.message }, err.status);
3206
+ } catch (err2) {
3207
+ if (err2 instanceof ValidationError) {
3208
+ return c.json({ ok: false, error: err2.message }, err2.status);
2536
3209
  }
2537
- const message = err instanceof Error ? err.message : String(err);
3210
+ const message = err2 instanceof Error ? err2.message : String(err2);
2538
3211
  return c.json({ ok: false, error: message }, 500);
2539
3212
  }
2540
3213
  });
@@ -2559,17 +3232,18 @@ modelConfigsRoute.delete("/providers/:provider/models/:modelId", async (c) => {
2559
3232
  refreshRegistry(workspaceId ?? void 0);
2560
3233
  const resp = { config: config2 };
2561
3234
  return c.json(resp);
2562
- } catch (err) {
2563
- if (err instanceof ValidationError) {
2564
- return c.json({ ok: false, error: err.message }, err.status);
3235
+ } catch (err2) {
3236
+ if (err2 instanceof ValidationError) {
3237
+ return c.json({ ok: false, error: err2.message }, err2.status);
2565
3238
  }
2566
- const message = err instanceof Error ? err.message : String(err);
3239
+ const message = err2 instanceof Error ? err2.message : String(err2);
2567
3240
  return c.json({ ok: false, error: message }, 500);
2568
3241
  }
2569
3242
  });
2570
3243
 
2571
3244
  // src/ws/hub.ts
2572
3245
  import { WebSocketServer } from "ws";
3246
+ var BACKGROUND_CAP = 4;
2573
3247
  var replacementLocks = /* @__PURE__ */ new Map();
2574
3248
  function withReplacementLock(workspaceId, fn) {
2575
3249
  const prev = replacementLocks.get(workspaceId) ?? Promise.resolve();
@@ -2586,7 +3260,7 @@ function withReplacementLock(workspaceId, fn) {
2586
3260
  function attachWsHub(httpServer) {
2587
3261
  const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
2588
3262
  wss.on("connection", (ws) => {
2589
- const state = {};
3263
+ const state = { background: /* @__PURE__ */ new Map() };
2590
3264
  ws.on("message", async (raw) => {
2591
3265
  let msg;
2592
3266
  try {
@@ -2597,8 +3271,8 @@ function attachWsHub(httpServer) {
2597
3271
  }
2598
3272
  try {
2599
3273
  await handle(ws, state, msg);
2600
- } catch (err) {
2601
- const message = err instanceof Error ? err.message : String(err);
3274
+ } catch (err2) {
3275
+ const message = err2 instanceof Error ? err2.message : String(err2);
2602
3276
  send(ws, { type: "error", message, command: msg.type });
2603
3277
  }
2604
3278
  });
@@ -2611,124 +3285,162 @@ function attachWsHub(httpServer) {
2611
3285
  async function handle(ws, state, msg) {
2612
3286
  switch (msg.type) {
2613
3287
  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" });
3288
+ let runtime;
3289
+ try {
3290
+ runtime = await workspaceManager.getOrCreate(msg.workspaceId, msg.sessionPath);
3291
+ } catch (err2) {
3292
+ const message = err2 instanceof Error ? err2.message : String(err2);
3293
+ send(ws, { type: "error", message, command: "subscribe" });
3294
+ send(ws, { type: "ack", command: "subscribe" });
3295
+ return;
2633
3296
  }
3297
+ promoteToPrimary(ws, state, msg.workspaceId, runtime);
2634
3298
  send(ws, { type: "ack", command: "subscribe" });
2635
3299
  return;
2636
3300
  }
3301
+ case "unsubscribe": {
3302
+ for (const [key, bg] of [...state.background]) {
3303
+ if (bg.workspaceId === msg.workspaceId) teardownBackground(state, key, ws);
3304
+ }
3305
+ return;
3306
+ }
2637
3307
  case "prompt": {
2638
- const wsId = state.workspaceId;
2639
- if (!wsId) {
3308
+ const primary = state.primary;
3309
+ if (!primary) {
2640
3310
  send(ws, { type: "error", message: "not subscribed", command: "prompt" });
2641
3311
  return;
2642
3312
  }
2643
- if (replacementLocks.has(wsId)) {
3313
+ if (replacementLocks.has(primary.workspaceId)) {
2644
3314
  send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
2645
3315
  return;
2646
3316
  }
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);
3317
+ void primary.runtime.session.prompt(msg.message, { streamingBehavior: msg.streamingBehavior }).catch((err2) => {
3318
+ const message = err2 instanceof Error ? err2.message : String(err2);
2656
3319
  send(ws, { type: "error", message, command: "prompt" });
2657
3320
  });
2658
3321
  return;
2659
3322
  }
2660
3323
  case "abort": {
2661
- const wsId = state.workspaceId;
2662
- if (!wsId) {
3324
+ const primary = state.primary;
3325
+ if (!primary) {
2663
3326
  send(ws, { type: "error", message: "not subscribed", command: "abort" });
2664
3327
  return;
2665
3328
  }
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();
3329
+ await primary.runtime.session.abort();
2673
3330
  return;
2674
3331
  }
2675
3332
  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" });
3333
+ const workspaceId = msg.workspaceId;
3334
+ await withReplacementLock(workspaceId, async () => {
3335
+ let runtime;
3336
+ try {
3337
+ runtime = await workspaceManager.createSession(workspaceId);
3338
+ } catch (err2) {
3339
+ const message = err2 instanceof Error ? err2.message : String(err2);
3340
+ send(ws, { type: "error", message, command: "new_session" });
2689
3341
  return;
2690
3342
  }
2691
- const result = await runtime.newSession();
2692
- if (result.cancelled) {
2693
- send(ws, { type: "error", message: "new session cancelled", command: "new_session" });
2694
- }
3343
+ promoteToPrimary(ws, state, workspaceId, runtime);
2695
3344
  });
2696
3345
  return;
2697
3346
  }
2698
3347
  case "fork": {
2699
- const wsId = state.workspaceId;
2700
- if (!wsId) {
3348
+ const primary = state.primary;
3349
+ if (!primary) {
2701
3350
  send(ws, { type: "error", message: "not subscribed", command: "fork" });
2702
3351
  return;
2703
3352
  }
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" });
3353
+ const workspaceId = primary.workspaceId;
3354
+ await withReplacementLock(workspaceId, async () => {
3355
+ const source = state.primary;
3356
+ if (!source) {
3357
+ send(ws, { type: "error", message: "not subscribed", command: "fork" });
2708
3358
  return;
2709
3359
  }
2710
- if (runtime.session.isStreaming) {
3360
+ if (!source.sessionPath) {
3361
+ send(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
3362
+ return;
3363
+ }
3364
+ if (source.runtime.session.isStreaming) {
2711
3365
  send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
2712
3366
  return;
2713
3367
  }
2714
- const result = await runtime.fork(msg.entryId);
2715
- if (result.cancelled) {
3368
+ let result;
3369
+ try {
3370
+ result = await workspaceManager.fork(workspaceId, source.sessionPath, msg.entryId);
3371
+ } catch (err2) {
3372
+ const message = err2 instanceof Error ? err2.message : String(err2);
3373
+ send(ws, { type: "error", message, command: "fork" });
3374
+ return;
3375
+ }
3376
+ if (result.cancelled || !result.runtime) {
2716
3377
  send(ws, { type: "error", message: "fork cancelled", command: "fork" });
3378
+ return;
2717
3379
  }
3380
+ promoteToPrimary(ws, state, workspaceId, result.runtime);
2718
3381
  });
2719
3382
  return;
2720
3383
  }
2721
3384
  case "answer_question": {
2722
- const wsId = state.workspaceId;
2723
- if (!wsId) {
3385
+ const primary = state.primary;
3386
+ if (!primary) {
2724
3387
  send(ws, { type: "error", message: "not subscribed", command: "answer_question" });
2725
3388
  return;
2726
3389
  }
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);
3390
+ resolveAnswer(msg.toolCallId, msg.answer, primary.runtime.session.sessionFile ?? null);
3391
+ return;
3392
+ }
3393
+ case "navigate_tree": {
3394
+ const primary = state.primary;
3395
+ if (!primary) {
3396
+ send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
3397
+ return;
3398
+ }
3399
+ if (msg.workspaceId !== primary.workspaceId) {
3400
+ send(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
3401
+ return;
3402
+ }
3403
+ await withReplacementLock(primary.workspaceId, async () => {
3404
+ const current = state.primary;
3405
+ if (!current) {
3406
+ send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
3407
+ return;
3408
+ }
3409
+ if (current.runtime.session.isStreaming) {
3410
+ send(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
3411
+ return;
3412
+ }
3413
+ const result = await current.runtime.session.navigateTree(msg.targetId, {
3414
+ summarize: msg.summarize,
3415
+ customInstructions: msg.customInstructions
3416
+ });
3417
+ send(ws, {
3418
+ type: "navigate_tree_result",
3419
+ workspaceId: current.workspaceId,
3420
+ editorText: result.editorText,
3421
+ cancelled: result.cancelled
3422
+ });
3423
+ });
3424
+ return;
3425
+ }
3426
+ case "compact": {
3427
+ const primary = state.primary;
3428
+ if (!primary) {
3429
+ send(ws, { type: "error", message: "not subscribed", command: "compact" });
3430
+ return;
3431
+ }
3432
+ if (primary.runtime.session.isStreaming) {
3433
+ send(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
3434
+ return;
3435
+ }
3436
+ if (primary.runtime.session.isCompacting) {
3437
+ send(ws, { type: "error", message: "compaction already in progress", command: "compact" });
3438
+ return;
3439
+ }
3440
+ primary.runtime.session.compact().catch((err2) => {
3441
+ const message = err2 instanceof Error ? err2.message : String(err2);
3442
+ send(ws, { type: "error", message, command: "compact" });
3443
+ });
2732
3444
  return;
2733
3445
  }
2734
3446
  default: {
@@ -2738,28 +3450,26 @@ async function handle(ws, state, msg) {
2738
3450
  }
2739
3451
  }
2740
3452
  }
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" });
3453
+ function promoteToPrimary(ws, state, workspaceId, runtime) {
3454
+ const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
3455
+ if (state.primary?.runtimeKey === runtimeKey) {
3456
+ workspaceManager.setActive(workspaceId, runtime);
3457
+ sendSubscribed(ws, workspaceId, runtime);
2755
3458
  return;
2756
3459
  }
2757
- state.unsubscribeSession?.();
3460
+ if (state.primary) demotePrimaryToBackground(ws, state);
3461
+ if (state.background.has(runtimeKey)) teardownBackground(state, runtimeKey, ws);
3462
+ bindPrimary(ws, state, workspaceId, runtime);
3463
+ }
3464
+ function bindPrimary(ws, state, workspaceId, runtime) {
3465
+ workspaceManager.addSubscriber(workspaceId, ws);
3466
+ workspaceManager.setActive(workspaceId, runtime);
2758
3467
  const session = runtime.session;
2759
3468
  const sessionPath = session.sessionFile ?? null;
3469
+ const runtimeKey = workspaceManager.runtimeKeyFor(workspaceId, runtime);
2760
3470
  let assistantStartAt;
2761
3471
  let assistantFirstTokenAt;
2762
- state.unsubscribeSession = session.subscribe((ev) => {
3472
+ const unsubscribe = session.subscribe((ev) => {
2763
3473
  const payload = translatePiEvent(ev);
2764
3474
  if (!payload) return;
2765
3475
  if (payload.kind === "message_start" && payload.role === "assistant") {
@@ -2768,12 +3478,7 @@ function bindCurrentSession(ws, state, workspaceId) {
2768
3478
  } else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
2769
3479
  assistantFirstTokenAt = performance.now();
2770
3480
  }
2771
- send(ws, {
2772
- type: "event",
2773
- workspaceId,
2774
- sessionPath,
2775
- payload
2776
- });
3481
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2777
3482
  if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
2778
3483
  const now = performance.now();
2779
3484
  const timing = {
@@ -2781,12 +3486,7 @@ function bindCurrentSession(ws, state, workspaceId) {
2781
3486
  firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
2782
3487
  totalMs: Math.round(now - assistantStartAt)
2783
3488
  };
2784
- send(ws, {
2785
- type: "event",
2786
- workspaceId,
2787
- sessionPath,
2788
- payload: timing
2789
- });
3489
+ send(ws, { type: "event", workspaceId, sessionPath, payload: timing });
2790
3490
  assistantStartAt = void 0;
2791
3491
  assistantFirstTokenAt = void 0;
2792
3492
  }
@@ -2794,33 +3494,80 @@ function bindCurrentSession(ws, state, workspaceId) {
2794
3494
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
2795
3495
  }
2796
3496
  });
2797
- send(ws, {
2798
- type: "subscribed",
2799
- workspaceId,
2800
- sessionPath,
2801
- sessionId: session.sessionId
2802
- });
2803
- const inFlight = inFlightAssistantSnapshot(runtime.session.state.streamingMessage);
3497
+ state.primary = { runtimeKey, workspaceId, sessionPath, runtime, unsubscribe };
3498
+ sendSubscribed(ws, workspaceId, runtime);
3499
+ const streamingMessage = runtime.session.state.streamingMessage;
3500
+ const scanMessages = streamingMessage ? [...runtime.session.messages, streamingMessage] : runtime.session.messages;
3501
+ for (const payload of inFlightRunningToolsSnapshot(
3502
+ runtime.session.state.pendingToolCalls,
3503
+ scanMessages
3504
+ )) {
3505
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
3506
+ }
3507
+ const inFlight = inFlightAssistantSnapshot(streamingMessage);
2804
3508
  if (inFlight) {
2805
3509
  for (const payload of inFlight) {
2806
- send(ws, {
2807
- type: "event",
2808
- workspaceId,
2809
- sessionPath,
2810
- payload
2811
- });
3510
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2812
3511
  }
2813
3512
  }
2814
3513
  for (const payload of inFlightToolCallsSnapshot(sessionPath)) {
2815
- send(ws, {
2816
- type: "event",
2817
- workspaceId,
2818
- sessionPath,
2819
- payload
2820
- });
3514
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
2821
3515
  }
2822
3516
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
2823
3517
  }
3518
+ function demotePrimaryToBackground(ws, state) {
3519
+ const primary = state.primary;
3520
+ if (!primary) return;
3521
+ primary.unsubscribe();
3522
+ state.primary = void 0;
3523
+ const session = primary.runtime.session;
3524
+ const sessionPath = primary.sessionPath;
3525
+ const unsubscribeSession = session.subscribe((ev) => {
3526
+ const payload = translatePiEvent(ev);
3527
+ if (!payload) return;
3528
+ send(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
3529
+ if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
3530
+ sendContextUsage(ws, primary.runtime, primary.workspaceId, sessionPath);
3531
+ }
3532
+ });
3533
+ state.background.set(primary.runtimeKey, {
3534
+ workspaceId: primary.workspaceId,
3535
+ sessionPath,
3536
+ unsubscribeSession
3537
+ });
3538
+ while (state.background.size > BACKGROUND_CAP) {
3539
+ const oldestKey = state.background.keys().next().value;
3540
+ if (oldestKey === void 0) break;
3541
+ const evicted = state.background.get(oldestKey);
3542
+ teardownBackground(state, oldestKey, ws);
3543
+ if (evicted) {
3544
+ send(ws, {
3545
+ type: "background_evicted",
3546
+ workspaceId: evicted.workspaceId,
3547
+ sessionPath: evicted.sessionPath
3548
+ });
3549
+ }
3550
+ }
3551
+ }
3552
+ function teardownBackground(state, runtimeKey, ws) {
3553
+ const bg = state.background.get(runtimeKey);
3554
+ if (!bg) return;
3555
+ bg.unsubscribeSession();
3556
+ state.background.delete(runtimeKey);
3557
+ if (ws) unrefWorkspaceSubscriber(state, bg.workspaceId, ws);
3558
+ }
3559
+ function sendSubscribed(ws, workspaceId, runtime) {
3560
+ send(ws, {
3561
+ type: "subscribed",
3562
+ workspaceId,
3563
+ sessionPath: runtime.session.sessionFile ?? null,
3564
+ sessionId: runtime.session.sessionId
3565
+ });
3566
+ }
3567
+ function unrefWorkspaceSubscriber(state, workspaceId, ws) {
3568
+ const stillUsed = state.primary?.workspaceId === workspaceId || [...state.background.values()].some((b) => b.workspaceId === workspaceId);
3569
+ if (!stillUsed) workspaceManager.removeSubscriber(workspaceId, ws);
3570
+ }
2824
3571
  function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
2825
3572
  const usage = runtime.session.getContextUsage();
2826
3573
  if (!usage) return;
@@ -2830,22 +3577,20 @@ function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
2830
3577
  contextWindow: usage.contextWindow,
2831
3578
  percent: usage.percent
2832
3579
  };
2833
- send(ws, {
2834
- type: "event",
2835
- workspaceId,
2836
- sessionPath,
2837
- payload
2838
- });
3580
+ send(ws, { type: "event", workspaceId, sessionPath, payload });
3581
+ }
3582
+ function detachPrimary(state, ws) {
3583
+ const primary = state.primary;
3584
+ if (!primary) return;
3585
+ primary.unsubscribe();
3586
+ state.primary = void 0;
3587
+ if (ws) unrefWorkspaceSubscriber(state, primary.workspaceId, ws);
2839
3588
  }
2840
3589
  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);
3590
+ detachPrimary(state, ws);
3591
+ for (const runtimeKey of [...state.background.keys()]) {
3592
+ teardownBackground(state, runtimeKey, ws);
2847
3593
  }
2848
- state.workspaceId = void 0;
2849
3594
  }
2850
3595
  function send(ws, msg) {
2851
3596
  if (ws.readyState !== ws.OPEN) return;
@@ -2854,7 +3599,7 @@ function send(ws, msg) {
2854
3599
 
2855
3600
  // src/index.ts
2856
3601
  configureHttpProxy();
2857
- var app = new Hono4();
3602
+ var app = new Hono5();
2858
3603
  var distDir = dirname6(fileURLToPath(import.meta.url));
2859
3604
  var webRoot = resolve5(process.env.PI_PILOT_WEB_ROOT ?? join9(distDir, "..", "public"));
2860
3605
  var webIndexPath = join9(webRoot, "index.html");
@@ -2891,11 +3636,11 @@ function safeResolveWebPath(pathname) {
2891
3636
  }
2892
3637
  async function readWebFile(path) {
2893
3638
  try {
2894
- return await readFile5(path);
2895
- } catch (err) {
2896
- const code = err.code;
3639
+ return await readFile6(path);
3640
+ } catch (err2) {
3641
+ const code = err2.code;
2897
3642
  if (code === "ENOENT" || code === "EISDIR") return void 0;
2898
- throw err;
3643
+ throw err2;
2899
3644
  }
2900
3645
  }
2901
3646
  async function serveWeb(c) {
@@ -2904,7 +3649,7 @@ async function serveWeb(c) {
2904
3649
  const assetPath = safeResolveWebPath(pathname);
2905
3650
  if (!assetPath) return c.text("invalid asset path", 400);
2906
3651
  const asset = await readWebFile(assetPath);
2907
- const body = asset ?? await readFile5(webIndexPath);
3652
+ const body = asset ?? await readFile6(webIndexPath);
2908
3653
  const filePath = asset ? assetPath : webIndexPath;
2909
3654
  const headers = {
2910
3655
  "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",