@jyork0828/pi-pilot 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { existsSync as existsSync2 } from "fs";
5
- import { readFile as readFile9 } from "fs/promises";
6
- import { dirname as dirname6, extname, join as join16, resolve as resolve7, sep as sep3 } from "path";
5
+ import { readFile as readFile10 } from "fs/promises";
6
+ import { dirname as dirname6, extname, join as join17, resolve as resolve8, sep as sep3 } from "path";
7
7
  import { fileURLToPath as fileURLToPath2 } from "url";
8
8
  import { serve } from "@hono/node-server";
9
9
  import { Hono as Hono6 } from "hono";
@@ -41,20 +41,31 @@ function configureHttpProxy() {
41
41
  }
42
42
 
43
43
  // src/api/workspaces.ts
44
- import { readFile as readFile7, stat as stat2 } from "fs/promises";
45
- import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve5 } from "path";
44
+ import { readFile as readFile8, stat as stat3 } from "fs/promises";
45
+ import { basename as basename2, isAbsolute as isAbsolute3, resolve as resolve6 } from "path";
46
46
  import { Hono as Hono2 } from "hono";
47
47
 
48
48
  // src/storage/resource-writer.ts
49
+ import { execFile } from "child_process";
50
+ import { createWriteStream } from "fs";
49
51
  import {
52
+ cp,
50
53
  mkdir,
54
+ mkdtemp,
55
+ readdir,
51
56
  readFile,
52
57
  rm,
53
58
  stat,
54
59
  unlink,
55
60
  writeFile
56
61
  } from "fs/promises";
62
+ import { tmpdir } from "os";
57
63
  import { basename, dirname, isAbsolute, join as join2, resolve, sep } from "path";
64
+ import { Readable, Transform } from "stream";
65
+ import { pipeline } from "stream/promises";
66
+ import { promisify } from "util";
67
+ import { createGunzip } from "zlib";
68
+ var exec = promisify(execFile);
58
69
  var SKILL_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
59
70
  var PROMPT_NAME_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
60
71
  function ensureSkillName(name) {
@@ -231,6 +242,156 @@ async function deleteSkill(filePath, roots) {
231
242
  assertUnder(dir, [roots.userSkills, roots.projectSkills]);
232
243
  await rm(dir, { recursive: true, force: true });
233
244
  }
245
+ var MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
246
+ var MAX_ENTRIES = 5e3;
247
+ async function installSkillFromZip(opts) {
248
+ const base = opts.scope === "user" ? opts.roots.userSkills : opts.roots.projectSkills;
249
+ const maxBytes = opts.limits?.maxUncompressedBytes ?? MAX_UNCOMPRESSED_BYTES;
250
+ const maxEntries = opts.limits?.maxEntries ?? MAX_ENTRIES;
251
+ const work = await mkdtemp(join2(tmpdir(), "pi-pilot-skill-"));
252
+ try {
253
+ const extractDir = join2(work, "x");
254
+ await mkdir(extractDir, { recursive: true });
255
+ await extractArchive(opts.zip, work, extractDir, { maxBytes, maxEntries });
256
+ const srcDir = await locateSkillDir(extractDir);
257
+ await assertNoSymlinks(srcDir);
258
+ const name = await resolveInstalledSkillName(srcDir, extractDir);
259
+ const dest = join2(base, name);
260
+ assertUnder(dest, [opts.roots.userSkills, opts.roots.projectSkills]);
261
+ if (await exists(dest)) {
262
+ if (!opts.overwrite) {
263
+ throw new HttpError(409, `a skill named "${name}" already exists \u2014 delete it first or overwrite`);
264
+ }
265
+ await rm(dest, { recursive: true, force: true });
266
+ }
267
+ await mkdir(base, { recursive: true });
268
+ await cp(srcDir, dest, {
269
+ recursive: true,
270
+ // Strip macOS archive cruft so the installed dir stays clean. (The
271
+ // tree is already symlink-free — see assertNoSymlinks above.)
272
+ filter: (src) => {
273
+ const b = basename(src);
274
+ return b !== "__MACOSX" && b !== ".DS_Store";
275
+ }
276
+ });
277
+ return { name, filePath: join2(dest, "SKILL.md") };
278
+ } finally {
279
+ await rm(work, { recursive: true, force: true });
280
+ }
281
+ }
282
+ async function extractArchive(zip, work, extractDir, caps) {
283
+ const isZip = zip.length >= 4 && zip[0] === 80 && zip[1] === 75;
284
+ const isGzip = zip.length >= 3 && zip[0] === 31 && zip[1] === 139 && zip[2] === 8;
285
+ if (!isZip && !isGzip) {
286
+ throw new HttpError(400, "uploaded file is not a .zip or .tar.gz archive");
287
+ }
288
+ try {
289
+ if (isZip) {
290
+ const zipPath = join2(work, "skill.zip");
291
+ await writeFile(zipPath, zip);
292
+ const { stdout } = await exec("unzip", ["-Z", "-t", zipPath]);
293
+ const m = stdout.match(/(\d+)\s+files?,\s+(\d+)\s+bytes uncompressed/);
294
+ if (!m) throw new HttpError(400, "could not read the archive's contents");
295
+ assertEntryCount(Number(m[1]), caps.maxEntries);
296
+ assertUncompressedSize(Number(m[2]), caps.maxBytes);
297
+ await exec("unzip", ["-o", "-q", zipPath, "-d", extractDir]);
298
+ } else {
299
+ const tarPath = join2(work, "skill.tar");
300
+ await gunzipToFileCapped(zip, tarPath, caps.maxBytes);
301
+ const { stdout } = await exec("tar", ["-tf", tarPath], { maxBuffer: 16 * 1024 * 1024 });
302
+ assertEntryCount(stdout.split("\n").filter((l) => l.length > 0).length, caps.maxEntries);
303
+ await exec("tar", ["-xf", tarPath, "-C", extractDir]);
304
+ }
305
+ } catch (err2) {
306
+ if (err2 instanceof HttpError) throw err2;
307
+ const e = err2;
308
+ if (e.code === "ENOENT") {
309
+ throw new HttpError(
310
+ 500,
311
+ `\`${e.path ?? "unzip/tar"}\` is not available on the server host \u2014 install it or place the skill folder under your skills directory manually`
312
+ );
313
+ }
314
+ throw new HttpError(400, `could not extract archive: ${e.stderr?.trim() || e.message}`);
315
+ }
316
+ }
317
+ async function gunzipToFileCapped(gz, destPath, maxBytes) {
318
+ let total = 0;
319
+ const cap = new Transform({
320
+ transform(chunk, _enc, cb) {
321
+ total += chunk.length;
322
+ if (total > maxBytes) {
323
+ cb(
324
+ new HttpError(
325
+ 400,
326
+ `archive expands beyond the ${Math.round(maxBytes / 1024 / 1024)} MB uncompressed cap`
327
+ )
328
+ );
329
+ return;
330
+ }
331
+ cb(null, chunk);
332
+ }
333
+ });
334
+ await pipeline(Readable.from([gz]), createGunzip(), cap, createWriteStream(destPath));
335
+ }
336
+ function assertEntryCount(entries, max) {
337
+ if (entries > max) {
338
+ throw new HttpError(400, `archive has too many files (${entries} > ${max})`);
339
+ }
340
+ }
341
+ function assertUncompressedSize(uncompressed, maxBytes) {
342
+ if (uncompressed > maxBytes) {
343
+ throw new HttpError(
344
+ 400,
345
+ `archive expands to ${Math.round(uncompressed / 1024 / 1024)} MB uncompressed (max ${Math.round(maxBytes / 1024 / 1024)} MB)`
346
+ );
347
+ }
348
+ }
349
+ async function locateSkillDir(extractDir) {
350
+ if (await exists(join2(extractDir, "SKILL.md"))) return extractDir;
351
+ const dirs = (await readdir(extractDir, { withFileTypes: true })).filter(
352
+ (e) => e.isDirectory() && e.name !== "__MACOSX"
353
+ );
354
+ const withSkill = [];
355
+ for (const e of dirs) {
356
+ const dir = join2(extractDir, e.name);
357
+ if (await exists(join2(dir, "SKILL.md"))) withSkill.push(dir);
358
+ }
359
+ if (withSkill.length > 1) {
360
+ throw new HttpError(400, "archive contains multiple skill folders \u2014 install one skill per .zip");
361
+ }
362
+ if (withSkill.length === 1) return withSkill[0];
363
+ throw new HttpError(400, "archive has no SKILL.md (at the root or one level down)");
364
+ }
365
+ async function assertNoSymlinks(dir) {
366
+ const entries = await readdir(dir, { withFileTypes: true });
367
+ for (const e of entries) {
368
+ if (e.isSymbolicLink()) {
369
+ throw new HttpError(
370
+ 400,
371
+ "archive contains a symbolic link, which is not allowed in a skill package"
372
+ );
373
+ }
374
+ if (e.isDirectory()) await assertNoSymlinks(join2(dir, e.name));
375
+ }
376
+ }
377
+ async function resolveInstalledSkillName(srcDir, extractDir) {
378
+ const text = await readFile(join2(srcDir, "SKILL.md"), "utf8");
379
+ const fmName = parseFile(text).frontmatter.name;
380
+ if (typeof fmName === "string" && fmName.trim()) {
381
+ const n2 = fmName.trim();
382
+ ensureSkillName(n2);
383
+ return n2;
384
+ }
385
+ if (resolve(srcDir) === resolve(extractDir)) {
386
+ throw new HttpError(
387
+ 400,
388
+ "SKILL.md has no `name:` field and the archive isn't wrapped in a folder \u2014 add a `name:` to the skill's frontmatter"
389
+ );
390
+ }
391
+ const n = basename(srcDir);
392
+ ensureSkillName(n);
393
+ return n;
394
+ }
234
395
  async function createPrompt(opts) {
235
396
  const file = promptFileFor(opts.scope, opts.name, opts.roots);
236
397
  assertUnder(file, [opts.roots.userPrompts, opts.roots.projectPrompts]);
@@ -477,9 +638,9 @@ async function reorderWorkspaces(ids) {
477
638
  }
478
639
 
479
640
  // src/storage/workspace-stats.ts
480
- import { execFile } from "child_process";
481
- import { promisify } from "util";
482
- var exec = promisify(execFile);
641
+ import { execFile as execFile2 } from "child_process";
642
+ import { promisify as promisify2 } from "util";
643
+ var exec2 = promisify2(execFile2);
483
644
  var CACHE_TTL_MS = 3e4;
484
645
  var cache2 = /* @__PURE__ */ new Map();
485
646
  var inflight = /* @__PURE__ */ new Map();
@@ -555,7 +716,7 @@ async function probeStats(path) {
555
716
  return { gitBranch, fileCount };
556
717
  }
557
718
  async function runGit(cwd, args) {
558
- const { stdout } = await exec("git", args, {
719
+ const { stdout } = await exec2("git", args, {
559
720
  cwd,
560
721
  timeout: 2e3,
561
722
  maxBuffer: 5 * 1024 * 1024,
@@ -1007,7 +1168,7 @@ function waitForAnswer({
1007
1168
  sessionFile,
1008
1169
  signal
1009
1170
  }) {
1010
- return new Promise((resolve8, reject) => {
1171
+ return new Promise((resolve9, reject) => {
1011
1172
  let settled = false;
1012
1173
  let timeoutHandle;
1013
1174
  const cleanup = () => {
@@ -1019,7 +1180,7 @@ function waitForAnswer({
1019
1180
  if (settled) return;
1020
1181
  settled = true;
1021
1182
  cleanup();
1022
- resolve8(a);
1183
+ resolve9(a);
1023
1184
  };
1024
1185
  const finishErr = (err2) => {
1025
1186
  if (settled) return;
@@ -1295,13 +1456,13 @@ function toolsField(value) {
1295
1456
  // src/extensions/subagent/child.ts
1296
1457
  import { spawn } from "child_process";
1297
1458
  import {
1298
- createWriteStream,
1459
+ createWriteStream as createWriteStream2,
1299
1460
  mkdirSync,
1300
1461
  mkdtempSync,
1301
1462
  writeFileSync
1302
1463
  } from "fs";
1303
1464
  import { rm as rm3 } from "fs/promises";
1304
- import { tmpdir } from "os";
1465
+ import { tmpdir as tmpdir2 } from "os";
1305
1466
  import { join as join8 } from "path";
1306
1467
 
1307
1468
  // src/extensions/subagent/schema.ts
@@ -1422,18 +1583,18 @@ function take(sessionKey) {
1422
1583
  }
1423
1584
  function acquireChildSlot(signal, sessionFile) {
1424
1585
  const sessionKey = keyOf2(sessionFile);
1425
- return new Promise((resolve8, reject) => {
1586
+ return new Promise((resolve9, reject) => {
1426
1587
  if (signal?.aborted) {
1427
1588
  reject(new Error("Aborted by user"));
1428
1589
  return;
1429
1590
  }
1430
1591
  if (hasCapacity(sessionKey)) {
1431
1592
  take(sessionKey);
1432
- resolve8(makeRelease(sessionKey));
1593
+ resolve9(makeRelease(sessionKey));
1433
1594
  return;
1434
1595
  }
1435
1596
  const waiter = {
1436
- grant: () => resolve8(makeRelease(sessionKey)),
1597
+ grant: () => resolve9(makeRelease(sessionKey)),
1437
1598
  sessionKey,
1438
1599
  signal,
1439
1600
  onAbort: void 0
@@ -1484,7 +1645,7 @@ function pump() {
1484
1645
 
1485
1646
  // src/extensions/subagent/child.ts
1486
1647
  var PROMPT_DIR_PREFIX = "pi-pilot-subagent-";
1487
- var TRANSCRIPTS_DIR = join8(tmpdir(), "pi-pilot-subagents", "transcripts");
1648
+ var TRANSCRIPTS_DIR = join8(tmpdir2(), "pi-pilot-subagents", "transcripts");
1488
1649
  var ACTIVITY_MAX = 30;
1489
1650
  var LABEL_MAX = 160;
1490
1651
  var STDERR_TAIL_MAX = 2048;
@@ -1493,7 +1654,7 @@ var SIGKILL_DELAY_MS = 5e3;
1493
1654
  async function runChild(opts) {
1494
1655
  const startedAt = Date.now();
1495
1656
  const cli = opts.cliPath ?? process.env.PI_PILOT_SUBAGENT_CLI ?? resolvePinnedPiCli();
1496
- const promptDir = mkdtempSync(join8(tmpdir(), PROMPT_DIR_PREFIX));
1657
+ const promptDir = mkdtempSync(join8(tmpdir2(), PROMPT_DIR_PREFIX));
1497
1658
  const promptPath = join8(promptDir, "prompt.md");
1498
1659
  writeFileSync(promptPath, opts.appendSystemPrompt, { mode: 384 });
1499
1660
  let transcriptPath;
@@ -1501,7 +1662,7 @@ async function runChild(opts) {
1501
1662
  try {
1502
1663
  mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
1503
1664
  transcriptPath = join8(TRANSCRIPTS_DIR, `${sanitizeId(opts.toolCallId)}.ndjson`);
1504
- tee = createWriteStream(transcriptPath, { flags: "w" });
1665
+ tee = createWriteStream2(transcriptPath, { flags: "w" });
1505
1666
  tee.on("error", () => {
1506
1667
  });
1507
1668
  } catch {
@@ -1649,12 +1810,12 @@ ${process.pid}`);
1649
1810
  armStallTimer();
1650
1811
  stderrAccum = (stderrAccum + chunk.toString("utf8")).slice(-STDERR_TAIL_MAX);
1651
1812
  });
1652
- const exitCode = await new Promise((resolve8) => {
1813
+ const exitCode = await new Promise((resolve9) => {
1653
1814
  child.on("error", (err2) => {
1654
1815
  errorMessage ??= err2 instanceof Error ? err2.message : String(err2);
1655
- resolve8(-1);
1816
+ resolve9(-1);
1656
1817
  });
1657
- child.on("close", (code) => resolve8(code ?? -1));
1818
+ child.on("close", (code) => resolve9(code ?? -1));
1658
1819
  });
1659
1820
  if (buf) handleLine(buf);
1660
1821
  clearTimeout(timeoutTimer);
@@ -2501,9 +2662,9 @@ function reconcileAfterRestart(sessionManager) {
2501
2662
  }
2502
2663
 
2503
2664
  // src/extensions/subagent/cleanup.ts
2504
- import { execFile as execFile2 } from "child_process";
2505
- import { readdir, readFile as readFile6, rm as rm4 } from "fs/promises";
2506
- import { tmpdir as tmpdir2 } from "os";
2665
+ import { execFile as execFile3 } from "child_process";
2666
+ import { readdir as readdir2, readFile as readFile6, rm as rm4 } from "fs/promises";
2667
+ import { tmpdir as tmpdir3 } from "os";
2507
2668
  import { join as join11 } from "path";
2508
2669
  var CUSTOM_TYPE2 = "subagent-restart-cancelled";
2509
2670
  function reconcileAfterRestart2(sessionManager) {
@@ -2558,10 +2719,10 @@ function reconcileAfterRestart2(sessionManager) {
2558
2719
  { ids: danglingIds }
2559
2720
  );
2560
2721
  }
2561
- async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir2()) {
2722
+ async function sweepOrphanedChildrenOnBoot(rootDir = tmpdir3()) {
2562
2723
  let dirNames;
2563
2724
  try {
2564
- dirNames = (await readdir(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
2725
+ dirNames = (await readdir2(rootDir)).filter((n) => n.startsWith(PROMPT_DIR_PREFIX));
2565
2726
  } catch {
2566
2727
  return;
2567
2728
  }
@@ -2598,14 +2759,14 @@ function isLiveNodeProcess(pid) {
2598
2759
  } catch {
2599
2760
  return Promise.resolve(false);
2600
2761
  }
2601
- return new Promise((resolve8) => {
2602
- execFile2("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
2762
+ return new Promise((resolve9) => {
2763
+ execFile3("ps", ["-o", "ucomm=", "-p", String(pid)], (err2, stdout) => {
2603
2764
  if (err2) {
2604
- resolve8(false);
2765
+ resolve9(false);
2605
2766
  return;
2606
2767
  }
2607
2768
  const name = stdout.trim().toLowerCase();
2608
- resolve8(name === "node" || name === "pi");
2769
+ resolve9(name === "node" || name === "pi");
2609
2770
  });
2610
2771
  });
2611
2772
  }
@@ -2615,8 +2776,10 @@ function translatePiEvent(ev) {
2615
2776
  switch (ev.type) {
2616
2777
  case "agent_start":
2617
2778
  return { kind: "agent_start" };
2618
- case "agent_end":
2619
- return { kind: "agent_end", willRetry: ev.willRetry };
2779
+ case "agent_end": {
2780
+ const error = ev.willRetry ? void 0 : finalAssistantError(ev.messages);
2781
+ return { kind: "agent_end", willRetry: ev.willRetry, ...error ? { error } : {} };
2782
+ }
2620
2783
  case "turn_start":
2621
2784
  return { kind: "turn_start" };
2622
2785
  case "turn_end":
@@ -2711,6 +2874,21 @@ function translatePiEvent(ev) {
2711
2874
  return void 0;
2712
2875
  }
2713
2876
  }
2877
+ function assistantErrorText(message) {
2878
+ const m = message;
2879
+ if (!m || m.stopReason !== "error") return void 0;
2880
+ const text = typeof m.errorMessage === "string" ? m.errorMessage.trim() : "";
2881
+ return text || "The model stream ended with an error.";
2882
+ }
2883
+ function finalAssistantError(messages) {
2884
+ if (!Array.isArray(messages)) return void 0;
2885
+ for (let i = messages.length - 1; i >= 0; i--) {
2886
+ const m = messages[i];
2887
+ if (!m || m.role !== "assistant") continue;
2888
+ return assistantErrorText(m);
2889
+ }
2890
+ return void 0;
2891
+ }
2714
2892
  var warnedUnknownRoles = /* @__PURE__ */ new Set();
2715
2893
  function roleOf(message) {
2716
2894
  const role = message?.role;
@@ -2837,6 +3015,97 @@ function extractText(result) {
2837
3015
  return parts.length === 0 ? void 0 : parts.join("");
2838
3016
  }
2839
3017
 
3018
+ // src/history-builder.ts
3019
+ function isAssistantSupersededByRetry(branch, index) {
3020
+ for (let j = index + 1; j < branch.length; j++) {
3021
+ const e = branch[j];
3022
+ if (e?.type !== "message") continue;
3023
+ return e.message?.role === "assistant";
3024
+ }
3025
+ return false;
3026
+ }
3027
+ function buildHistoryItems(branch) {
3028
+ const items = [];
3029
+ const argsByCallId = /* @__PURE__ */ new Map();
3030
+ for (let i = 0; i < branch.length; i++) {
3031
+ const entry = branch[i];
3032
+ if (!entry || entry.type !== "message") continue;
3033
+ const msg = entry.message;
3034
+ const role = msg.role;
3035
+ if (role === "user") {
3036
+ const text = extractUserText2(msg);
3037
+ if (text) items.push({ kind: "user", text, entryId: entry.id ?? "" });
3038
+ } else if (role === "assistant") {
3039
+ const { text, thinking, toolCalls } = extractAssistantContent(
3040
+ msg
3041
+ );
3042
+ for (const tc of toolCalls) {
3043
+ argsByCallId.set(tc.id, tc.args);
3044
+ }
3045
+ const error = isAssistantSupersededByRetry(branch, i) ? void 0 : assistantErrorText(msg);
3046
+ if (text || thinking || error) {
3047
+ items.push({ kind: "assistant", text, thinking, ...error ? { error } : {} });
3048
+ }
3049
+ } else if (role === "toolResult") {
3050
+ const tr = msg;
3051
+ items.push({
3052
+ kind: "tool",
3053
+ toolCallId: tr.toolCallId,
3054
+ toolName: tr.toolName,
3055
+ args: argsByCallId.get(tr.toolCallId) ?? "",
3056
+ text: extractContentText(tr.content),
3057
+ isError: tr.isError,
3058
+ // Mirror live wire whitelist (bridge.ts): only ship details for
3059
+ // tools whose cards need the structured shape, so the history
3060
+ // payload stays small for bash / edit / read.
3061
+ ...shouldForwardDetails(tr.toolName) && tr.details !== void 0 ? { details: tr.details } : {}
3062
+ });
3063
+ } else if (role === "bashExecution") {
3064
+ const be = msg;
3065
+ items.push({
3066
+ kind: "bash",
3067
+ command: be.command,
3068
+ output: be.output,
3069
+ exitCode: be.exitCode
3070
+ });
3071
+ }
3072
+ }
3073
+ return items;
3074
+ }
3075
+ function extractUserText2(msg) {
3076
+ if (typeof msg.content === "string") return msg.content;
3077
+ return extractContentText(msg.content);
3078
+ }
3079
+ function extractAssistantContent(msg) {
3080
+ const textParts = [];
3081
+ const thinkingParts = [];
3082
+ const toolCalls = [];
3083
+ for (const block of msg.content ?? []) {
3084
+ if (!block || typeof block !== "object") continue;
3085
+ const b = block;
3086
+ if (b.type === "text" && typeof b.text === "string") textParts.push(b.text);
3087
+ else if (b.type === "thinking" && typeof b.thinking === "string") thinkingParts.push(b.thinking);
3088
+ else if (b.type === "toolCall" && typeof b.id === "string") {
3089
+ toolCalls.push({
3090
+ id: b.id,
3091
+ args: b.arguments != null ? JSON.stringify(b.arguments) : ""
3092
+ });
3093
+ }
3094
+ }
3095
+ return { text: textParts.join(""), thinking: thinkingParts.join(""), toolCalls };
3096
+ }
3097
+ function extractContentText(content) {
3098
+ if (!Array.isArray(content)) return "";
3099
+ const parts = [];
3100
+ for (const block of content) {
3101
+ if (block && typeof block === "object" && block.type === "text") {
3102
+ const text = block.text;
3103
+ if (typeof text === "string") parts.push(text);
3104
+ }
3105
+ }
3106
+ return parts.join("");
3107
+ }
3108
+
2840
3109
  // src/ws/extension-ui.ts
2841
3110
  var ExtensionUIBridge = class {
2842
3111
  /** Symmetric with the old bridge so workspace-manager's dispose path
@@ -3213,48 +3482,7 @@ ${err2.stack}` : "")
3213
3482
  if (!runtime) return { items: [], isStreaming: false };
3214
3483
  const isStreaming = runtime.session.isStreaming ?? false;
3215
3484
  const branch = runtime.session.sessionManager.getBranch();
3216
- const items = [];
3217
- const argsByCallId = /* @__PURE__ */ new Map();
3218
- for (const entry of branch) {
3219
- if (entry.type !== "message") continue;
3220
- const msg = entry.message;
3221
- const role = msg.role;
3222
- if (role === "user") {
3223
- const text = extractUserText2(msg);
3224
- if (text) items.push({ kind: "user", text, entryId: entry.id });
3225
- } else if (role === "assistant") {
3226
- const { text, thinking, toolCalls } = extractAssistantContent(
3227
- msg
3228
- );
3229
- for (const tc of toolCalls) {
3230
- argsByCallId.set(tc.id, tc.args);
3231
- }
3232
- if (text || thinking) items.push({ kind: "assistant", text, thinking });
3233
- } else if (role === "toolResult") {
3234
- const tr = msg;
3235
- items.push({
3236
- kind: "tool",
3237
- toolCallId: tr.toolCallId,
3238
- toolName: tr.toolName,
3239
- args: argsByCallId.get(tr.toolCallId) ?? "",
3240
- text: extractContentText(tr.content),
3241
- isError: tr.isError,
3242
- // Mirror live wire whitelist (bridge.ts): only ship details
3243
- // for tools whose cards need the structured shape, so the
3244
- // history payload stays small for bash / edit / read.
3245
- ...shouldForwardDetails(tr.toolName) && tr.details !== void 0 ? { details: tr.details } : {}
3246
- });
3247
- } else if (role === "bashExecution") {
3248
- const be = msg;
3249
- items.push({
3250
- kind: "bash",
3251
- command: be.command,
3252
- output: be.output,
3253
- exitCode: be.exitCode
3254
- });
3255
- }
3256
- }
3257
- return { items, isStreaming };
3485
+ return { items: buildHistoryItems(branch), isStreaming };
3258
3486
  }
3259
3487
  /**
3260
3488
  * Delete a session JSONL file belonging to this workspace.
@@ -3394,39 +3622,6 @@ function toSessionSummary(info, running2) {
3394
3622
  ...running2 ? { running: true } : {}
3395
3623
  };
3396
3624
  }
3397
- function extractUserText2(msg) {
3398
- if (typeof msg.content === "string") return msg.content;
3399
- return extractContentText(msg.content);
3400
- }
3401
- function extractAssistantContent(msg) {
3402
- const textParts = [];
3403
- const thinkingParts = [];
3404
- const toolCalls = [];
3405
- for (const block of msg.content ?? []) {
3406
- if (!block || typeof block !== "object") continue;
3407
- const b = block;
3408
- if (b.type === "text" && typeof b.text === "string") textParts.push(b.text);
3409
- else if (b.type === "thinking" && typeof b.thinking === "string") thinkingParts.push(b.thinking);
3410
- else if (b.type === "toolCall" && typeof b.id === "string") {
3411
- toolCalls.push({
3412
- id: b.id,
3413
- args: b.arguments != null ? JSON.stringify(b.arguments) : ""
3414
- });
3415
- }
3416
- }
3417
- return { text: textParts.join(""), thinking: thinkingParts.join(""), toolCalls };
3418
- }
3419
- function extractContentText(content) {
3420
- if (!Array.isArray(content)) return "";
3421
- const parts = [];
3422
- for (const block of content) {
3423
- if (block && typeof block === "object" && block.type === "text") {
3424
- const text = block.text;
3425
- if (typeof text === "string") parts.push(text);
3426
- }
3427
- }
3428
- return parts.join("");
3429
- }
3430
3625
  var workspaceManager = new SessionRuntimeManager();
3431
3626
  function broadcastTo(subscribers, msg) {
3432
3627
  const wire = JSON.stringify(msg);
@@ -3630,11 +3825,11 @@ function mountConfigRoutes(app2) {
3630
3825
  }
3631
3826
 
3632
3827
  // src/api/files.ts
3633
- import { execFile as execFile3 } from "child_process";
3634
- import { readdir as readdir2 } from "fs/promises";
3635
- import { join as join12, relative, sep as sep2 } from "path";
3636
- import { promisify as promisify2 } from "util";
3637
- var exec2 = promisify2(execFile3);
3828
+ import { execFile as execFile4 } from "child_process";
3829
+ import { readdir as readdir3, readFile as readFile7, realpath, stat as stat2, writeFile as writeFile4 } from "fs/promises";
3830
+ import { join as join12, relative, resolve as resolve5, sep as sep2 } from "path";
3831
+ import { promisify as promisify3 } from "util";
3832
+ var exec3 = promisify3(execFile4);
3638
3833
  var LIST_TTL_MS = 1e4;
3639
3834
  var MAX_CACHED_WORKSPACES = 16;
3640
3835
  var MAX_FILES_TRACKED = 2e4;
@@ -3685,7 +3880,7 @@ async function getFileList(workspacePath) {
3685
3880
  }
3686
3881
  async function probeFileList(workspacePath) {
3687
3882
  try {
3688
- const { stdout } = await exec2(
3883
+ const { stdout } = await exec3(
3689
3884
  "git",
3690
3885
  ["ls-files", "--cached", "--others", "--exclude-standard"],
3691
3886
  {
@@ -3714,7 +3909,7 @@ async function walkDir(root, dir, depth, out) {
3714
3909
  if (depth > WALK_MAX_DEPTH) return;
3715
3910
  let dirents;
3716
3911
  try {
3717
- dirents = await readdir2(dir, { withFileTypes: true });
3912
+ dirents = await readdir3(dir, { withFileTypes: true });
3718
3913
  } catch {
3719
3914
  return;
3720
3915
  }
@@ -3744,6 +3939,21 @@ async function ensureWorkspaceExists(id) {
3744
3939
  const ws = await getWorkspace(id);
3745
3940
  return ws ? ws.path : null;
3746
3941
  }
3942
+ var MAX_EDIT_BYTES = 1024 * 1024;
3943
+ var TREE_MAX_FILES = 5e3;
3944
+ function resolveInWorkspace(workspacePath, relPath) {
3945
+ const root = resolve5(workspacePath);
3946
+ const abs = resolve5(root, relPath);
3947
+ if (abs !== root && !abs.startsWith(root + sep2)) return null;
3948
+ return abs;
3949
+ }
3950
+ async function resolveExistingInWorkspace(workspacePath, relPath) {
3951
+ const abs = resolveInWorkspace(workspacePath, relPath);
3952
+ if (!abs) return null;
3953
+ const [rootReal, targetReal] = await Promise.all([realpath(workspacePath), realpath(abs)]);
3954
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep2)) return null;
3955
+ return targetReal;
3956
+ }
3747
3957
  function mountFilesRoute(app2) {
3748
3958
  app2.get("/:id/files/search", async (c) => {
3749
3959
  const id = c.req.param("id");
@@ -3791,12 +4001,81 @@ function mountFilesRoute(app2) {
3791
4001
  return c.json({ ok: false, error: message }, 500);
3792
4002
  }
3793
4003
  });
4004
+ app2.get("/:id/files/list", async (c) => {
4005
+ const id = c.req.param("id");
4006
+ const workspacePath = await ensureWorkspaceExists(id);
4007
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4008
+ try {
4009
+ const all = await getFileList(workspacePath);
4010
+ const entries = all.slice(0, TREE_MAX_FILES);
4011
+ const body = {
4012
+ workspacePath,
4013
+ entries,
4014
+ truncated: all.length > TREE_MAX_FILES
4015
+ };
4016
+ return c.json(body);
4017
+ } catch (err2) {
4018
+ const message = err2 instanceof Error ? err2.message : String(err2);
4019
+ console.error(`[api/files] list for ${id} failed:`, err2);
4020
+ return c.json({ ok: false, error: message }, 500);
4021
+ }
4022
+ });
4023
+ app2.get("/:id/file", async (c) => {
4024
+ const id = c.req.param("id");
4025
+ const workspacePath = await ensureWorkspaceExists(id);
4026
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4027
+ const relPath = c.req.query("path");
4028
+ if (!relPath) return c.json({ ok: false, error: "path query is required" }, 400);
4029
+ try {
4030
+ const abs = await resolveExistingInWorkspace(workspacePath, relPath);
4031
+ if (!abs) return c.json({ ok: false, error: "path escapes workspace" }, 400);
4032
+ const st = await stat2(abs);
4033
+ if (!st.isFile()) return c.json({ ok: false, error: "not a file" }, 400);
4034
+ if (st.size > MAX_EDIT_BYTES) {
4035
+ return c.json({ ok: false, error: "file too large to edit (>1 MB)" }, 400);
4036
+ }
4037
+ const buf = await readFile7(abs);
4038
+ if (buf.includes(0)) return c.json({ ok: false, error: "binary file" }, 400);
4039
+ const body = { relPath, content: buf.toString("utf-8") };
4040
+ return c.json(body);
4041
+ } catch (err2) {
4042
+ const code = err2.code;
4043
+ const msg = code === "ENOENT" ? "not found" : code === "EACCES" ? "permission denied" : "read failed";
4044
+ return c.json({ ok: false, error: msg }, 400);
4045
+ }
4046
+ });
4047
+ app2.put("/:id/file", async (c) => {
4048
+ const id = c.req.param("id");
4049
+ const workspacePath = await ensureWorkspaceExists(id);
4050
+ if (!workspacePath) return c.json({ ok: false, error: "not found" }, 404);
4051
+ const body = await c.req.json().catch(() => null);
4052
+ if (!body || typeof body.path !== "string" || typeof body.content !== "string") {
4053
+ return c.json({ ok: false, error: "path and content are required" }, 400);
4054
+ }
4055
+ if (Buffer.byteLength(body.content, "utf-8") > MAX_EDIT_BYTES) {
4056
+ return c.json({ ok: false, error: "content too large (>1 MB)" }, 400);
4057
+ }
4058
+ try {
4059
+ const abs = await resolveExistingInWorkspace(workspacePath, body.path);
4060
+ if (!abs) return c.json({ ok: false, error: "path escapes workspace" }, 400);
4061
+ const st = await stat2(abs);
4062
+ if (!st.isFile()) return c.json({ ok: false, error: "not a file" }, 400);
4063
+ await writeFile4(abs, body.content, "utf-8");
4064
+ const ok = { ok: true };
4065
+ return c.json(ok);
4066
+ } catch (err2) {
4067
+ const code = err2.code;
4068
+ const msg = code === "ENOENT" ? "not found" : code === "EACCES" ? "permission denied" : "write failed";
4069
+ return c.json({ ok: false, error: msg }, 400);
4070
+ }
4071
+ });
3794
4072
  }
3795
4073
 
3796
4074
  // src/api/resources.ts
3797
- import { readdir as readdir3 } from "fs/promises";
4075
+ import { readdir as readdir4 } from "fs/promises";
3798
4076
  import { join as join13 } from "path";
3799
4077
  import { getAgentDir as getAgentDir3 } from "@earendil-works/pi-coding-agent";
4078
+ var MAX_SKILL_ZIP_BYTES = 25 * 1024 * 1024;
3800
4079
  function toResourceSource(info) {
3801
4080
  return {
3802
4081
  scope: info.scope,
@@ -3809,7 +4088,7 @@ async function scanExtensionDirs(workspaceCwd) {
3809
4088
  const found = [];
3810
4089
  for (const dir of dirs) {
3811
4090
  try {
3812
- const entries = await readdir3(dir, { withFileTypes: true });
4091
+ const entries = await readdir4(dir, { withFileTypes: true });
3813
4092
  for (const entry of entries) {
3814
4093
  if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
3815
4094
  found.push(join13(dir, entry.name));
@@ -3989,6 +4268,39 @@ function mountResourcesRoute(app2) {
3989
4268
  return respondError(c, err2);
3990
4269
  }
3991
4270
  });
4271
+ app2.post("/:id/resources/skills/install", async (c) => {
4272
+ const id = c.req.param("id");
4273
+ const ws = await getWorkspace(id);
4274
+ if (!ws) return c.json({ ok: false, error: "not found" }, 404);
4275
+ const scope = c.req.query("scope");
4276
+ if (!isScope(scope)) {
4277
+ return c.json({ ok: false, error: "scope query must be 'user' or 'project'" }, 400);
4278
+ }
4279
+ const overwrite = c.req.query("overwrite") === "1";
4280
+ const declared = Number(c.req.header("content-length") ?? "0");
4281
+ if (declared > MAX_SKILL_ZIP_BYTES) {
4282
+ return c.json({ ok: false, error: "archive too large (max 25 MB)" }, 400);
4283
+ }
4284
+ let zip;
4285
+ try {
4286
+ zip = Buffer.from(await c.req.arrayBuffer());
4287
+ } catch {
4288
+ return c.json({ ok: false, error: "could not read upload" }, 400);
4289
+ }
4290
+ if (zip.length === 0) return c.json({ ok: false, error: "empty upload" }, 400);
4291
+ if (zip.length > MAX_SKILL_ZIP_BYTES) {
4292
+ return c.json({ ok: false, error: "archive too large (max 25 MB)" }, 400);
4293
+ }
4294
+ try {
4295
+ await workspaceManager.getOrCreate(id);
4296
+ const { roots, workspaceCwd } = await rootsFor(id);
4297
+ await installSkillFromZip({ roots, scope, zip, overwrite });
4298
+ await reload(id);
4299
+ return c.json(await snapshot(id, roots, workspaceCwd));
4300
+ } catch (err2) {
4301
+ return respondError(c, err2);
4302
+ }
4303
+ });
3992
4304
  app2.put("/:id/resources/skills", async (c) => {
3993
4305
  const id = c.req.param("id");
3994
4306
  const ws = await getWorkspace(id);
@@ -4400,7 +4712,7 @@ workspacesRoute.get("/:id/export", async (c) => {
4400
4712
  }
4401
4713
  const runtime = await workspaceManager.getOrCreate(id, sessionPath || void 0);
4402
4714
  const outputPath = await runtime.session.exportToHtml();
4403
- const html = await readFile7(outputPath, "utf-8");
4715
+ const html = await readFile8(outputPath, "utf-8");
4404
4716
  const filename = basename2(outputPath);
4405
4717
  const body = { html, filename };
4406
4718
  return c.json(body);
@@ -4433,9 +4745,9 @@ workspacesRoute.post("/", async (c) => {
4433
4745
  if (!isAbsolute3(body.path)) {
4434
4746
  return c.json({ ok: false, error: "path must be absolute" }, 400);
4435
4747
  }
4436
- const resolved = resolve5(body.path);
4748
+ const resolved = resolve6(body.path);
4437
4749
  try {
4438
- const st = await stat2(resolved);
4750
+ const st = await stat3(resolved);
4439
4751
  if (!st.isDirectory()) {
4440
4752
  return c.json({ ok: false, error: "path is not a directory" }, 400);
4441
4753
  }
@@ -4476,18 +4788,18 @@ mountFilesRoute(workspacesRoute);
4476
4788
  workspacesRoute.route("/:id/tree", treeRoute);
4477
4789
 
4478
4790
  // src/api/fs.ts
4479
- import { readdir as readdir4 } from "fs/promises";
4791
+ import { readdir as readdir5 } from "fs/promises";
4480
4792
  import { homedir as homedir3 } from "os";
4481
- import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve6 } from "path";
4793
+ import { dirname as dirname5, isAbsolute as isAbsolute4, join as join14, resolve as resolve7 } from "path";
4482
4794
  import { Hono as Hono3 } from "hono";
4483
4795
  var fsRoute = new Hono3();
4484
4796
  fsRoute.get("/browse", async (c) => {
4485
4797
  const rawPath = c.req.query("path");
4486
4798
  const showHidden = c.req.query("showHidden") === "1";
4487
- const target = rawPath && isAbsolute4(rawPath) ? resolve6(rawPath) : homedir3();
4799
+ const target = rawPath && isAbsolute4(rawPath) ? resolve7(rawPath) : homedir3();
4488
4800
  let dirents;
4489
4801
  try {
4490
- dirents = await readdir4(target, { withFileTypes: true });
4802
+ dirents = await readdir5(target, { withFileTypes: true });
4491
4803
  } catch (err2) {
4492
4804
  const code = err2.code;
4493
4805
  const msg = code === "EACCES" ? "permission denied" : code === "ENOENT" ? "not found" : "read failed";
@@ -4507,7 +4819,7 @@ fsRoute.get("/browse", async (c) => {
4507
4819
  });
4508
4820
 
4509
4821
  // src/api/model-configs.ts
4510
- import { readFile as readFile8 } from "fs/promises";
4822
+ import { readFile as readFile9 } from "fs/promises";
4511
4823
  import { join as join15 } from "path";
4512
4824
  import { Hono as Hono4 } from "hono";
4513
4825
  import {
@@ -4563,7 +4875,7 @@ function modelsPath() {
4563
4875
  }
4564
4876
  async function readModelsJson() {
4565
4877
  try {
4566
- const raw = await readFile8(modelsPath(), "utf-8");
4878
+ const raw = await readFile9(modelsPath(), "utf-8");
4567
4879
  return JSON.parse(raw);
4568
4880
  } catch (err2) {
4569
4881
  if (err2?.code === "ENOENT") {
@@ -4847,6 +5159,132 @@ function isAllowedWsOrigin(origin) {
4847
5159
  return allowedWsOrigins.has(origin);
4848
5160
  }
4849
5161
 
5162
+ // src/ws/terminals.ts
5163
+ import { createRequire } from "module";
5164
+ import { accessSync, chmodSync, constants, statSync } from "fs";
5165
+ import { join as join16 } from "path";
5166
+ import { spawn as spawn2 } from "node-pty";
5167
+ var FALLBACK_SHELL = process.platform === "win32" ? "powershell.exe" : "/bin/bash";
5168
+ function send(ws, msg) {
5169
+ if (ws.readyState !== ws.OPEN) return;
5170
+ ws.send(JSON.stringify(msg));
5171
+ }
5172
+ function ptyEnv() {
5173
+ const env = {};
5174
+ for (const [k, v] of Object.entries(process.env)) {
5175
+ if (typeof v === "string") env[k] = v;
5176
+ }
5177
+ env.TERM = "xterm-256color";
5178
+ env.COLORTERM = "truecolor";
5179
+ return env;
5180
+ }
5181
+ async function handleTerminalMessage(ws, terminals, msg) {
5182
+ switch (msg.type) {
5183
+ case "terminal_open": {
5184
+ ensureSpawnHelperExecutable();
5185
+ const existing = terminals.get(msg.terminalId);
5186
+ if (existing) {
5187
+ try {
5188
+ existing.kill();
5189
+ } catch {
5190
+ }
5191
+ terminals.delete(msg.terminalId);
5192
+ }
5193
+ const ws_ = await getWorkspace(msg.workspaceId);
5194
+ if (!ws_) {
5195
+ send(ws, { type: "terminal_error", terminalId: msg.terminalId, message: "workspace not found" });
5196
+ return;
5197
+ }
5198
+ const shell = process.env.SHELL || FALLBACK_SHELL;
5199
+ let pty;
5200
+ try {
5201
+ pty = spawn2(shell, [], {
5202
+ name: "xterm-256color",
5203
+ cols: clampDim(msg.cols, 80),
5204
+ rows: clampDim(msg.rows, 24),
5205
+ cwd: ws_.path,
5206
+ env: ptyEnv()
5207
+ });
5208
+ } catch (err2) {
5209
+ const message = err2 instanceof Error ? err2.message : String(err2);
5210
+ send(ws, { type: "terminal_error", terminalId: msg.terminalId, message });
5211
+ return;
5212
+ }
5213
+ terminals.set(msg.terminalId, pty);
5214
+ pty.onData((data) => {
5215
+ send(ws, { type: "terminal_output", terminalId: msg.terminalId, data });
5216
+ });
5217
+ pty.onExit(({ exitCode }) => {
5218
+ terminals.delete(msg.terminalId);
5219
+ send(ws, { type: "terminal_exit", terminalId: msg.terminalId, exitCode });
5220
+ });
5221
+ return;
5222
+ }
5223
+ case "terminal_input": {
5224
+ terminals.get(msg.terminalId)?.write(msg.data);
5225
+ return;
5226
+ }
5227
+ case "terminal_resize": {
5228
+ const pty = terminals.get(msg.terminalId);
5229
+ if (pty) {
5230
+ try {
5231
+ pty.resize(clampDim(msg.cols, 80), clampDim(msg.rows, 24));
5232
+ } catch {
5233
+ }
5234
+ }
5235
+ return;
5236
+ }
5237
+ case "terminal_close": {
5238
+ const pty = terminals.get(msg.terminalId);
5239
+ if (pty) {
5240
+ terminals.delete(msg.terminalId);
5241
+ try {
5242
+ pty.kill();
5243
+ } catch {
5244
+ }
5245
+ }
5246
+ return;
5247
+ }
5248
+ }
5249
+ }
5250
+ function closeAllTerminals(terminals) {
5251
+ for (const pty of terminals.values()) {
5252
+ try {
5253
+ pty.kill();
5254
+ } catch {
5255
+ }
5256
+ }
5257
+ terminals.clear();
5258
+ }
5259
+ function clampDim(n, fallback) {
5260
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
5261
+ }
5262
+ var spawnHelperChecked = false;
5263
+ function ensureSpawnHelperExecutable() {
5264
+ if (spawnHelperChecked || process.platform === "win32") return;
5265
+ spawnHelperChecked = true;
5266
+ try {
5267
+ const require2 = createRequire(import.meta.url);
5268
+ const root = join16(require2.resolve("node-pty/package.json"), "..");
5269
+ const candidates = [
5270
+ join16(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"),
5271
+ join16(root, "build", "Release", "spawn-helper")
5272
+ ];
5273
+ for (const path of candidates) {
5274
+ try {
5275
+ accessSync(path, constants.X_OK);
5276
+ } catch {
5277
+ try {
5278
+ const mode = statSync(path).mode;
5279
+ chmodSync(path, mode | 73);
5280
+ } catch {
5281
+ }
5282
+ }
5283
+ }
5284
+ } catch {
5285
+ }
5286
+ }
5287
+
4850
5288
  // src/ws/hub.ts
4851
5289
  var BACKGROUND_CAP = 4;
4852
5290
  var replacementLocks = /* @__PURE__ */ new Map();
@@ -4877,7 +5315,7 @@ function attachWsHub(httpServer) {
4877
5315
  }
4878
5316
  });
4879
5317
  wss.on("connection", (ws) => {
4880
- const state = { background: /* @__PURE__ */ new Map() };
5318
+ const state = { background: /* @__PURE__ */ new Map(), terminals: /* @__PURE__ */ new Map() };
4881
5319
  let inbound = Promise.resolve();
4882
5320
  ws.on("message", (raw) => {
4883
5321
  inbound = inbound.then(async () => {
@@ -4885,14 +5323,14 @@ function attachWsHub(httpServer) {
4885
5323
  try {
4886
5324
  msg = JSON.parse(raw.toString());
4887
5325
  } catch {
4888
- send(ws, { type: "error", message: "invalid JSON" });
5326
+ send2(ws, { type: "error", message: "invalid JSON" });
4889
5327
  return;
4890
5328
  }
4891
5329
  try {
4892
5330
  await handle(ws, state, msg);
4893
5331
  } catch (err2) {
4894
5332
  const message = err2 instanceof Error ? err2.message : String(err2);
4895
- send(ws, { type: "error", message, command: msg.type });
5333
+ send2(ws, { type: "error", message, command: msg.type });
4896
5334
  }
4897
5335
  }).catch((err2) => {
4898
5336
  console.error("[ws] inbound chain error:", err2);
@@ -4912,12 +5350,12 @@ async function handle(ws, state, msg) {
4912
5350
  runtime = await workspaceManager.getOrCreate(msg.workspaceId, msg.sessionPath);
4913
5351
  } catch (err2) {
4914
5352
  const message = err2 instanceof Error ? err2.message : String(err2);
4915
- send(ws, { type: "error", message, command: "subscribe" });
4916
- send(ws, { type: "ack", command: "subscribe" });
5353
+ send2(ws, { type: "error", message, command: "subscribe" });
5354
+ send2(ws, { type: "ack", command: "subscribe" });
4917
5355
  return;
4918
5356
  }
4919
5357
  promoteToPrimary(ws, state, msg.workspaceId, runtime);
4920
- send(ws, { type: "ack", command: "subscribe" });
5358
+ send2(ws, { type: "ack", command: "subscribe" });
4921
5359
  return;
4922
5360
  }
4923
5361
  case "unsubscribe": {
@@ -4929,23 +5367,23 @@ async function handle(ws, state, msg) {
4929
5367
  case "prompt": {
4930
5368
  const primary = state.primary;
4931
5369
  if (!primary) {
4932
- send(ws, { type: "error", message: "not subscribed", command: "prompt" });
5370
+ send2(ws, { type: "error", message: "not subscribed", command: "prompt" });
4933
5371
  return;
4934
5372
  }
4935
5373
  if (replacementLocks.has(primary.workspaceId)) {
4936
- send(ws, { type: "error", message: "session switching in progress", command: "prompt" });
5374
+ send2(ws, { type: "error", message: "session switching in progress", command: "prompt" });
4937
5375
  return;
4938
5376
  }
4939
5377
  void primary.runtime.session.prompt(msg.message, { streamingBehavior: msg.streamingBehavior }).catch((err2) => {
4940
5378
  const message = err2 instanceof Error ? err2.message : String(err2);
4941
- send(ws, { type: "error", message, command: "prompt" });
5379
+ send2(ws, { type: "error", message, command: "prompt" });
4942
5380
  });
4943
5381
  return;
4944
5382
  }
4945
5383
  case "abort": {
4946
5384
  const primary = state.primary;
4947
5385
  if (!primary) {
4948
- send(ws, { type: "error", message: "not subscribed", command: "abort" });
5386
+ send2(ws, { type: "error", message: "not subscribed", command: "abort" });
4949
5387
  return;
4950
5388
  }
4951
5389
  await primary.runtime.session.abort();
@@ -4959,7 +5397,7 @@ async function handle(ws, state, msg) {
4959
5397
  runtime = await workspaceManager.createSession(workspaceId);
4960
5398
  } catch (err2) {
4961
5399
  const message = err2 instanceof Error ? err2.message : String(err2);
4962
- send(ws, { type: "error", message, command: "new_session" });
5400
+ send2(ws, { type: "error", message, command: "new_session" });
4963
5401
  return;
4964
5402
  }
4965
5403
  promoteToPrimary(ws, state, workspaceId, runtime);
@@ -4969,22 +5407,22 @@ async function handle(ws, state, msg) {
4969
5407
  case "fork": {
4970
5408
  const primary = state.primary;
4971
5409
  if (!primary) {
4972
- send(ws, { type: "error", message: "not subscribed", command: "fork" });
5410
+ send2(ws, { type: "error", message: "not subscribed", command: "fork" });
4973
5411
  return;
4974
5412
  }
4975
5413
  const workspaceId = primary.workspaceId;
4976
5414
  await withReplacementLock(workspaceId, async () => {
4977
5415
  const source = state.primary;
4978
5416
  if (!source) {
4979
- send(ws, { type: "error", message: "not subscribed", command: "fork" });
5417
+ send2(ws, { type: "error", message: "not subscribed", command: "fork" });
4980
5418
  return;
4981
5419
  }
4982
5420
  if (!source.sessionPath) {
4983
- send(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
5421
+ send2(ws, { type: "error", message: "cannot fork an unsaved session", command: "fork" });
4984
5422
  return;
4985
5423
  }
4986
5424
  if (source.runtime.session.isStreaming) {
4987
- send(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
5425
+ send2(ws, { type: "error", message: "cannot fork while streaming", command: "fork" });
4988
5426
  return;
4989
5427
  }
4990
5428
  let result;
@@ -4992,11 +5430,11 @@ async function handle(ws, state, msg) {
4992
5430
  result = await workspaceManager.fork(workspaceId, source.sessionPath, msg.entryId);
4993
5431
  } catch (err2) {
4994
5432
  const message = err2 instanceof Error ? err2.message : String(err2);
4995
- send(ws, { type: "error", message, command: "fork" });
5433
+ send2(ws, { type: "error", message, command: "fork" });
4996
5434
  return;
4997
5435
  }
4998
5436
  if (result.cancelled || !result.runtime) {
4999
- send(ws, { type: "error", message: "fork cancelled", command: "fork" });
5437
+ send2(ws, { type: "error", message: "fork cancelled", command: "fork" });
5000
5438
  return;
5001
5439
  }
5002
5440
  promoteToPrimary(ws, state, workspaceId, result.runtime);
@@ -5006,7 +5444,7 @@ async function handle(ws, state, msg) {
5006
5444
  case "answer_question": {
5007
5445
  const primary = state.primary;
5008
5446
  if (!primary) {
5009
- send(ws, { type: "error", message: "not subscribed", command: "answer_question" });
5447
+ send2(ws, { type: "error", message: "not subscribed", command: "answer_question" });
5010
5448
  return;
5011
5449
  }
5012
5450
  resolveAnswer(msg.toolCallId, msg.answer, primary.runtime.session.sessionFile ?? null);
@@ -5015,28 +5453,28 @@ async function handle(ws, state, msg) {
5015
5453
  case "navigate_tree": {
5016
5454
  const primary = state.primary;
5017
5455
  if (!primary) {
5018
- send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5456
+ send2(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5019
5457
  return;
5020
5458
  }
5021
5459
  if (msg.workspaceId !== primary.workspaceId) {
5022
- send(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
5460
+ send2(ws, { type: "error", message: "workspace mismatch", command: "navigate_tree" });
5023
5461
  return;
5024
5462
  }
5025
5463
  await withReplacementLock(primary.workspaceId, async () => {
5026
5464
  const current = state.primary;
5027
5465
  if (!current) {
5028
- send(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5466
+ send2(ws, { type: "error", message: "not subscribed", command: "navigate_tree" });
5029
5467
  return;
5030
5468
  }
5031
5469
  if (current.runtime.session.isStreaming) {
5032
- send(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
5470
+ send2(ws, { type: "error", message: "cannot navigate tree while streaming", command: "navigate_tree" });
5033
5471
  return;
5034
5472
  }
5035
5473
  const result = await current.runtime.session.navigateTree(msg.targetId, {
5036
5474
  summarize: msg.summarize,
5037
5475
  customInstructions: msg.customInstructions
5038
5476
  });
5039
- send(ws, {
5477
+ send2(ws, {
5040
5478
  type: "navigate_tree_result",
5041
5479
  workspaceId: current.workspaceId,
5042
5480
  editorText: result.editorText,
@@ -5048,27 +5486,34 @@ async function handle(ws, state, msg) {
5048
5486
  case "compact": {
5049
5487
  const primary = state.primary;
5050
5488
  if (!primary) {
5051
- send(ws, { type: "error", message: "not subscribed", command: "compact" });
5489
+ send2(ws, { type: "error", message: "not subscribed", command: "compact" });
5052
5490
  return;
5053
5491
  }
5054
5492
  if (primary.runtime.session.isStreaming) {
5055
- send(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
5493
+ send2(ws, { type: "error", message: "cannot compact while streaming", command: "compact" });
5056
5494
  return;
5057
5495
  }
5058
5496
  if (primary.runtime.session.isCompacting) {
5059
- send(ws, { type: "error", message: "compaction already in progress", command: "compact" });
5497
+ send2(ws, { type: "error", message: "compaction already in progress", command: "compact" });
5060
5498
  return;
5061
5499
  }
5062
5500
  primary.runtime.session.compact().catch((err2) => {
5063
5501
  const message = err2 instanceof Error ? err2.message : String(err2);
5064
- send(ws, { type: "error", message, command: "compact" });
5502
+ send2(ws, { type: "error", message, command: "compact" });
5065
5503
  });
5066
5504
  return;
5067
5505
  }
5506
+ case "terminal_open":
5507
+ case "terminal_input":
5508
+ case "terminal_resize":
5509
+ case "terminal_close": {
5510
+ await handleTerminalMessage(ws, state.terminals, msg);
5511
+ return;
5512
+ }
5068
5513
  default: {
5069
5514
  const _ = msg;
5070
5515
  void _;
5071
- send(ws, { type: "error", message: "unknown command" });
5516
+ send2(ws, { type: "error", message: "unknown command" });
5072
5517
  }
5073
5518
  }
5074
5519
  }
@@ -5100,7 +5545,7 @@ function bindPrimary(ws, state, workspaceId, runtime) {
5100
5545
  } else if (payload.kind === "message_update" && payload.delta.kind === "text" && assistantStartAt !== void 0 && assistantFirstTokenAt === void 0) {
5101
5546
  assistantFirstTokenAt = performance.now();
5102
5547
  }
5103
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5548
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
5104
5549
  if (payload.kind === "message_end" && payload.role === "assistant" && assistantStartAt !== void 0) {
5105
5550
  const now = performance.now();
5106
5551
  const timing = {
@@ -5108,7 +5553,7 @@ function bindPrimary(ws, state, workspaceId, runtime) {
5108
5553
  firstTokenMs: assistantFirstTokenAt !== void 0 ? Math.round(assistantFirstTokenAt - assistantStartAt) : null,
5109
5554
  totalMs: Math.round(now - assistantStartAt)
5110
5555
  };
5111
- send(ws, { type: "event", workspaceId, sessionPath, payload: timing });
5556
+ send2(ws, { type: "event", workspaceId, sessionPath, payload: timing });
5112
5557
  assistantStartAt = void 0;
5113
5558
  assistantFirstTokenAt = void 0;
5114
5559
  }
@@ -5124,16 +5569,16 @@ function bindPrimary(ws, state, workspaceId, runtime) {
5124
5569
  runtime.session.state.pendingToolCalls,
5125
5570
  scanMessages
5126
5571
  )) {
5127
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5572
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
5128
5573
  }
5129
5574
  const inFlight = inFlightAssistantSnapshot(streamingMessage);
5130
5575
  if (inFlight) {
5131
5576
  for (const payload of inFlight) {
5132
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5577
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
5133
5578
  }
5134
5579
  }
5135
5580
  for (const payload of inFlightToolCallsSnapshot(sessionPath)) {
5136
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5581
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
5137
5582
  }
5138
5583
  sendContextUsage(ws, runtime, workspaceId, sessionPath);
5139
5584
  }
@@ -5147,7 +5592,7 @@ function demotePrimaryToBackground(ws, state) {
5147
5592
  const unsubscribeSession = session.subscribe((ev) => {
5148
5593
  const payload = translatePiEvent(ev);
5149
5594
  if (!payload) return;
5150
- send(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
5595
+ send2(ws, { type: "event", workspaceId: primary.workspaceId, sessionPath, payload });
5151
5596
  if (payload.kind === "agent_end" || payload.kind === "compaction_end" || payload.kind === "session_info_changed" || payload.kind === "thinking_level_changed") {
5152
5597
  sendContextUsage(ws, primary.runtime, primary.workspaceId, sessionPath);
5153
5598
  }
@@ -5163,7 +5608,7 @@ function demotePrimaryToBackground(ws, state) {
5163
5608
  const evicted = state.background.get(oldestKey);
5164
5609
  teardownBackground(state, oldestKey, ws);
5165
5610
  if (evicted) {
5166
- send(ws, {
5611
+ send2(ws, {
5167
5612
  type: "background_evicted",
5168
5613
  workspaceId: evicted.workspaceId,
5169
5614
  sessionPath: evicted.sessionPath
@@ -5179,7 +5624,7 @@ function teardownBackground(state, runtimeKey, ws) {
5179
5624
  if (ws) unrefWorkspaceSubscriber(state, bg.workspaceId, ws);
5180
5625
  }
5181
5626
  function sendSubscribed(ws, workspaceId, runtime) {
5182
- send(ws, {
5627
+ send2(ws, {
5183
5628
  type: "subscribed",
5184
5629
  workspaceId,
5185
5630
  sessionPath: runtime.session.sessionFile ?? null,
@@ -5199,7 +5644,7 @@ function sendContextUsage(ws, runtime, workspaceId, sessionPath) {
5199
5644
  contextWindow: usage.contextWindow,
5200
5645
  percent: usage.percent
5201
5646
  };
5202
- send(ws, { type: "event", workspaceId, sessionPath, payload });
5647
+ send2(ws, { type: "event", workspaceId, sessionPath, payload });
5203
5648
  }
5204
5649
  function detachPrimary(state, ws) {
5205
5650
  const primary = state.primary;
@@ -5213,8 +5658,9 @@ function detach(state, ws) {
5213
5658
  for (const runtimeKey of [...state.background.keys()]) {
5214
5659
  teardownBackground(state, runtimeKey, ws);
5215
5660
  }
5661
+ closeAllTerminals(state.terminals);
5216
5662
  }
5217
- function send(ws, msg) {
5663
+ function send2(ws, msg) {
5218
5664
  if (ws.readyState !== ws.OPEN) return;
5219
5665
  ws.send(JSON.stringify(msg));
5220
5666
  }
@@ -5223,8 +5669,8 @@ function send(ws, msg) {
5223
5669
  configureHttpProxy();
5224
5670
  var app = new Hono6();
5225
5671
  var distDir = dirname6(fileURLToPath2(import.meta.url));
5226
- var webRoot = resolve7(process.env.PI_PILOT_WEB_ROOT ?? join16(distDir, "..", "public"));
5227
- var webIndexPath = join16(webRoot, "index.html");
5672
+ var webRoot = resolve8(process.env.PI_PILOT_WEB_ROOT ?? join17(distDir, "..", "public"));
5673
+ var webIndexPath = join17(webRoot, "index.html");
5228
5674
  var mimeTypes = {
5229
5675
  ".css": "text/css; charset=utf-8",
5230
5676
  ".html": "text/html; charset=utf-8",
@@ -5250,7 +5696,7 @@ function safeResolveWebPath(pathname) {
5250
5696
  return void 0;
5251
5697
  }
5252
5698
  const relativePath = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
5253
- const candidate = resolve7(webRoot, relativePath);
5699
+ const candidate = resolve8(webRoot, relativePath);
5254
5700
  if (candidate !== webRoot && !candidate.startsWith(`${webRoot}${sep3}`)) {
5255
5701
  return void 0;
5256
5702
  }
@@ -5258,7 +5704,7 @@ function safeResolveWebPath(pathname) {
5258
5704
  }
5259
5705
  async function readWebFile(path) {
5260
5706
  try {
5261
- return await readFile9(path);
5707
+ return await readFile10(path);
5262
5708
  } catch (err2) {
5263
5709
  const code = err2.code;
5264
5710
  if (code === "ENOENT" || code === "EISDIR") return void 0;
@@ -5271,7 +5717,7 @@ async function serveWeb(c) {
5271
5717
  const assetPath = safeResolveWebPath(pathname);
5272
5718
  if (!assetPath) return c.text("invalid asset path", 400);
5273
5719
  const asset = await readWebFile(assetPath);
5274
- const body = asset ?? await readFile9(webIndexPath);
5720
+ const body = asset ?? await readFile10(webIndexPath);
5275
5721
  const filePath = asset ? assetPath : webIndexPath;
5276
5722
  const headers = {
5277
5723
  "Content-Type": mimeTypes[extname(filePath)] ?? "application/octet-stream",