@computesdk/loops 0.1.1 → 0.1.3

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.
Files changed (75) hide show
  1. package/API.md +35 -5
  2. package/README.md +67 -21
  3. package/bin/loops.mjs +13 -4
  4. package/bin/pi-rpc-startup.mjs +38 -0
  5. package/bin/pi-rpc.mjs +27 -0
  6. package/client.ts +16 -1
  7. package/deploy/loops.service.example +24 -0
  8. package/dist/assets/{abnfDiagram-VCTEODGH-CceVg1jC.js → abnfDiagram-VCTEODGH-BjWORKhB.js} +1 -1
  9. package/dist/assets/{arc-CurbUKc7.js → arc-iaXuj2E3.js} +1 -1
  10. package/dist/assets/{architectureDiagram-5GKGNRK7-BG6bMida.js → architectureDiagram-5GKGNRK7-BGYlRe5d.js} +1 -1
  11. package/dist/assets/{blockDiagram-I7D4REHJ-B11cpZbJ.js → blockDiagram-I7D4REHJ-DbRX-Eko.js} +1 -1
  12. package/dist/assets/{c4Diagram-7LVT6UL2-DiXBjlUf.js → c4Diagram-7LVT6UL2-DFNI3QWM.js} +1 -1
  13. package/dist/assets/channel-mVgxi6SM.js +1 -0
  14. package/dist/assets/{chunk-2Q5K7J3B-DMtau_ZO.js → chunk-2Q5K7J3B-DqhjvffG.js} +1 -1
  15. package/dist/assets/{chunk-5VM5RSS4-CkDw9Dwr.js → chunk-5VM5RSS4-pskYIVgB.js} +1 -1
  16. package/dist/assets/{chunk-F27PBJKO-DduXbVaR.js → chunk-F27PBJKO-CQcyYiby.js} +1 -1
  17. package/dist/assets/{chunk-IMKFNOWR-CA3FEpcX.js → chunk-IMKFNOWR-B0MpoITM.js} +1 -1
  18. package/dist/assets/{chunk-JWPE2WC7-_aPFiznH.js → chunk-JWPE2WC7-BsNxH0py.js} +1 -1
  19. package/dist/assets/{chunk-POPQ4Y6H-BJ1FxtOa.js → chunk-POPQ4Y6H-VnAuAhI3.js} +1 -1
  20. package/dist/assets/{chunk-SVP7TREG-BISx4Qsv.js → chunk-SVP7TREG-fiQQ7YON.js} +1 -1
  21. package/dist/assets/{chunk-TICWLB2K-jN0ScesT.js → chunk-TICWLB2K-agM_rWnc.js} +1 -1
  22. package/dist/assets/{chunk-XXDRQBXY-Bp2w1xsm.js → chunk-XXDRQBXY-BrnsKK60.js} +1 -1
  23. package/dist/assets/classDiagram-ZZMXUADV-BNg4EoCj.js +1 -0
  24. package/dist/assets/classDiagram-v2-VYDZK3BY-BNg4EoCj.js +1 -0
  25. package/dist/assets/{cose-bilkent-JH36ORCC-RJxH4pHE.js → cose-bilkent-JH36ORCC-skVRi8C0.js} +1 -1
  26. package/dist/assets/{cynefin-OW5HDTMX-RQb2d53E.js → cynefin-OW5HDTMX-sugAAQ5D.js} +1 -1
  27. package/dist/assets/{cynefinDiagram-5FMLGOSQ-BkWuDtvv.js → cynefinDiagram-5FMLGOSQ-BTjQ5HD4.js} +1 -1
  28. package/dist/assets/{dagre-GXQ25YYZ-Dw_p1MY7.js → dagre-GXQ25YYZ-wS0CVEvh.js} +1 -1
  29. package/dist/assets/{diagram-S7CK7UJ4-CNvafPnD.js → diagram-S7CK7UJ4-0P_U489Q.js} +1 -1
  30. package/dist/assets/{diagram-UQ7AKVKN-Ca50sGck.js → diagram-UQ7AKVKN-D85SHcZz.js} +1 -1
  31. package/dist/assets/{diagram-VSXAHHWV-B9EkCAhx.js → diagram-VSXAHHWV-CozaqKLI.js} +1 -1
  32. package/dist/assets/{diagram-VX7I27RA-Be2j3KkX.js → diagram-VX7I27RA-kzpgzRzP.js} +1 -1
  33. package/dist/assets/{diagram-Z3DM3KII-Iki9IPof.js → diagram-Z3DM3KII-DeIAvld9.js} +1 -1
  34. package/dist/assets/{ebnfDiagram-PWID7BFC-C_DacKX3.js → ebnfDiagram-PWID7BFC-Cvo7CZD4.js} +1 -1
  35. package/dist/assets/{erDiagram-RLTQ6QDP-CN090mdT.js → erDiagram-RLTQ6QDP-BILowZs8.js} +1 -1
  36. package/dist/assets/{flowDiagram-HODETNUW-z9R5Hzp_.js → flowDiagram-HODETNUW-D58_1bOZ.js} +1 -1
  37. package/dist/assets/{ganttDiagram-EL5Y4UJY-Ctsoo8fA.js → ganttDiagram-EL5Y4UJY-Wc2egLDH.js} +1 -1
  38. package/dist/assets/{gitGraphDiagram-WWUBYQGX-BAfnTLBI.js → gitGraphDiagram-WWUBYQGX-prvx2Pvu.js} +1 -1
  39. package/dist/assets/{highlighted-body-JLMTFACH-JGCpsH-e.js → highlighted-body-JLMTFACH-D3B11uXh.js} +1 -1
  40. package/dist/assets/index-C68b-bL6.css +1 -0
  41. package/dist/assets/index-CoHgGrA6.js +900 -0
  42. package/dist/assets/{infoDiagram-27XIBGKW-BMe5_1z8.js → infoDiagram-27XIBGKW-S1_RXMDl.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-5VMMS53U-BRSqbbUo.js → ishikawaDiagram-5VMMS53U-DPVVwIcA.js} +1 -1
  44. package/dist/assets/{journeyDiagram-3NMN7TZE-WkAorMt-.js → journeyDiagram-3NMN7TZE-Dmlf7Ej8.js} +1 -1
  45. package/dist/assets/{kanban-definition-UXKFOSKX-aa6aiAN2.js → kanban-definition-UXKFOSKX-D7SJAgUZ.js} +1 -1
  46. package/dist/assets/{layout-BDC_N0BU.js → layout-DWrir7bj.js} +1 -1
  47. package/dist/assets/{linear-CznrJ42T.js → linear-B7B0s6Fb.js} +1 -1
  48. package/dist/assets/{mindmap-definition-YA3MSWOX-CW6ybBUC.js → mindmap-definition-YA3MSWOX-CvytaBvR.js} +1 -1
  49. package/dist/assets/{pegDiagram-XKGWAZYB-Dr3qzrfb.js → pegDiagram-XKGWAZYB-unjus9we.js} +1 -1
  50. package/dist/assets/{pieDiagram-E7YTZNPT-BkgMYztr.js → pieDiagram-E7YTZNPT-Bc0mRNN1.js} +1 -1
  51. package/dist/assets/{quadrantDiagram-AXDQQJYC-BTswMqBf.js → quadrantDiagram-AXDQQJYC-DCMBYddi.js} +1 -1
  52. package/dist/assets/{railroadDiagram-O6MQD6OU-DVCTkg3c.js → railroadDiagram-O6MQD6OU-Bbb5CPfd.js} +1 -1
  53. package/dist/assets/{requirementDiagram-BXWQKSXE-DDSFlh61.js → requirementDiagram-BXWQKSXE-C8qmEESJ.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-P5KCCOFB-DHP89LFt.js → sankeyDiagram-P5KCCOFB-ChzXGPH8.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-WJ2MYXX4-CUcSveH3.js → sequenceDiagram-WJ2MYXX4-BeKUAzAe.js} +1 -1
  56. package/dist/assets/{sizeCapture-INFHLROL-fGlaZz_7.js → sizeCapture-INFHLROL-YJiYQ0Mn.js} +1 -1
  57. package/dist/assets/{stateDiagram-D77RDMKH-DqcVPNAG.js → stateDiagram-D77RDMKH-D6NvtAkq.js} +1 -1
  58. package/dist/assets/stateDiagram-v2-MP3YSRHH-C9eBMjpA.js +1 -0
  59. package/dist/assets/{swimlanes-42K2YHIH-L-pgkzCN.js → swimlanes-42K2YHIH-Cjb-j5cm.js} +1 -1
  60. package/dist/assets/swimlanesDiagram-VR7AAH4N-tvNn9kix.js +8 -0
  61. package/dist/assets/{timeline-definition-24CTP7MA-C__pbpoz.js → timeline-definition-24CTP7MA-CaBJ4VuK.js} +1 -1
  62. package/dist/assets/{vennDiagram-4TSXK5OY-B4f-azPR.js → vennDiagram-4TSXK5OY-D8MLav5x.js} +1 -1
  63. package/dist/assets/{wardleyDiagram-VM6X3IG4-01ZelHA6.js → wardleyDiagram-VM6X3IG4-CSy7rn-Q.js} +1 -1
  64. package/dist/assets/{xychartDiagram-S5SC5T6Z-CNfj8huj.js → xychartDiagram-S5SC5T6Z-BtRqCsaE.js} +1 -1
  65. package/dist/index.html +2 -2
  66. package/dist/server.mjs +661 -312
  67. package/package.json +5 -3
  68. package/src/shared/types.ts +1 -1
  69. package/dist/assets/channel-Cy2zP8Uh.js +0 -1
  70. package/dist/assets/classDiagram-ZZMXUADV-BHTKitbG.js +0 -1
  71. package/dist/assets/classDiagram-v2-VYDZK3BY-BHTKitbG.js +0 -1
  72. package/dist/assets/index-DkxpcXIf.js +0 -900
  73. package/dist/assets/index-NzHlk7dF.css +0 -1
  74. package/dist/assets/stateDiagram-v2-MP3YSRHH-_t9xbbjh.js +0 -1
  75. package/dist/assets/swimlanesDiagram-VR7AAH4N-6eL6UTv7.js +0 -8
package/dist/server.mjs CHANGED
@@ -1,10 +1,38 @@
1
1
  // server/index.ts
2
- import fs8 from "node:fs";
3
- import { randomBytes } from "node:crypto";
2
+ import fs9 from "node:fs";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
4
  import http from "node:http";
5
- import path8 from "node:path";
5
+ import path9 from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { execFile, execFileSync as execFileSync2, spawn as spawn6 } from "node:child_process";
7
+ import { execFile as execFile2, execFileSync as execFileSync2, spawn as spawn6 } from "node:child_process";
8
+
9
+ // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/http-exception.js
10
+ var HTTPException = class extends Error {
11
+ res;
12
+ status;
13
+ /**
14
+ * Creates an instance of `HTTPException`.
15
+ * @param status - HTTP status code for the exception. Defaults to 500.
16
+ * @param options - Additional options for the exception.
17
+ */
18
+ constructor(status = 500, options) {
19
+ super(options?.message, { cause: options?.cause });
20
+ this.res = options?.res;
21
+ this.status = status;
22
+ }
23
+ /**
24
+ * Returns the response object associated with the exception.
25
+ * If a response object is not provided, a new response is created with the error message and status code.
26
+ * @returns The response object.
27
+ */
28
+ getResponse() {
29
+ if (this.res) return new Response(this.res.body, {
30
+ status: this.status,
31
+ headers: this.res.headers
32
+ });
33
+ return new Response(this.message, { status: this.status });
34
+ }
35
+ };
8
36
 
9
37
  // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/request/constants.js
10
38
  var GET_MATCH_RESULT = /* @__PURE__ */ Symbol();
@@ -79,26 +107,26 @@ var throwNestingLimitExceeded = () => {
79
107
  };
80
108
 
81
109
  // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/utils/url.js
82
- var splitPath = (path9) => {
83
- const paths = path9.split("/");
110
+ var splitPath = (path10) => {
111
+ const paths = path10.split("/");
84
112
  if (paths[0] === "") paths.shift();
85
113
  return paths;
86
114
  };
87
115
  var splitRoutingPath = (routePath) => {
88
- const { groups, path: path9 } = extractGroupsFromPath(routePath);
89
- const paths = splitPath(path9);
116
+ const { groups, path: path10 } = extractGroupsFromPath(routePath);
117
+ const paths = splitPath(path10);
90
118
  return replaceGroupMarks(paths, groups);
91
119
  };
92
- var extractGroupsFromPath = (path9) => {
120
+ var extractGroupsFromPath = (path10) => {
93
121
  const groups = [];
94
- path9 = path9.replace(/\{[^}]+\}/g, (match2, index) => {
122
+ path10 = path10.replace(/\{[^}]+\}/g, (match2, index) => {
95
123
  const mark = `@${index}`;
96
124
  groups.push([mark, match2]);
97
125
  return mark;
98
126
  });
99
127
  return {
100
128
  groups,
101
- path: path9
129
+ path: path10
102
130
  };
103
131
  };
104
132
  var replaceGroupMarks = (paths, groups) => {
@@ -161,8 +189,8 @@ var getPath = (request) => {
161
189
  const queryIndex = url.indexOf("?", i);
162
190
  const hashIndex = url.indexOf("#", i);
163
191
  const end = queryIndex === -1 ? hashIndex === -1 ? void 0 : hashIndex : hashIndex === -1 ? queryIndex : Math.min(queryIndex, hashIndex);
164
- const path9 = url.slice(start, end);
165
- return tryDecodeURI(path9.includes("%25") ? path9.replace(/%25/g, "%2525") : path9);
192
+ const path10 = url.slice(start, end);
193
+ return tryDecodeURI(path10.includes("%25") ? path10.replace(/%25/g, "%2525") : path10);
166
194
  } else if (charCode === 63 || charCode === 35) break;
167
195
  }
168
196
  return url.slice(start, i);
@@ -175,9 +203,9 @@ var mergePath = (base, sub, ...rest) => {
175
203
  if (rest.length) sub = mergePath(sub, ...rest);
176
204
  return `${base?.[0] === "/" ? "" : "/"}${base}${sub === "/" ? "" : `${base?.at(-1) === "/" ? "" : "/"}${sub?.[0] === "/" ? sub.slice(1) : sub}`}`;
177
205
  };
178
- var checkOptionalParameter = (path9) => {
179
- if (path9.charCodeAt(path9.length - 1) !== 63 || !path9.includes(":")) return null;
180
- const segments = path9.split("/");
206
+ var checkOptionalParameter = (path10) => {
207
+ if (path10.charCodeAt(path10.length - 1) !== 63 || !path10.includes(":")) return null;
208
+ const segments = path10.split("/");
181
209
  const results = [];
182
210
  let basePath = "";
183
211
  segments.forEach((segment) => {
@@ -288,9 +316,9 @@ var HonoRequest = class {
288
316
  */
289
317
  path;
290
318
  bodyCache = {};
291
- constructor(request, path9 = "/", matchResult = [[]]) {
319
+ constructor(request, path10 = "/", matchResult = [[]]) {
292
320
  this.raw = request;
293
- this.path = path9;
321
+ this.path = path10;
294
322
  this.#matchResult = matchResult;
295
323
  }
296
324
  param(key) {
@@ -1020,8 +1048,8 @@ var Hono = class Hono2 {
1020
1048
  return this;
1021
1049
  };
1022
1050
  });
1023
- this.on = (method, path9, ...handlers) => {
1024
- for (const p of [path9].flat()) {
1051
+ this.on = (method, path10, ...handlers) => {
1052
+ for (const p of [path10].flat()) {
1025
1053
  this.#path = p;
1026
1054
  for (const m of [method].flat()) {
1027
1055
  const methodName = m.toUpperCase();
@@ -1075,8 +1103,8 @@ var Hono = class Hono2 {
1075
1103
  * app.route("/api", app2) // GET /api/user
1076
1104
  * ```
1077
1105
  */
1078
- route(path9, app2) {
1079
- const subApp = this.basePath(path9);
1106
+ route(path10, app2) {
1107
+ const subApp = this.basePath(path10);
1080
1108
  app2.routes.map((r) => {
1081
1109
  let handler;
1082
1110
  if (app2.errorHandler === errorHandler) handler = r.handler;
@@ -1101,9 +1129,9 @@ var Hono = class Hono2 {
1101
1129
  * const api = new Hono().basePath('/api')
1102
1130
  * ```
1103
1131
  */
1104
- basePath(path9) {
1132
+ basePath(path10) {
1105
1133
  const subApp = this.#clone();
1106
- subApp._basePath = mergePath(this._basePath, path9);
1134
+ subApp._basePath = mergePath(this._basePath, path10);
1107
1135
  return subApp;
1108
1136
  }
1109
1137
  /**
@@ -1179,7 +1207,7 @@ var Hono = class Hono2 {
1179
1207
  * })
1180
1208
  * ```
1181
1209
  */
1182
- mount(path9, applicationHandler, options) {
1210
+ mount(path10, applicationHandler, options) {
1183
1211
  let replaceRequest;
1184
1212
  let optionHandler;
1185
1213
  if (options) {
@@ -1202,7 +1230,7 @@ var Hono = class Hono2 {
1202
1230
  return [c.env, executionContext];
1203
1231
  };
1204
1232
  replaceRequest ||= (() => {
1205
- const mergedPath = mergePath(this._basePath, path9);
1233
+ const mergedPath = mergePath(this._basePath, path10);
1206
1234
  const pathPrefixLength = mergedPath === "/" ? 0 : mergedPath.length;
1207
1235
  return (request) => {
1208
1236
  const url = new URL(request.url);
@@ -1215,18 +1243,18 @@ var Hono = class Hono2 {
1215
1243
  if (res) return res;
1216
1244
  await next();
1217
1245
  };
1218
- this.#addRoute("ALL", mergePath(path9, "*"), handler);
1246
+ this.#addRoute("ALL", mergePath(path10, "*"), handler);
1219
1247
  return this;
1220
1248
  }
1221
- #addRoute(method, path9, handler, baseRoutePath) {
1222
- path9 = mergePath(this._basePath, path9);
1249
+ #addRoute(method, path10, handler, baseRoutePath) {
1250
+ path10 = mergePath(this._basePath, path10);
1223
1251
  const r = {
1224
1252
  basePath: baseRoutePath !== void 0 ? mergePath(this._basePath, baseRoutePath) : this._basePath,
1225
- path: path9,
1253
+ path: path10,
1226
1254
  method,
1227
1255
  handler
1228
1256
  };
1229
- this.router.add(method, path9, [handler, r]);
1257
+ this.router.add(method, path10, [handler, r]);
1230
1258
  this.routes.push(r);
1231
1259
  }
1232
1260
  #handleError(err, c) {
@@ -1235,10 +1263,10 @@ var Hono = class Hono2 {
1235
1263
  }
1236
1264
  #dispatch(request, executionCtx, env, method) {
1237
1265
  if (method === "HEAD") return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
1238
- const path9 = this.getPath(request, { env });
1239
- const matchResult = this.router.match(method, path9);
1266
+ const path10 = this.getPath(request, { env });
1267
+ const matchResult = this.router.match(method, path10);
1240
1268
  const c = new Context(request, {
1241
- path: path9,
1269
+ path: path10,
1242
1270
  matchResult,
1243
1271
  env,
1244
1272
  executionCtx,
@@ -1326,19 +1354,19 @@ var createNullObject = () => /* @__PURE__ */ Object.create(null);
1326
1354
 
1327
1355
  // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/router/reg-exp-router/matcher.js
1328
1356
  var emptyParam = [];
1329
- function match(method, path9) {
1357
+ function match(method, path10) {
1330
1358
  const matchers = this.buildAllMatchers();
1331
- const match2 = ((method2, path10) => {
1359
+ const match2 = ((method2, path11) => {
1332
1360
  const matcher = matchers[method2] || matchers["ALL"];
1333
- const staticMatch = matcher[2][path10];
1361
+ const staticMatch = matcher[2][path11];
1334
1362
  if (staticMatch) return staticMatch;
1335
- const match3 = path10.match(matcher[0]);
1363
+ const match3 = path11.match(matcher[0]);
1336
1364
  if (!match3) return [[], emptyParam];
1337
1365
  const index = match3.indexOf("", 1);
1338
1366
  return [matcher[1][index], match3];
1339
1367
  });
1340
1368
  this.match = match2;
1341
- return match2(method, path9);
1369
+ return match2(method, path10);
1342
1370
  }
1343
1371
 
1344
1372
  // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/router/reg-exp-router/node.js
@@ -1428,14 +1456,14 @@ var Trie = class {
1428
1456
  #root = new Node();
1429
1457
  #index = 0;
1430
1458
  paths = createNullObject();
1431
- insert(path9, isStatic) {
1459
+ insert(path10, isStatic) {
1432
1460
  if (isStatic) {
1433
- this.#root.insert(path9.split(""), 0, [], this.#context, true);
1461
+ this.#root.insert(path10.split(""), 0, [], this.#context, true);
1434
1462
  return;
1435
1463
  }
1436
1464
  const paramAssoc = [];
1437
1465
  const groups = [];
1438
- let markedPath = path9;
1466
+ let markedPath = path10;
1439
1467
  for (let i = 0; ; ) {
1440
1468
  let replaced = false;
1441
1469
  markedPath = markedPath.replace(/\{[^}]+\}/g, (m) => {
@@ -1456,7 +1484,7 @@ var Trie = class {
1456
1484
  }
1457
1485
  }
1458
1486
  this.#root.insert(tokens, this.#index, paramAssoc, this.#context, false);
1459
- this.paths[path9] = [this.#index++, paramAssoc];
1487
+ this.paths[path10] = [this.#index++, paramAssoc];
1460
1488
  }
1461
1489
  buildRegExp() {
1462
1490
  let regexp = this.#root.buildRegExpStr();
@@ -1489,11 +1517,11 @@ var Trie = class {
1489
1517
 
1490
1518
  // ../../node_modules/.pnpm/hono@4.13.14/node_modules/hono/dist/router/reg-exp-router/router.js
1491
1519
  var wildcardRegExpCache = createNullObject();
1492
- function buildWildcardRegExp(path9) {
1493
- return wildcardRegExpCache[path9] ??= new RegExp(`^${path9.replace(/\/:[^/{}]+(?:\{\[\^\/]\+})?(?=[/{]|$)|\/?\*$|([.\\+*[^\]$()?{}|])/g, (match2, metaChar) => metaChar ? `\\${metaChar}` : match2 === "/*" ? TAIL_WILDCARD_REG_EXP_STR : match2 === "*" ? ".*" : `/:${LABEL_REG_EXP_STR}`)}$`);
1520
+ function buildWildcardRegExp(path10) {
1521
+ return wildcardRegExpCache[path10] ??= new RegExp(`^${path10.replace(/\/:[^/{}]+(?:\{\[\^\/]\+})?(?=[/{]|$)|\/?\*$|([.\\+*[^\]$()?{}|])/g, (match2, metaChar) => metaChar ? `\\${metaChar}` : match2 === "/*" ? TAIL_WILDCARD_REG_EXP_STR : match2 === "*" ? ".*" : `/:${LABEL_REG_EXP_STR}`)}$`);
1494
1522
  }
1495
- function findMiddleware(middleware, path9) {
1496
- for (const k of Object.keys(middleware).sort((a, b) => b.length - a.length)) if (buildWildcardRegExp(k).test(path9)) return [...middleware[k]];
1523
+ function findMiddleware(middleware, path10) {
1524
+ for (const k of Object.keys(middleware).sort((a, b) => b.length - a.length)) if (buildWildcardRegExp(k).test(path10)) return [...middleware[k]];
1497
1525
  }
1498
1526
  var RegExpRouter = class {
1499
1527
  name = "RegExpRouter";
@@ -1505,14 +1533,14 @@ var RegExpRouter = class {
1505
1533
  this.#routes = { ["ALL"]: createNullObject() };
1506
1534
  this.#tries = { ["ALL"]: new Trie() };
1507
1535
  }
1508
- #insertPath(method, path9) {
1536
+ #insertPath(method, path10) {
1509
1537
  try {
1510
- this.#tries[method].insert(path9, !/\*|\/:/.test(path9));
1538
+ this.#tries[method].insert(path10, !/\*|\/:/.test(path10));
1511
1539
  } catch (e) {
1512
- throw e === PATH_ERROR ? new UnsupportedPathError(path9) : e;
1540
+ throw e === PATH_ERROR ? new UnsupportedPathError(path10) : e;
1513
1541
  }
1514
1542
  }
1515
- add(method, path9, handler) {
1543
+ add(method, path10, handler) {
1516
1544
  const middleware = this.#middleware;
1517
1545
  const routes = this.#routes;
1518
1546
  if (!middleware) throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
@@ -1526,24 +1554,24 @@ var RegExpRouter = class {
1526
1554
  }
1527
1555
  }
1528
1556
  }
1529
- if (path9 === "/*") path9 = "*";
1557
+ if (path10 === "/*") path10 = "*";
1530
1558
  const methods = method === "ALL" ? Object.keys(middleware) : [method];
1531
- if (/\*$/.test(path9)) {
1532
- const re = buildWildcardRegExp(path9);
1533
- for (const m of methods) if (!middleware[m][path9]) {
1534
- this.#insertPath(m, path9);
1535
- middleware[m][path9] = findMiddleware(middleware[m], path9) || findMiddleware(middleware["ALL"], path9) || [];
1559
+ if (/\*$/.test(path10)) {
1560
+ const re = buildWildcardRegExp(path10);
1561
+ for (const m of methods) if (!middleware[m][path10]) {
1562
+ this.#insertPath(m, path10);
1563
+ middleware[m][path10] = findMiddleware(middleware[m], path10) || findMiddleware(middleware["ALL"], path10) || [];
1536
1564
  }
1537
- for (const handlerMap of [middleware, routes]) for (const m of methods) for (const p in handlerMap[m]) re.test(p) && handlerMap[m][p].push([handler, path9]);
1565
+ for (const handlerMap of [middleware, routes]) for (const m of methods) for (const p in handlerMap[m]) re.test(p) && handlerMap[m][p].push([handler, path10]);
1538
1566
  return;
1539
1567
  }
1540
- const paths = checkOptionalParameter(path9) || [path9];
1541
- for (const path10 of paths) for (const m of methods) {
1542
- if (!routes[m][path10]) {
1543
- this.#insertPath(m, path10);
1544
- routes[m][path10] = findMiddleware(middleware[m], path10) || findMiddleware(middleware["ALL"], path10) || [];
1568
+ const paths = checkOptionalParameter(path10) || [path10];
1569
+ for (const path11 of paths) for (const m of methods) {
1570
+ if (!routes[m][path11]) {
1571
+ this.#insertPath(m, path11);
1572
+ routes[m][path11] = findMiddleware(middleware[m], path11) || findMiddleware(middleware["ALL"], path11) || [];
1545
1573
  }
1546
- routes[m][path10].push([handler, path10]);
1574
+ routes[m][path11].push([handler, path11]);
1547
1575
  }
1548
1576
  }
1549
1577
  match = match;
@@ -1561,11 +1589,11 @@ var RegExpRouter = class {
1561
1589
  const staticMap = createNullObject();
1562
1590
  const handlerData = [];
1563
1591
  const [regexp, indexReplacementMap, paramReplacementMap] = trie.buildRegExp();
1564
- for (const r of [middleware, routes]) for (const path9 in r) {
1565
- const handlers = r[path9];
1566
- const pathData = trie.paths[path9];
1592
+ for (const r of [middleware, routes]) for (const path10 in r) {
1593
+ const handlers = r[path10];
1594
+ const pathData = trie.paths[path10];
1567
1595
  if (!pathData) {
1568
- staticMap[path9] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
1596
+ staticMap[path10] = [handlers.map(([h]) => [h, createNullObject()]), emptyParam];
1569
1597
  continue;
1570
1598
  }
1571
1599
  handlerData[pathData[0]] = handlers.map(([h, handlerPath]) => [h, trie.paths[handlerPath][1].reduceRight((map, [key], i) => {
@@ -1589,15 +1617,15 @@ var SmartRouter = class {
1589
1617
  constructor(init) {
1590
1618
  this.#routers = init.routers;
1591
1619
  }
1592
- add(method, path9, handler) {
1620
+ add(method, path10, handler) {
1593
1621
  if (!this.#routes) throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT);
1594
1622
  this.#routes.push([
1595
1623
  method,
1596
- path9,
1624
+ path10,
1597
1625
  handler
1598
1626
  ]);
1599
1627
  }
1600
- match(method, path9) {
1628
+ match(method, path10) {
1601
1629
  if (!this.#routes) throw new Error("Fatal error");
1602
1630
  const routers = this.#routers;
1603
1631
  const routes = this.#routes;
@@ -1608,7 +1636,7 @@ var SmartRouter = class {
1608
1636
  const router = routers[i];
1609
1637
  try {
1610
1638
  for (let i2 = 0, len2 = routes.length; i2 < len2; i2++) router.add(...routes[i2]);
1611
- res = router.match(method, path9);
1639
+ res = router.match(method, path10);
1612
1640
  } catch (e) {
1613
1641
  if (e instanceof UnsupportedPathError) continue;
1614
1642
  throw e;
@@ -1637,9 +1665,9 @@ var Node3 = class Node4 {
1637
1665
  #patterns = [];
1638
1666
  #pattern;
1639
1667
  #params = emptyParams;
1640
- insert(method, path9, handler) {
1668
+ insert(method, path10, handler) {
1641
1669
  let curNode = this;
1642
- const parts = splitRoutingPath(path9);
1670
+ const parts = splitRoutingPath(path10);
1643
1671
  const possibleKeys = /* @__PURE__ */ new Set();
1644
1672
  let i = 0;
1645
1673
  for (const p of parts) {
@@ -1675,11 +1703,11 @@ var Node3 = class Node4 {
1675
1703
  }
1676
1704
  }
1677
1705
  }
1678
- search(method, path9) {
1706
+ search(method, path10) {
1679
1707
  const handlerSets = [];
1680
1708
  this.#params = emptyParams;
1681
1709
  let curNodes = [this];
1682
- const parts = splitPath(path9);
1710
+ const parts = splitPath(path10);
1683
1711
  const curNodesQueue = [];
1684
1712
  const len = parts.length;
1685
1713
  let partOffsets = null;
@@ -1715,13 +1743,13 @@ var Node3 = class Node4 {
1715
1743
  if (matcher !== true) {
1716
1744
  if (!partOffsets) {
1717
1745
  partOffsets = [];
1718
- let offset = path9[0] === "/" ? 1 : 0;
1746
+ let offset = path10[0] === "/" ? 1 : 0;
1719
1747
  for (let p = 0; p < len; p++) {
1720
1748
  partOffsets[p] = offset;
1721
1749
  offset += parts[p].length + 1;
1722
1750
  }
1723
1751
  }
1724
- const restPathString = path9.slice(partOffsets[i]);
1752
+ const restPathString = path10.slice(partOffsets[i]);
1725
1753
  const m = matcher.exec(restPathString);
1726
1754
  if (m) {
1727
1755
  if (!Object.hasOwn(params, name)) params[name] = m[0];
@@ -1762,11 +1790,11 @@ var Node3 = class Node4 {
1762
1790
  var TrieRouter = class {
1763
1791
  name = "TrieRouter";
1764
1792
  #node = new Node3();
1765
- add(method, path9, handler) {
1766
- for (const result of checkOptionalParameter(path9) || [path9]) this.#node.insert(method, result, handler);
1793
+ add(method, path10, handler) {
1794
+ for (const result of checkOptionalParameter(path10) || [path10]) this.#node.insert(method, result, handler);
1767
1795
  }
1768
- match(method, path9) {
1769
- return this.#node.search(method, path9);
1796
+ match(method, path10) {
1797
+ return this.#node.search(method, path10);
1770
1798
  }
1771
1799
  };
1772
1800
 
@@ -2347,19 +2375,19 @@ var flushHeaders = (outgoing) => {
2347
2375
  }
2348
2376
  };
2349
2377
  var responseViaCache = async (res, outgoing) => {
2350
- let [status, body, header] = res[cacheKey];
2378
+ let [status, body, header2] = res[cacheKey];
2351
2379
  let hasContentLength = false;
2352
- if (!header) {
2353
- header = { "content-type": "text/plain; charset=UTF-8" };
2354
- } else if (header instanceof Headers) {
2355
- hasContentLength = header.has("content-length");
2356
- header = buildOutgoingHttpHeaders(header);
2357
- } else if (Array.isArray(header)) {
2358
- const headerObj = new Headers(header);
2380
+ if (!header2) {
2381
+ header2 = { "content-type": "text/plain; charset=UTF-8" };
2382
+ } else if (header2 instanceof Headers) {
2383
+ hasContentLength = header2.has("content-length");
2384
+ header2 = buildOutgoingHttpHeaders(header2);
2385
+ } else if (Array.isArray(header2)) {
2386
+ const headerObj = new Headers(header2);
2359
2387
  hasContentLength = headerObj.has("content-length");
2360
- header = buildOutgoingHttpHeaders(headerObj);
2388
+ header2 = buildOutgoingHttpHeaders(headerObj);
2361
2389
  } else {
2362
- for (const key in header) {
2390
+ for (const key in header2) {
2363
2391
  if (key.length === 14 && key.toLowerCase() === "content-length") {
2364
2392
  hasContentLength = true;
2365
2393
  break;
@@ -2368,14 +2396,14 @@ var responseViaCache = async (res, outgoing) => {
2368
2396
  }
2369
2397
  if (!hasContentLength) {
2370
2398
  if (typeof body === "string") {
2371
- header["Content-Length"] = Buffer.byteLength(body);
2399
+ header2["Content-Length"] = Buffer.byteLength(body);
2372
2400
  } else if (body instanceof Uint8Array) {
2373
- header["Content-Length"] = body.byteLength;
2401
+ header2["Content-Length"] = body.byteLength;
2374
2402
  } else if (body instanceof Blob) {
2375
- header["Content-Length"] = body.size;
2403
+ header2["Content-Length"] = body.size;
2376
2404
  }
2377
2405
  }
2378
- outgoing.writeHead(status, header);
2406
+ outgoing.writeHead(status, header2);
2379
2407
  if (typeof body === "string" || body instanceof Uint8Array) {
2380
2408
  outgoing.end(body);
2381
2409
  } else if (body instanceof Blob) {
@@ -2572,6 +2600,9 @@ var PiRpc = class {
2572
2600
  options;
2573
2601
  constructor(options) {
2574
2602
  this.options = options;
2603
+ if (options.onEvent) this.listeners.add(options.onEvent);
2604
+ if (options.onUiRequest) this.uiListeners.add(options.onUiRequest);
2605
+ if (options.onExit) this.exitListeners.add(options.onExit);
2575
2606
  }
2576
2607
  get running() {
2577
2608
  return !!this.proc && !this.exited;
@@ -2584,12 +2615,16 @@ var PiRpc = class {
2584
2615
  }
2585
2616
  async start() {
2586
2617
  if (this.proc) return;
2587
- const proc = spawn(this.options.command ?? "pi", ["--mode", "rpc", ...this.options.args ?? []], {
2618
+ const command = this.options.command ?? "pi";
2619
+ const script = /\.m?js$/i.test(command);
2620
+ const proc = spawn(script ? process.execPath : command, [...script ? [command] : [], "--mode", "rpc", ...this.options.args ?? []], {
2588
2621
  cwd: this.options.cwd,
2589
2622
  env: { ...process.env, ...this.options.env },
2590
2623
  stdio: ["pipe", "pipe", "pipe"]
2591
2624
  });
2592
2625
  this.proc = proc;
2626
+ proc.stdin.on("error", () => {
2627
+ });
2593
2628
  proc.stdout.setEncoding("utf8");
2594
2629
  proc.stdout.on("data", (chunk) => {
2595
2630
  this.buffer += chunk;
@@ -2614,7 +2649,11 @@ var PiRpc = class {
2614
2649
  const done = (code) => {
2615
2650
  if (this.exited) return;
2616
2651
  this.exited = { code };
2617
- for (const [, p] of this.pending) p.reject(new Error(`pi exited (code ${code})${this.stderr ? `: ${this.stderr.slice(-400)}` : ""}`));
2652
+ for (const [, p] of this.pending) {
2653
+ const noise = this.stderr.split("\n").map((l) => l.trim()).filter((l) => l && !/^warn(ing)?\b/i.test(l)).slice(-3).join(" \xB7 ").slice(-300);
2654
+ const how = code === 0 ? "pi exited before answering" : `pi exited (code ${code})`;
2655
+ p.reject(new Error(noise ? `${how}: ${noise}` : how));
2656
+ }
2618
2657
  this.pending.clear();
2619
2658
  for (const fn of this.exitListeners) {
2620
2659
  try {
@@ -2623,7 +2662,7 @@ var PiRpc = class {
2623
2662
  }
2624
2663
  }
2625
2664
  };
2626
- proc.on("exit", done);
2665
+ proc.on("close", done);
2627
2666
  proc.on("error", (e) => {
2628
2667
  this.stderr += `
2629
2668
  spawn error: ${e.message}`;
@@ -2695,7 +2734,9 @@ spawn error: ${e.message}`;
2695
2734
  const entry = this.pending.get(id);
2696
2735
  this.pending.set(id, { resolve: (d) => settle(() => resolve(d)), reject: (e) => settle(() => reject(e)) });
2697
2736
  try {
2698
- this.proc.stdin.write(payload);
2737
+ this.proc.stdin.write(payload, (error) => {
2738
+ if (error && this.pending.delete(id)) settle(() => reject(error));
2739
+ });
2699
2740
  } catch (e) {
2700
2741
  this.pending.delete(id);
2701
2742
  settle(() => reject(e));
@@ -2704,12 +2745,11 @@ spawn error: ${e.message}`;
2704
2745
  }
2705
2746
  /** Answer an extension dialog. Without an answer the extension's tool call waits forever. */
2706
2747
  async respondUi(id, response) {
2707
- if (!this.proc || this.exited) return;
2708
- const record = { type: "extension_ui_response", id, ...response };
2709
- try {
2710
- this.proc.stdin.write(JSON.stringify(record) + "\n");
2711
- } catch {
2712
- }
2748
+ if (!this.proc || this.exited) throw new Error("Pi is not running. Retry the agent before answering.");
2749
+ const record = { ...response, type: "extension_ui_response", id };
2750
+ await new Promise((resolve, reject) => {
2751
+ this.proc.stdin.write(JSON.stringify(record) + "\n", (error) => error ? reject(error) : resolve());
2752
+ });
2713
2753
  }
2714
2754
  prompt(message, behavior) {
2715
2755
  return this.send({ type: "prompt", message, ...behavior ? { streamingBehavior: behavior } : {} });
@@ -2728,9 +2768,8 @@ spawn error: ${e.message}`;
2728
2768
  proc.kill("SIGKILL");
2729
2769
  } catch {
2730
2770
  }
2731
- resolve();
2732
2771
  }, 3e3);
2733
- proc.once("exit", () => {
2772
+ proc.once("close", () => {
2734
2773
  clearTimeout(t);
2735
2774
  resolve();
2736
2775
  });
@@ -3065,6 +3104,13 @@ function itemsFromMessages(messages) {
3065
3104
  }
3066
3105
  return items;
3067
3106
  }
3107
+ function readSessionMessages(file) {
3108
+ try {
3109
+ return fs2.readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)).filter((e) => e.type === "message" && e.message).map((e) => e.message);
3110
+ } catch {
3111
+ return [];
3112
+ }
3113
+ }
3068
3114
  var Chat = class {
3069
3115
  id;
3070
3116
  options;
@@ -3075,6 +3121,8 @@ var Chat = class {
3075
3121
  live = null;
3076
3122
  liveLoops = /* @__PURE__ */ new Map();
3077
3123
  dialogs = [];
3124
+ answeringUi = /* @__PURE__ */ new Set();
3125
+ dialogTimers = /* @__PURE__ */ new Map();
3078
3126
  notices = [];
3079
3127
  runs = [];
3080
3128
  model = null;
@@ -3082,6 +3130,9 @@ var Chat = class {
3082
3130
  refreshing = null;
3083
3131
  lastBroadcast = 0;
3084
3132
  stderr = "";
3133
+ exitError = "";
3134
+ refreshError = "";
3135
+ stopping = false;
3085
3136
  file = null;
3086
3137
  sessionName = null;
3087
3138
  autoNamed = false;
@@ -3091,14 +3142,22 @@ var Chat = class {
3091
3142
  this.model = options.model ?? null;
3092
3143
  this.pi = new PiRpc({
3093
3144
  command: options.piCommand,
3094
- args: ["--session-dir", options.sessionDir, "--session-id", id, ...options.args ?? [], ...options.model ? ["--model", options.model] : []],
3145
+ args: ["--session-dir", options.sessionDir, ...options.sessionFile ? ["--session", options.sessionFile] : ["--session-id", id], ...options.args ?? [], ...options.model ? ["--model", options.model] : []],
3095
3146
  cwd: options.cwd,
3096
3147
  onEvent: (event) => this.onEvent(event),
3097
- onExit: (code) => {
3098
- this.stderr = this.pi.stderrText;
3148
+ onExit: (code, stderr) => {
3149
+ this.stderr = stderr;
3150
+ const hadDialog = this.dialogs.length > 0;
3151
+ this.dialogs = [];
3152
+ for (const { timer } of this.dialogTimers.values()) clearTimeout(timer);
3153
+ this.dialogTimers.clear();
3099
3154
  this.live = null;
3155
+ if (!this.stopping) {
3156
+ const diagnostic = stderr.split("\n").map((l) => l.trim()).filter((l) => l && !/^warn(ing)?\b/i.test(l)).slice(-3).join(" \xB7 ").slice(-300);
3157
+ this.exitError = hadDialog ? "Pi exited while an extension decision was pending. Retry the agent; if you selected a custom Pi executable, use the default Loops runner for startup-dialog support." : code === 0 ? "Pi exited unexpectedly." : `Pi exited (code ${code ?? "unknown"}).`;
3158
+ if (diagnostic) this.exitError += ` ${diagnostic}`;
3159
+ }
3100
3160
  this.broadcast(true);
3101
- if (code != null) this.touch();
3102
3161
  }
3103
3162
  });
3104
3163
  }
@@ -3129,10 +3188,21 @@ var Chat = class {
3129
3188
  async start() {
3130
3189
  if (this.started) return;
3131
3190
  this.started = true;
3132
- await this.pi.start();
3191
+ let signalDialog;
3192
+ const dialogReady = new Promise((resolve) => {
3193
+ signalDialog = resolve;
3194
+ });
3133
3195
  this.pi.onUiRequest((request) => {
3134
- if (["select", "confirm", "input", "editor"].includes(request.method)) this.dialogs.push(request);
3135
- else if (request.method === "notify") {
3196
+ if (["select", "confirm", "input", "editor"].includes(request.method)) {
3197
+ this.dialogs.push(request);
3198
+ if (typeof request.timeout === "number" && Number.isFinite(request.timeout) && request.timeout > 0) {
3199
+ const timeout = Math.min(request.timeout, 2 ** 31 - 1);
3200
+ const timer = setTimeout(() => this.closeDialog(request.id), timeout);
3201
+ timer.unref();
3202
+ this.dialogTimers.set(request.id, { timer, deadline: Date.now() + timeout });
3203
+ }
3204
+ signalDialog();
3205
+ } else if (request.method === "notify") {
3136
3206
  console.log(`[${this.id}] ${request.level ?? "info"}: ${request.message ?? ""}`);
3137
3207
  this.notices.push({ t: Date.now(), level: String(request.level ?? "info"), message: String(request.message ?? "") });
3138
3208
  this.notices = this.notices.slice(-20);
@@ -3140,13 +3210,15 @@ var Chat = class {
3140
3210
  void this.refresh();
3141
3211
  this.broadcast(true);
3142
3212
  });
3213
+ await this.pi.start();
3143
3214
  if (!this.pi.running) {
3144
3215
  this.stderr = this.pi.stderrText;
3145
3216
  return;
3146
3217
  }
3147
- await this.refresh(true);
3218
+ await Promise.race([this.refresh(true), dialogReady]);
3148
3219
  }
3149
3220
  async stop() {
3221
+ this.stopping = true;
3150
3222
  await this.pi.stop();
3151
3223
  }
3152
3224
  /** Re-read the session's entries (cursor-based) and messages, then rebuild the runs. */
@@ -3174,9 +3246,10 @@ var Chat = class {
3174
3246
  await this.rename(auto).catch(() => {
3175
3247
  });
3176
3248
  }
3249
+ this.refreshError = "";
3177
3250
  } catch (e) {
3178
3251
  console.error(`[${this.id}] refresh failed:`, e.message ?? e);
3179
- this.stderr = String(e.message ?? e);
3252
+ this.refreshError = String(e.message ?? e);
3180
3253
  } finally {
3181
3254
  this.refreshing = null;
3182
3255
  this.broadcast(true);
@@ -3231,6 +3304,8 @@ var Chat = class {
3231
3304
  void this.refresh();
3232
3305
  this.broadcast(true);
3233
3306
  } else if (type === "extension_ui_request") {
3307
+ } else if (type === "extension_ui_request_cancelled") {
3308
+ this.closeDialog(String(event.id));
3234
3309
  } else if (type.startsWith("session_") || type === "compaction_start" || type === "compaction_end") {
3235
3310
  if (type === "session_idle" || type === "session_start") void this.refresh(true);
3236
3311
  }
@@ -3261,11 +3336,36 @@ var Chat = class {
3261
3336
  } catch {
3262
3337
  }
3263
3338
  }
3264
- async answerUi(id, response) {
3339
+ closeDialog(id) {
3340
+ const timed = this.dialogTimers.get(id);
3341
+ if (timed) {
3342
+ clearTimeout(timed.timer);
3343
+ this.dialogTimers.delete(id);
3344
+ }
3265
3345
  this.dialogs = this.dialogs.filter((d) => d.id !== id);
3266
- await this.pi.respondUi(id, response);
3267
3346
  this.broadcast(true);
3268
3347
  }
3348
+ async answerUi(id, response) {
3349
+ if (!this.isUp) throw new Error("Pi is not running. Retry the agent before answering.");
3350
+ if ((this.dialogTimers.get(id)?.deadline ?? Infinity) <= Date.now()) this.closeDialog(id);
3351
+ const dialog = this.dialogs.find((d) => d.id === id);
3352
+ if (!dialog) throw new Error("This decision is no longer pending. Refresh the chat.");
3353
+ if (this.answeringUi.has(id)) throw new Error("An answer to this decision is already being submitted.");
3354
+ if (!response || typeof response !== "object" || Array.isArray(response)) throw new Error("Invalid decision response.");
3355
+ if (response.cancelled !== true) {
3356
+ if (dialog.method === "select" && (typeof response.value !== "string" || !dialog.options?.includes(response.value))) throw new Error("Choose one of the available options.");
3357
+ if (dialog.method === "confirm" && typeof response.confirmed !== "boolean") throw new Error("A confirmation is required.");
3358
+ if (["input", "editor"].includes(dialog.method) && typeof response.value !== "string") throw new Error("A text response is required.");
3359
+ }
3360
+ this.answeringUi.add(id);
3361
+ try {
3362
+ await this.pi.respondUi(id, response);
3363
+ if (!this.isUp || !this.dialogs.some((d) => d.id === id)) throw new Error("This decision is no longer pending. Refresh the chat.");
3364
+ this.closeDialog(id);
3365
+ } finally {
3366
+ this.answeringUi.delete(id);
3367
+ }
3368
+ }
3269
3369
  async abort() {
3270
3370
  await this.pi.send({ type: "abort" }).catch(() => {
3271
3371
  });
@@ -3337,7 +3437,7 @@ var Chat = class {
3337
3437
  live: this.live,
3338
3438
  runs,
3339
3439
  model: this.model,
3340
- error: this.pi.running ? void 0 : this.stderr || void 0,
3440
+ error: this.pi.running ? this.refreshError || void 0 : this.exitError || this.refreshError || void 0,
3341
3441
  questions: runs.flatMap((r) => r.questions),
3342
3442
  dialogs: this.dialogs,
3343
3443
  notices: this.notices.slice(-6),
@@ -3353,11 +3453,17 @@ function listChats(sessionDir, cwd = process.cwd()) {
3353
3453
  const full = path2.join(sessionDir, f);
3354
3454
  let title;
3355
3455
  let firstUser;
3456
+ let recordedCwd;
3457
+ let sessionId;
3356
3458
  const entries = [];
3357
3459
  try {
3358
3460
  for (const line of fs2.readFileSync(full, "utf8").split("\n")) {
3359
3461
  if (!line.trim()) continue;
3360
3462
  const e = JSON.parse(line);
3463
+ if (e.type === "session" && typeof e.cwd === "string") {
3464
+ recordedCwd = e.cwd;
3465
+ sessionId = e.id;
3466
+ }
3361
3467
  entries.push(e);
3362
3468
  if ((e.type === "session_name" || e.type === "name") && e.name) title = String(e.name);
3363
3469
  if (!firstUser && e.type === "message" && e.message?.role === "user") {
@@ -3368,15 +3474,23 @@ function listChats(sessionDir, cwd = process.cwd()) {
3368
3474
  } catch {
3369
3475
  }
3370
3476
  summary = summarize(cwd, entries);
3371
- return { id: path2.basename(f, ".jsonl").replace(/^.*_/, ""), last: fs2.statSync(full).mtimeMs, title: title ?? (firstUser ? firstUser.replace(/\s+/g, " ").slice(0, 46) : void 0), summary };
3477
+ return { id: path2.basename(f, ".jsonl").replace(/^.*_/, ""), last: fs2.statSync(full).mtimeMs, title: title ?? (firstUser ? firstUser.replace(/\s+/g, " ").slice(0, 46) : void 0), summary, cwd: recordedCwd, sessionId };
3372
3478
  });
3373
3479
  } catch {
3374
3480
  return [];
3375
3481
  }
3376
- return files.filter((f) => /^chat-/.test(f.id)).sort((a, b) => b.last - a.last).map((f) => ({ id: f.id, title: f.title ?? goalName("New chat"), ts: f.last, last: f.last, summary: f.summary }));
3482
+ const canonical2 = (p) => {
3483
+ try {
3484
+ return fs2.realpathSync(p);
3485
+ } catch {
3486
+ return path2.resolve(p);
3487
+ }
3488
+ };
3489
+ return files.filter((f) => /^chat-/.test(f.id) && (f.sessionId === void 0 || f.sessionId === f.id) && f.cwd && canonical2(f.cwd) === canonical2(cwd)).sort((a, b) => b.last - a.last).map((f) => ({ id: f.id, title: f.title ?? goalName("New chat"), ts: f.last, last: f.last, summary: f.summary }));
3377
3490
  }
3378
3491
  var ChatManager = class {
3379
3492
  chats = /* @__PURE__ */ new Map();
3493
+ operations = /* @__PURE__ */ new Map();
3380
3494
  listeners = /* @__PURE__ */ new Set();
3381
3495
  base;
3382
3496
  /** Where a chat lives. Set by the server from the workspace registry, which owns the persistence. */
@@ -3400,33 +3514,66 @@ var ChatManager = class {
3400
3514
  get(id) {
3401
3515
  return this.chats.get(id) ?? null;
3402
3516
  }
3403
- async open(id) {
3404
- const existing = this.chats.get(id);
3405
- if (existing?.isUp) {
3406
- await existing.start();
3407
- return existing;
3517
+ async serialize(id, operation) {
3518
+ const result = (this.operations.get(id) ?? Promise.resolve()).then(operation);
3519
+ const settled = result.then(() => {
3520
+ }, () => {
3521
+ });
3522
+ this.operations.set(id, settled);
3523
+ try {
3524
+ return await result;
3525
+ } finally {
3526
+ if (this.operations.get(id) === settled) this.operations.delete(id);
3408
3527
  }
3409
- const where = this.bind(id);
3410
- const chat = new Chat(id, { ...this.base, ...where, onUpdate: (c) => {
3411
- for (const fn of this.listeners) {
3412
- try {
3413
- fn(c);
3414
- } catch {
3528
+ }
3529
+ open(id) {
3530
+ return this.serialize(id, async () => {
3531
+ const where = this.bind(id);
3532
+ const existing = this.chats.get(id);
3533
+ if (existing && existing.workspacePath !== where.cwd) throw new Error("chat directory does not match its Pi session");
3534
+ if (existing) return existing;
3535
+ const chat = new Chat(id, { ...this.base, ...where, onUpdate: (c) => {
3536
+ for (const fn of this.listeners) {
3537
+ try {
3538
+ fn(c);
3539
+ } catch {
3540
+ }
3415
3541
  }
3416
- }
3417
- } });
3418
- this.chats.set(id, chat);
3419
- await chat.start();
3420
- return chat;
3542
+ } });
3543
+ this.chats.set(id, chat);
3544
+ await chat.start();
3545
+ return chat;
3546
+ });
3421
3547
  }
3422
- async close(id) {
3423
- const chat = this.chats.get(id);
3424
- this.chats.delete(id);
3425
- await chat?.stop();
3548
+ retry(id) {
3549
+ return this.serialize(id, async () => {
3550
+ const where = this.bind(id);
3551
+ const existing = this.chats.get(id);
3552
+ if (existing?.isUp) throw new Error("chat is already running");
3553
+ await existing?.stop();
3554
+ const chat = new Chat(id, { ...this.base, ...where, onUpdate: (c) => {
3555
+ for (const fn of this.listeners) {
3556
+ try {
3557
+ fn(c);
3558
+ } catch {
3559
+ }
3560
+ }
3561
+ } });
3562
+ this.chats.set(id, chat);
3563
+ await chat.start();
3564
+ return chat;
3565
+ });
3566
+ }
3567
+ close(id, afterClose) {
3568
+ return this.serialize(id, async () => {
3569
+ const chat = this.chats.get(id);
3570
+ await chat?.stop();
3571
+ this.chats.delete(id);
3572
+ afterClose?.();
3573
+ });
3426
3574
  }
3427
3575
  async closeAll() {
3428
- await Promise.all([...this.chats.values()].map((c) => c.stop()));
3429
- this.chats.clear();
3576
+ await Promise.all([.../* @__PURE__ */ new Set([...this.chats.keys(), ...this.operations.keys()])].map((id) => this.close(id)));
3430
3577
  }
3431
3578
  openChats() {
3432
3579
  return [...this.chats.values()];
@@ -4180,13 +4327,52 @@ import fs7 from "node:fs";
4180
4327
  import path7 from "node:path";
4181
4328
  import { execFileSync } from "node:child_process";
4182
4329
  var slug = (p) => path7.basename(p).replace(/[^a-z0-9._-]+/gi, "-").toLowerCase() || "repo";
4330
+ var canonical = (p) => {
4331
+ const abs = path7.resolve(p);
4332
+ try {
4333
+ return fs7.realpathSync(abs);
4334
+ } catch {
4335
+ return abs;
4336
+ }
4337
+ };
4338
+ var available = (p) => {
4339
+ try {
4340
+ return fs7.statSync(p).isDirectory();
4341
+ } catch {
4342
+ return false;
4343
+ }
4344
+ };
4345
+ function header(file) {
4346
+ const fd = fs7.openSync(file, "r");
4347
+ try {
4348
+ const chunks = [];
4349
+ let size = 0;
4350
+ while (size < 1024 * 1024) {
4351
+ const buf = Buffer.alloc(4096);
4352
+ const n = fs7.readSync(fd, buf, 0, buf.length, null);
4353
+ if (!n) break;
4354
+ const end = buf.subarray(0, n).indexOf(10);
4355
+ chunks.push(buf.subarray(0, end < 0 ? n : end));
4356
+ size += end < 0 ? n : end;
4357
+ if (end >= 0) break;
4358
+ }
4359
+ if (size >= 1024 * 1024) throw new Error("session header too large");
4360
+ const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
4361
+ if (typeof result.cwd !== "string" || !result.cwd || result.id !== void 0 && typeof result.id !== "string") throw new Error("invalid session header");
4362
+ return result;
4363
+ } finally {
4364
+ fs7.closeSync(fd);
4365
+ }
4366
+ }
4183
4367
  var Workspaces = class {
4184
- /** Where workspaces.json and the per-repo session dirs live. */
4185
4368
  root;
4186
4369
  file;
4187
4370
  data;
4188
4371
  byId = /* @__PURE__ */ new Map();
4189
4372
  chatMap;
4373
+ files = /* @__PURE__ */ new Map();
4374
+ // A new chat may not have written its first session file yet. This is never persisted.
4375
+ pending = /* @__PURE__ */ new Set();
4190
4376
  constructor({ root, cwd }) {
4191
4377
  this.root = root;
4192
4378
  this.file = path7.join(root, "workspaces.json");
@@ -4194,139 +4380,185 @@ var Workspaces = class {
4194
4380
  let data = { workspaces: [], chats: {} };
4195
4381
  if (fs7.existsSync(this.file)) {
4196
4382
  try {
4197
- data = JSON.parse(fs7.readFileSync(this.file, "utf8"));
4383
+ const parsed = JSON.parse(fs7.readFileSync(this.file, "utf8"));
4384
+ if (!Array.isArray(parsed.workspaces) || !parsed.chats || typeof parsed.chats !== "object" || Array.isArray(parsed.chats)) throw new Error("invalid registry");
4385
+ data = parsed;
4198
4386
  } catch {
4199
- try {
4200
- fs7.renameSync(this.file, `${this.file}.corrupt-${Date.now()}`);
4201
- } catch {
4202
- }
4203
- console.error(`[workspaces] could not read ${this.file}; kept a copy and rebuilding from sessions`);
4387
+ fs7.renameSync(this.file, `${this.file}.corrupt-${Date.now()}`);
4204
4388
  }
4205
4389
  }
4206
- this.data = { workspaces: data.workspaces ?? [], chats: data.chats ?? {}, default: data.default };
4207
- this.chatMap = new Map(Object.entries(this.data.chats));
4208
- const first = this.add(cwd, { persist: false });
4209
- this.restore();
4210
- if (!this.data.default || !this.byId.has(this.data.default)) this.data.default = first.id;
4390
+ this.data = { version: 2, workspaces: [], chats: {}, default: data.default };
4391
+ this.chatMap = new Map(Object.entries(data.chats));
4392
+ if (data.files && typeof data.files === "object") {
4393
+ for (const [chat, files] of Object.entries(data.files)) {
4394
+ if (Array.isArray(files) && files.every((f) => typeof f === "string")) this.files.set(chat, new Set(files));
4395
+ }
4396
+ }
4397
+ for (const w of data.workspaces) {
4398
+ if (!w || typeof w.id !== "string" || typeof w.path !== "string" || this.byId.has(w.id)) continue;
4399
+ const p = canonical(w.path);
4400
+ const legacy = path7.join(p, ".pi", "loops-ui", "sessions");
4401
+ const sessionDir = w.sessionDir ?? (fs7.existsSync(legacy) ? legacy : path7.join(root, "workspaces", w.id, "sessions"));
4402
+ this.byId.set(w.id, { id: w.id, path: p, sessionDir, title: path7.basename(p) || p, open: w.open !== false, available: available(p) });
4403
+ }
4404
+ this.recoverSessions(void 0, data.version !== 2);
4405
+ if (cwd && ![...this.byId.values()].some((w) => w.path === canonical(cwd))) {
4406
+ this.add(cwd);
4407
+ }
4211
4408
  this.save();
4212
4409
  }
4213
4410
  save() {
4411
+ this.data.workspaces = [...this.byId.values()].map(({ id, path: p, sessionDir, open }) => ({ id, path: p, sessionDir, open }));
4214
4412
  this.data.chats = Object.fromEntries(this.chatMap);
4215
- try {
4216
- const tmp = `${this.file}.tmp`;
4217
- fs7.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + "\n");
4218
- fs7.renameSync(tmp, this.file);
4219
- } catch {
4220
- }
4413
+ this.data.files = Object.fromEntries([...this.files].map(([chat, files]) => [chat, [...files]]));
4414
+ const tmp = `${this.file}.tmp`;
4415
+ fs7.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + "\n");
4416
+ fs7.renameSync(tmp, this.file);
4221
4417
  }
4222
4418
  get defaultId() {
4223
- return this.data.default ?? [...this.byId.keys()][0] ?? "";
4419
+ return this.get()?.id ?? "";
4224
4420
  }
4225
4421
  list() {
4226
- return [...this.byId.values()];
4422
+ return [...this.byId.values()].map((w) => ({ ...w, available: available(w.path) }));
4227
4423
  }
4228
4424
  get(id) {
4229
- return this.byId.get(id ?? "") ?? this.byId.get(this.defaultId) ?? null;
4425
+ const w = id != null ? this.byId.get(id) : this.byId.get(this.data.default ?? "")?.open ? this.byId.get(this.data.default) : [...this.byId.values()].find((x) => x.open);
4426
+ return w ? { ...w, available: available(w.path) } : null;
4230
4427
  }
4231
- /** Add (or re-find) a repo. `persist: false` is used for the startup cwd. */
4232
- add(dir, { persist = true } = {}) {
4233
- const abs = path7.resolve(dir);
4234
- const existing = [...this.byId.values()].find((w) => w.path === abs);
4428
+ register(p, sessionDir, preferredId) {
4429
+ const existing = [...this.byId.values()].find((w) => w.path === p);
4235
4430
  if (existing) return existing;
4236
- if (!fs7.existsSync(abs) || !fs7.statSync(abs).isDirectory()) throw new Error(`${abs} is not a directory`);
4237
- const st = fs7.statSync(abs);
4238
- if (!st.isDirectory()) throw new Error(`${abs} is not a directory`);
4239
- let id = slug(abs);
4240
- for (let i = 2; this.byId.has(id); i++) id = `${slug(abs)}-${i}`;
4241
- const legacy = path7.join(abs, ".pi", "loops-ui", "sessions");
4242
- const sessionDir = fs7.existsSync(legacy) ? legacy : path7.join(this.root, "workspaces", id, "sessions");
4243
- const ws = { id, path: abs, sessionDir, title: path7.basename(abs) || abs };
4244
- fs7.mkdirSync(ws.sessionDir, { recursive: true });
4431
+ let id = preferredId && !this.byId.has(preferredId) ? preferredId : slug(p);
4432
+ for (let i = 2; this.byId.has(id); i++) id = `${slug(p)}-${i}`;
4433
+ const legacy = path7.join(p, ".pi", "loops-ui", "sessions");
4434
+ const ws = { id, path: p, sessionDir: sessionDir ?? (fs7.existsSync(legacy) ? legacy : path7.join(this.root, "workspaces", id, "sessions")), title: path7.basename(p) || p, open: true, available: available(p) };
4245
4435
  this.byId.set(id, ws);
4246
- if (persist) {
4247
- this.data.workspaces = [...this.data.workspaces.filter((w) => w.path !== abs), { id, path: abs }];
4248
- this.save();
4249
- }
4250
4436
  return ws;
4251
4437
  }
4252
- /** Register the persisted workspaces (their paths may have been deleted since), then rebuild from Pi sessions. */
4438
+ add(dir) {
4439
+ const p = canonical(dir);
4440
+ if (!available(p)) throw new Error(`${p} is not a directory`);
4441
+ const ws = this.register(p);
4442
+ ws.open = true;
4443
+ fs7.mkdirSync(ws.sessionDir, { recursive: true });
4444
+ if (!this.data.default || !this.byId.get(this.data.default)?.open) this.data.default = ws.id;
4445
+ this.recoverSessions([ws.sessionDir], false);
4446
+ this.save();
4447
+ return this.get(ws.id);
4448
+ }
4449
+ /** Re-scan sessions, including legacy session directories. */
4253
4450
  restore() {
4254
- for (const w of this.data.workspaces) {
4255
- try {
4256
- this.add(w.path);
4257
- } catch {
4258
- }
4259
- }
4260
- this.recoverSessions();
4451
+ this.recoverSessions(void 0, false);
4261
4452
  this.save();
4262
4453
  }
4263
- /**
4264
- * Chat -> repo is recoverable from Pi itself: each session header carries `cwd`, and the session dir names the
4265
- * workspace. This runs on startup, so a lost or stale `workspaces.json` is a cache miss, not lost history.
4266
- */
4267
- recoverSessions() {
4268
- const root = path7.join(this.root, "workspaces");
4269
- let ids = [];
4270
- try {
4271
- ids = fs7.readdirSync(root);
4272
- } catch {
4273
- return;
4274
- }
4275
- for (const id of ids) {
4276
- const dir = path7.join(root, id, "sessions");
4277
- let files = [];
4454
+ recoverSessions(onlyDirs, rebind = true) {
4455
+ const dirs = new Set(onlyDirs ?? [...this.byId.values()].map((w) => w.sessionDir));
4456
+ const base = path7.join(this.root, "workspaces");
4457
+ if (!onlyDirs && fs7.existsSync(base)) for (const id of fs7.readdirSync(base)) dirs.add(path7.join(base, id, "sessions"));
4458
+ for (const dir of dirs) {
4459
+ let names;
4278
4460
  try {
4279
- files = fs7.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
4461
+ names = fs7.readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
4280
4462
  } catch {
4281
4463
  continue;
4282
4464
  }
4283
- let cwd = null;
4284
- for (const f of files) {
4285
- try {
4286
- const head = JSON.parse(fs7.readFileSync(path7.join(dir, f), "utf8").split("\n")[0]);
4287
- if (typeof head?.cwd === "string" && head.cwd) {
4288
- cwd = head.cwd;
4289
- break;
4290
- }
4291
- } catch {
4292
- }
4293
- }
4294
- let ws = this.byId.get(id) ?? null;
4295
- if (!ws && cwd) {
4465
+ for (const name of names) {
4466
+ const chat = path7.basename(name, ".jsonl").replace(/^.*_/, "");
4467
+ if (!chat.startsWith("chat-")) continue;
4468
+ const file = path7.join(dir, name);
4469
+ const files = this.files.get(chat) ?? /* @__PURE__ */ new Set();
4470
+ files.add(file);
4471
+ this.files.set(chat, files);
4472
+ let cwd;
4296
4473
  try {
4297
- ws = this.add(cwd);
4298
- if (ws.id !== id) ws.sessionDir = dir;
4474
+ const head = header(file);
4475
+ if (head.id !== void 0 && head.id !== chat) continue;
4476
+ cwd = canonical(head.cwd);
4299
4477
  } catch {
4478
+ continue;
4300
4479
  }
4301
- }
4302
- if (!ws) continue;
4303
- for (const f of files) {
4304
- const chat = f.replace(/\.jsonl$/, "").replace(/^.*_/, "");
4305
- if (/^chat-/.test(chat) && !this.chatMap.has(chat)) this.chatMap.set(chat, ws.id);
4480
+ const parent = path7.dirname(dir);
4481
+ const preferred = path7.dirname(parent) === base ? path7.basename(parent) : void 0;
4482
+ const ws = this.register(cwd, dir, preferred);
4483
+ if (rebind || !this.chatMap.has(chat)) this.chatMap.set(chat, ws.id);
4306
4484
  }
4307
4485
  }
4308
4486
  }
4309
4487
  remove(id) {
4310
- if (id === this.defaultId) throw new Error("cannot remove the workspace this app was started in");
4311
- this.byId.delete(id);
4312
- this.data.workspaces = this.data.workspaces.filter((w) => w.id !== id);
4313
- for (const [chat, ws] of this.chatMap) if (ws === id) this.chatMap.delete(chat);
4488
+ const ws = this.byId.get(id);
4489
+ if (!ws) throw new Error(`unknown workspace: ${id}`);
4490
+ ws.open = false;
4314
4491
  this.save();
4315
4492
  }
4316
- /** Which workspace a chat belongs to. Unknown ids fall back to the default (older chats predate the registry). */
4493
+ hasChat(chat) {
4494
+ return this.chatMap.has(chat) && this.byId.has(this.chatMap.get(chat));
4495
+ }
4317
4496
  ofChat(chat) {
4318
- return this.get(this.chatMap.get(chat)) ?? this.get();
4497
+ const ws = this.get(this.chatMap.get(chat) ?? "");
4498
+ if (!ws) throw new Error(`unknown chat: ${chat}`);
4499
+ const files = this.candidates(chat, ws);
4500
+ if (files.length === 0 && !this.pending.has(chat)) throw new Error(`missing session for chat: ${chat}`);
4501
+ if (files.length > 1) throw new Error(`ambiguous session for chat: ${chat}`);
4502
+ if (files.length === 1 && !this.validFile(files[0], chat, ws)) throw new Error(`invalid session for chat: ${chat}`);
4503
+ return ws;
4319
4504
  }
4320
4505
  bindChat(chat, ws) {
4321
- if (this.byId.has(ws)) {
4322
- this.chatMap.set(chat, ws);
4506
+ if (!this.byId.has(ws)) throw new Error(`unknown workspace: ${ws}`);
4507
+ const existing = this.chatMap.get(chat);
4508
+ if (existing && existing !== ws) throw new Error(`chat ${chat} is already bound to ${existing}`);
4509
+ if (!existing) this.pending.add(chat);
4510
+ this.chatMap.set(chat, ws);
4511
+ this.save();
4512
+ }
4513
+ unbindChat(chat) {
4514
+ if (this.chatMap.delete(chat)) {
4515
+ this.pending.delete(chat);
4516
+ this.files.delete(chat);
4323
4517
  this.save();
4324
4518
  }
4325
4519
  }
4326
- unbindChat(chat) {
4327
- if (this.chatMap.delete(chat)) this.save();
4520
+ /** All known session directories for this workspace, including recovered secondary directories. */
4521
+ sessionDirs(id) {
4522
+ const ws = this.get(id);
4523
+ if (!ws) return [];
4524
+ const dirs = /* @__PURE__ */ new Set([ws.sessionDir]);
4525
+ for (const [chat, files] of this.files) {
4526
+ if (this.chatMap.get(chat) === id) for (const file of files) dirs.add(path7.dirname(file));
4527
+ }
4528
+ return [...dirs];
4529
+ }
4530
+ candidates(chat, ws) {
4531
+ const files = new Set(this.files.get(chat) ?? []);
4532
+ const oldSize = files.size;
4533
+ for (const dir of this.sessionDirs(ws.id)) {
4534
+ try {
4535
+ for (const name of fs7.readdirSync(dir)) {
4536
+ if (name.endsWith(".jsonl") && path7.basename(name, ".jsonl").replace(/^.*_/, "") === chat) files.add(path7.join(dir, name));
4537
+ }
4538
+ } catch {
4539
+ }
4540
+ }
4541
+ if (files.size) this.pending.delete(chat);
4542
+ if (files.size !== oldSize) {
4543
+ this.files.set(chat, files);
4544
+ this.save();
4545
+ }
4546
+ return [...files];
4547
+ }
4548
+ validFile(file, chat, ws) {
4549
+ try {
4550
+ const h = header(file);
4551
+ return (h.id === void 0 || h.id === chat) && canonical(h.cwd) === ws.path;
4552
+ } catch {
4553
+ return false;
4554
+ }
4555
+ }
4556
+ sessionFile(chat) {
4557
+ if (!this.hasChat(chat)) return null;
4558
+ const ws = this.get(this.chatMap.get(chat));
4559
+ const files = this.candidates(chat, ws);
4560
+ return files.length === 1 && this.validFile(files[0], chat, ws) ? files[0] : null;
4328
4561
  }
4329
- /** A repo is loopable when git can name a commit in it; that is what the sidebar shows. */
4330
4562
  static loopable(dir) {
4331
4563
  try {
4332
4564
  execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, stdio: "pipe" });
@@ -4337,9 +4569,48 @@ var Workspaces = class {
4337
4569
  }
4338
4570
  };
4339
4571
 
4572
+ // server/directories.ts
4573
+ import fs8 from "node:fs/promises";
4574
+ import os4 from "node:os";
4575
+ import path8 from "node:path";
4576
+ import { execFile } from "node:child_process";
4577
+ function directoryPath(value) {
4578
+ if (typeof value !== "string" || !value.trim()) throw new Error("an absolute directory path is required");
4579
+ const expanded = value.trim().replace(/^~(?=\/|$)/, os4.homedir());
4580
+ if (!path8.isAbsolute(expanded)) throw new Error("use an absolute path on the server, or ~/ for its home directory");
4581
+ return expanded;
4582
+ }
4583
+ async function browseDirectories(value, hidden = false) {
4584
+ const requested = value === void 0 ? os4.homedir() : directoryPath(value);
4585
+ const dir = await fs8.realpath(requested);
4586
+ const entries = [];
4587
+ let scanned = 0, truncated = false;
4588
+ const handle = await fs8.opendir(dir);
4589
+ for await (const entry of handle) {
4590
+ if (++scanned > 1e4) {
4591
+ truncated = true;
4592
+ break;
4593
+ }
4594
+ if (!hidden && entry.name.startsWith(".")) continue;
4595
+ const full = path8.join(dir, entry.name);
4596
+ const symlink = entry.isSymbolicLink();
4597
+ if (!entry.isDirectory() && !(symlink && await fs8.stat(full).then((s) => s.isDirectory(), () => false))) continue;
4598
+ entries.push({ name: entry.name, path: full, symlink });
4599
+ if (entries.length >= 500) {
4600
+ truncated = true;
4601
+ break;
4602
+ }
4603
+ }
4604
+ entries.sort((a, b) => a.name.localeCompare(b.name));
4605
+ const loopable = await new Promise((resolve) => {
4606
+ execFile("git", ["rev-parse", "--verify", "HEAD"], { cwd: dir, timeout: 2e3 }, (e) => resolve(!e));
4607
+ });
4608
+ return { path: dir, parent: path8.dirname(dir) === dir ? null : path8.dirname(dir), home: os4.homedir(), entries, truncated, loopable };
4609
+ }
4610
+
4340
4611
  // server/index.ts
4341
- var HERE = path8.dirname(fileURLToPath(import.meta.url));
4342
- var APP_ROOT = path8.resolve(HERE, "..");
4612
+ var HERE = path9.dirname(fileURLToPath(import.meta.url));
4613
+ var APP_ROOT = path9.resolve(HERE, "..");
4343
4614
  var arg = (name, fallback) => {
4344
4615
  const i = process.argv.indexOf(`--${name}`);
4345
4616
  return i >= 0 ? process.argv[i + 1] : process.env[`LOOPS_UI_${name.toUpperCase().replace(/-/g, "_")}`] ?? fallback;
@@ -4347,16 +4618,24 @@ var arg = (name, fallback) => {
4347
4618
  var PORT = Number(arg("port", "8001"));
4348
4619
  var HOST = arg("host", "127.0.0.1");
4349
4620
  var LOOPBACK = ["127.0.0.1", "localhost", "::1"].includes(HOST);
4350
- var CWD = path8.resolve(arg("cwd", process.cwd()));
4621
+ var PUBLIC_ORIGINS = [...new Set((arg("public-origin", "") ?? "").split(",").map((s) => s.trim()).filter(Boolean).map((s) => {
4622
+ const url = new URL(s);
4623
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
4624
+ throw new Error(`--public-origin must contain HTTP(S) origins, not paths or credentials: ${s}`);
4625
+ }
4626
+ return url.origin;
4627
+ }))];
4628
+ var SEED = arg("cwd");
4629
+ var CWD = SEED ? path9.resolve(SEED) : null;
4351
4630
  var DEV = process.argv.includes("--dev") || process.env.NODE_ENV === "development";
4352
4631
  var VITE = arg("vite", "http://localhost:5173");
4353
- var PI = arg("pi", "pi");
4354
- var DATA = path8.resolve(arg("data", process.env.LOOPS_UI_DATA ?? path8.join(process.env.HOME ?? process.cwd(), ".pi", "loops-ui")));
4632
+ var PI = arg("pi", path9.join(APP_ROOT, "bin", "pi-rpc.mjs"));
4633
+ var DATA = path9.resolve(arg("data", process.env.LOOPS_UI_DATA ?? path9.join(process.env.HOME ?? process.cwd(), ".pi", "loops-ui")));
4355
4634
  var MODEL = arg("model", void 0) || null;
4356
- var EXTENSIONS = (arg("extensions", path8.resolve(APP_ROOT, "../pi-loops/extensions/loop.ts")) ?? "").split(",").filter(Boolean);
4635
+ var EXTENSIONS = (arg("extensions", path9.resolve(APP_ROOT, "../pi-loops/extensions/loop.ts")) ?? "").split(",").filter(Boolean);
4357
4636
  function prerequisites() {
4358
4637
  const out = [];
4359
- const onPath = PI.includes("/") || /^[A-Za-z]:\\/.test(PI) ? fs8.existsSync(PI) : (() => {
4638
+ const onPath = PI.includes("/") || /^[A-Za-z]:\\/.test(PI) ? fs9.existsSync(PI) : (() => {
4360
4639
  try {
4361
4640
  execFileSync2(process.platform === "win32" ? "where" : "which", [PI], { stdio: ["ignore", "pipe", "ignore"] });
4362
4641
  return true;
@@ -4366,18 +4645,18 @@ function prerequisites() {
4366
4645
  })();
4367
4646
  if (!onPath) out.push({ name: "pi", detail: `\`${PI}\` is not on PATH`, fix: "install Pi, or pass --pi <path to the pi binary>" });
4368
4647
  for (const e of EXTENSIONS) {
4369
- if (!fs8.existsSync(e)) out.push({ name: "extension", detail: `${e} does not exist`, fix: "pi install npm:@computesdk/pi-loops, or pass --extensions <path to extensions/loop.ts>" });
4648
+ if (!fs9.existsSync(e)) out.push({ name: "extension", detail: `${e} does not exist`, fix: "pi install npm:@computesdk/pi-loops, or pass --extensions <path to extensions/loop.ts>" });
4370
4649
  }
4371
4650
  return out;
4372
4651
  }
4373
4652
  var PROBLEMS = prerequisites();
4374
- fs8.mkdirSync(DATA, { recursive: true });
4653
+ fs9.mkdirSync(DATA, { recursive: true });
4375
4654
  var TOKEN = (() => {
4376
4655
  const explicit = arg("token", void 0);
4377
- const file = path8.join(DATA, "token");
4656
+ const file = path9.join(DATA, "token");
4378
4657
  const stored = (() => {
4379
4658
  try {
4380
- return fs8.readFileSync(file, "utf8").trim() || null;
4659
+ return fs9.readFileSync(file, "utf8").trim() || null;
4381
4660
  } catch {
4382
4661
  return null;
4383
4662
  }
@@ -4385,7 +4664,7 @@ var TOKEN = (() => {
4385
4664
  if (explicit) {
4386
4665
  if (!LOOPBACK) {
4387
4666
  try {
4388
- fs8.writeFileSync(file, explicit + "\n", { mode: 384 });
4667
+ fs9.writeFileSync(file, explicit + "\n", { mode: 384 });
4389
4668
  } catch {
4390
4669
  }
4391
4670
  }
@@ -4394,32 +4673,53 @@ var TOKEN = (() => {
4394
4673
  if (stored) return stored;
4395
4674
  if (LOOPBACK) return null;
4396
4675
  const made = randomBytes(24).toString("base64url");
4397
- fs8.writeFileSync(file, made + "\n", { mode: 384 });
4676
+ fs9.writeFileSync(file, made + "\n", { mode: 384 });
4398
4677
  return made;
4399
4678
  })();
4400
4679
  if (!LOOPBACK && !TOKEN) {
4401
4680
  console.error(`refusing to listen on ${HOST} without a token: this UI drives an agent that has bash. Pass --token <secret> (or let it generate one in ${DATA}/token).`);
4402
4681
  process.exit(2);
4403
4682
  }
4404
- var workspaces = new Workspaces({ root: DATA, cwd: CWD });
4683
+ var workspaces = new Workspaces({ root: DATA, ...CWD ? { cwd: CWD } : {} });
4684
+ var requireWorkspace = (id) => {
4685
+ const ws = workspaces.get(id);
4686
+ if (!ws) throw new HTTPException(404, { message: id ? `unknown workspace: ${id}` : "choose a workspace first" });
4687
+ if (!ws.available) throw new HTTPException(409, { message: `workspace directory is unavailable: ${ws.path}` });
4688
+ return ws;
4689
+ };
4690
+ var requireChatWorkspace = (id) => {
4691
+ if (!workspaces.hasChat(id)) throw new HTTPException(404, { message: `unknown chat: ${id}` });
4692
+ let ws;
4693
+ try {
4694
+ ws = workspaces.ofChat(id);
4695
+ } catch (e) {
4696
+ throw new HTTPException(409, { message: e.message });
4697
+ }
4698
+ return requireWorkspace(ws.id);
4699
+ };
4405
4700
  var manager = new ChatManager({
4406
4701
  piCommand: PI,
4407
4702
  args: EXTENSIONS.flatMap((e) => ["-e", e]),
4408
4703
  model: MODEL,
4409
4704
  // a chat's repo comes from the registry: the browser never hands us a path
4410
4705
  bind: (chat) => {
4411
- const ws = workspaces.ofChat(chat);
4412
- return { cwd: ws.path, sessionDir: ws.sessionDir };
4706
+ const ws = requireChatWorkspace(chat);
4707
+ return { cwd: ws.path, sessionDir: ws.sessionDir, sessionFile: workspaces.sessionFile(chat) ?? void 0 };
4413
4708
  }
4414
4709
  });
4415
- var wsOf = (chat) => chat ? workspaces.ofChat(chat) : workspaces.get();
4710
+ var wsOf = (chat) => chat ? requireChatWorkspace(chat) : requireWorkspace();
4416
4711
  var wsOfRun = (run3) => {
4417
- const byIndex = workspaces.list().find((w) => fs8.existsSync(path8.join(w.path, STATE_DIR, `${run3}.json`)));
4712
+ const byIndex = workspaces.list().find((w) => fs9.existsSync(path9.join(w.path, STATE_DIR, `${run3}.json`)));
4418
4713
  if (byIndex) return byIndex;
4419
4714
  const chat = manager.findRun(run3);
4420
- return chat ? workspaces.ofChat(chat.id) : workspaces.get();
4715
+ if (chat) return requireChatWorkspace(chat.id);
4716
+ throw new HTTPException(404, { message: `unknown run: ${run3}` });
4717
+ };
4718
+ var listWorkspaceChats = (ws) => {
4719
+ const all = workspaces.sessionDirs(ws.id).flatMap((sessionDir) => manager.list({ ...ws, sessionDir }));
4720
+ return [...new Map(all.map((chat) => [chat.id, chat])).values()].sort((a, b) => b.last - a.last);
4421
4721
  };
4422
- var chatsOf = (wsId) => wsId ? manager.list(workspaces.get(wsId)) : workspaces.list().flatMap((w) => manager.list(w));
4722
+ var chatsOf = (wsId) => wsId ? listWorkspaceChats(requireWorkspace(wsId)) : workspaces.list().filter((w) => w.available).flatMap(listWorkspaceChats);
4423
4723
  var withLive = (state) => {
4424
4724
  for (const run3 of state.runs ?? []) {
4425
4725
  const url = liveUrl(run3.id);
@@ -4442,7 +4742,7 @@ var watchOf = (wsId) => {
4442
4742
  if (!w) {
4443
4743
  const read = () => {
4444
4744
  try {
4445
- return JSON.parse(fs8.readFileSync(path8.join(ws.path, ".pi", "loops.json"), "utf8"));
4745
+ return JSON.parse(fs9.readFileSync(path9.join(ws.path, ".pi", "loops.json"), "utf8"));
4446
4746
  } catch {
4447
4747
  return {};
4448
4748
  }
@@ -4450,7 +4750,7 @@ var watchOf = (wsId) => {
4450
4750
  w = { cwd: ws.path, accepted: read() };
4451
4751
  watches.set(ws.id, w);
4452
4752
  try {
4453
- fs8.watch(path8.join(ws.path, ".pi"), (_e, name) => {
4753
+ fs9.watch(path9.join(ws.path, ".pi"), (_e, name) => {
4454
4754
  if (name && name !== "loops.json") return;
4455
4755
  clearTimeout(watchTimers.get(ws.id));
4456
4756
  watchTimers.set(ws.id, setTimeout(() => checkConfig(ws.id), 120));
@@ -4472,10 +4772,10 @@ function notifyProposals() {
4472
4772
  }
4473
4773
  function checkConfig(wsId) {
4474
4774
  const w = watchOf(wsId);
4475
- const CONFIG = path8.join(w.cwd, ".pi", "loops.json");
4775
+ const CONFIG = path9.join(w.cwd, ".pi", "loops.json");
4476
4776
  const raw2 = (() => {
4477
4777
  try {
4478
- return fs8.readFileSync(CONFIG, "utf8");
4778
+ return fs9.readFileSync(CONFIG, "utf8");
4479
4779
  } catch {
4480
4780
  return "";
4481
4781
  }
@@ -4496,13 +4796,13 @@ function checkConfig(wsId) {
4496
4796
  notifyProposals();
4497
4797
  }
4498
4798
  var inside = (root, p) => {
4499
- const abs = path8.resolve(root, p);
4500
- return abs === root || abs.startsWith(root + path8.sep) ? abs : null;
4799
+ const abs = path9.resolve(root, p);
4800
+ return abs === root || abs.startsWith(root + path9.sep) ? abs : null;
4501
4801
  };
4502
4802
  var stateOf = (run3) => manager.findRun(run3)?.stateFor(run3) ?? readState(wsOfRun(run3).path, run3);
4503
4803
  var app = new Hono3();
4504
- var OWN_HOSTS = /* @__PURE__ */ new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`, `[::1]:${PORT}`]);
4505
- var OWN_ORIGINS = new Set([...OWN_HOSTS].map((h) => `http://${h}`));
4804
+ var OWN_HOSTS = /* @__PURE__ */ new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`, `[::1]:${PORT}`, ...PUBLIC_ORIGINS.map((o) => new URL(o).host)]);
4805
+ var OWN_ORIGINS = /* @__PURE__ */ new Set([`http://127.0.0.1:${PORT}`, `http://localhost:${PORT}`, `http://[::1]:${PORT}`, ...PUBLIC_ORIGINS]);
4506
4806
  var ALLOWED_ORIGINS = (arg("allow-origin", "") ?? "").split(",").map((o) => o.trim()).filter(Boolean);
4507
4807
  var originAllowed = (origin) => {
4508
4808
  if (ALLOWED_ORIGINS.includes("*")) return true;
@@ -4540,6 +4840,11 @@ app.use("/api/v1/*", async (c, next) => {
4540
4840
  await next();
4541
4841
  });
4542
4842
  var api = new Hono3();
4843
+ api.onError((error, c) => {
4844
+ if (error instanceof HTTPException) return c.json({ error: error.message }, error.status);
4845
+ console.error(error);
4846
+ return c.json({ error: error.message }, 500);
4847
+ });
4543
4848
  api.get("/health", (c) => c.json({ ok: PROBLEMS.length === 0, problems: PROBLEMS, cwd: CWD, data: DATA, pi: PI, extensions: EXTENSIONS, workspaces: workspaces.list() }));
4544
4849
  api.get("/credentials", (c) => c.json({ dir: agentDir2(), providers: credentialStatus() }));
4545
4850
  api.post("/credentials", async (c) => {
@@ -4550,7 +4855,17 @@ api.post("/credentials", async (c) => {
4550
4855
  if (!key) return c.json({ error: "a key is required" }, 400);
4551
4856
  try {
4552
4857
  const file = await writeApiKey(provider, key);
4553
- const verified = body?.model ? await verifyModel({ pi: PI, model: String(body.model), cwd: CWD }) : null;
4858
+ let model = typeof body?.model === "string" && body.model.trim() ? body.model.trim() : "";
4859
+ if (!model) {
4860
+ for (const chat of manager.openChats()) {
4861
+ const found = (await chat.availableModels()).find((m) => m.provider === provider);
4862
+ if (found) {
4863
+ model = found.id;
4864
+ break;
4865
+ }
4866
+ }
4867
+ }
4868
+ const verified = model ? { model, ...await verifyModel({ pi: PI, model, cwd: CWD ?? DATA }) } : null;
4554
4869
  return c.json({ ok: true, provider, file, configured: credentialStatus().find((s) => s.id === provider)?.configured ?? true, verified });
4555
4870
  } catch (e) {
4556
4871
  return c.json({ error: String(e.message ?? e).slice(0, 200) }, 400);
@@ -4564,16 +4879,20 @@ api.get("/chats/:id/events", async (c) => {
4564
4879
  return streamSSE(c, async (stream2) => {
4565
4880
  const send = (event, data, cursor) => stream2.writeSSE({ event, data: JSON.stringify(data), ...cursor ? { id: cursor } : {} }).catch(() => {
4566
4881
  });
4567
- await send("state", snapshotOf(chat, space.id), chat.entryCursor);
4568
4882
  const entry = { chat: id, send };
4569
4883
  streams.add(entry);
4884
+ const aborted = new Promise((resolve) => stream2.onAbort(resolve));
4570
4885
  const ping = setInterval(() => {
4571
4886
  void stream2.writeSSE({ event: "ping", data: "{}" }).catch(() => {
4572
4887
  });
4573
4888
  }, 2e4);
4574
- await new Promise((resolve) => stream2.onAbort(resolve));
4575
- clearInterval(ping);
4576
- streams.delete(entry);
4889
+ try {
4890
+ await send("state", snapshotOf(chat, space.id), chat.entryCursor);
4891
+ await aborted;
4892
+ } finally {
4893
+ clearInterval(ping);
4894
+ streams.delete(entry);
4895
+ }
4577
4896
  });
4578
4897
  });
4579
4898
  api.get("/state", async (c) => {
@@ -4585,34 +4904,48 @@ api.get("/state", async (c) => {
4585
4904
  });
4586
4905
  api.post("/chat", async (c) => {
4587
4906
  const body = await c.req.json().catch(() => ({}));
4588
- const ws = workspaces.get(body.ws) ?? workspaces.get();
4589
- const id = String(body.id ?? `chat-${Date.now().toString(36)}`).replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 60);
4590
- workspaces.bindChat(id, ws.id);
4907
+ if (!body || typeof body !== "object") return c.json({ error: "a chat request object is required" }, 400);
4908
+ let id;
4909
+ let ws;
4910
+ if (body.id !== void 0) {
4911
+ id = String(body.id);
4912
+ ws = requireChatWorkspace(id);
4913
+ if (body.ws !== void 0 && body.ws !== ws.id) return c.json({ error: "an existing chat cannot move to another workspace" }, 409);
4914
+ } else {
4915
+ if (typeof body.ws !== "string" || !body.ws) return c.json({ error: "workspace required for a new chat" }, 400);
4916
+ ws = requireWorkspace(body.ws);
4917
+ if (!ws.open) return c.json({ error: "open the workspace before creating a chat" }, 409);
4918
+ id = `chat-${randomUUID()}`;
4919
+ workspaces.bindChat(id, ws.id);
4920
+ }
4591
4921
  watchOf(ws.id);
4592
4922
  await manager.open(id);
4593
4923
  return c.json({ chat: id, ws: ws.id, chats: chatsOf(ws.id) });
4594
4924
  });
4595
4925
  api.post("/chat/delete", async (c) => {
4596
4926
  const { chat: id } = await c.req.json();
4597
- const chat = manager.get(String(id));
4598
- const file = chat?.sessionFile ?? null;
4599
- await manager.close(String(id));
4600
- workspaces.unbindChat(String(id));
4601
- if (file && fs8.existsSync(file)) {
4602
- try {
4603
- fs8.rmSync(file);
4604
- } catch {
4605
- }
4606
- }
4927
+ requireChatWorkspace(String(id));
4928
+ await manager.close(String(id), () => {
4929
+ const file = workspaces.sessionFile(String(id));
4930
+ if (file && fs9.existsSync(file)) fs9.unlinkSync(file);
4931
+ workspaces.unbindChat(String(id));
4932
+ });
4607
4933
  return c.json({ ok: true });
4608
4934
  });
4609
4935
  api.get("/workspaces", (c) => c.json(workspaces.list()));
4936
+ api.get("/directories", async (c) => {
4937
+ try {
4938
+ return c.json(await browseDirectories(c.req.query("path"), c.req.query("hidden") === "1"));
4939
+ } catch (e) {
4940
+ const error = e;
4941
+ return c.json({ error: error.message }, error.code === "EACCES" || error.code === "EPERM" ? 403 : error.code === "ENOENT" || error.code === "ENOTDIR" ? 404 : 400);
4942
+ }
4943
+ });
4610
4944
  api.post("/workspaces", async (c) => {
4611
4945
  const body = await c.req.json().catch(() => ({}));
4612
4946
  try {
4613
- const dir = path8.resolve(String(body.path ?? "").replace(/^~(?=\/|$)/, process.env.HOME ?? "~"));
4614
- if (!dir || dir === "/") throw new Error("a path is required");
4615
- if (!fs8.existsSync(dir) || !fs8.statSync(dir).isDirectory()) throw new Error(`${dir} is not a directory`);
4947
+ const dir = directoryPath(body.path);
4948
+ if (!fs9.existsSync(dir) || !fs9.statSync(dir).isDirectory()) throw new Error(`${dir} is not a directory`);
4616
4949
  const ws = workspaces.add(dir);
4617
4950
  watchOf(ws.id);
4618
4951
  return c.json({ ok: true, workspace: ws, loopable: Workspaces.loopable(ws.path) });
@@ -4622,6 +4955,7 @@ api.post("/workspaces", async (c) => {
4622
4955
  });
4623
4956
  api.post("/workspaces/remove", async (c) => {
4624
4957
  const { id } = await c.req.json();
4958
+ if (!workspaces.get(String(id))) return c.json({ error: `unknown workspace: ${id}` }, 404);
4625
4959
  try {
4626
4960
  workspaces.remove(String(id));
4627
4961
  return c.json({ ok: true });
@@ -4647,11 +4981,25 @@ api.post("/abort", async (c) => {
4647
4981
  await chat.abort();
4648
4982
  return c.json({ ok: true });
4649
4983
  });
4984
+ api.post("/chat/retry", async (c) => {
4985
+ const { chat: id } = await c.req.json();
4986
+ requireChatWorkspace(String(id));
4987
+ try {
4988
+ await manager.retry(String(id));
4989
+ return c.json({ ok: true });
4990
+ } catch (e) {
4991
+ return c.json({ ok: false, error: String(e.message ?? e) }, 409);
4992
+ }
4993
+ });
4650
4994
  api.post("/ui", async (c) => {
4651
4995
  const { chat: id, id: dialogId, response } = await c.req.json();
4652
4996
  const chat = await manager.open(String(id));
4653
- await chat.answerUi(String(dialogId), response ?? {});
4654
- return c.json({ ok: true });
4997
+ try {
4998
+ await chat.answerUi(String(dialogId), response ?? {});
4999
+ return c.json({ ok: true });
5000
+ } catch (e) {
5001
+ return c.json({ ok: false, error: String(e.message ?? e) }, 409);
5002
+ }
4655
5003
  });
4656
5004
  api.post("/answer", async (c) => {
4657
5005
  const { chat: id, question, choice } = await c.req.json();
@@ -4696,9 +5044,8 @@ api.get("/session", async (c) => {
4696
5044
  if (!run3 || !name) return c.json([]);
4697
5045
  const state = stateOf(run3);
4698
5046
  const file = state?.rounds?.flatMap((r) => r.attempts).find((a) => a.label === name)?.session ?? state?.rounds?.flatMap((r) => r.attempts).find((a) => a.label?.toLowerCase() === name.toLowerCase())?.session;
4699
- if (!file || !fs8.existsSync(file)) return c.json([]);
4700
- const chat = await manager.open(c.req.query("chat") ?? manager.list()[0]?.id ?? "");
4701
- return c.json(chat.sessionItems(file));
5047
+ if (!file || !fs9.existsSync(file)) return c.json([]);
5048
+ return c.json(itemsFromMessages(readSessionMessages(file)));
4702
5049
  });
4703
5050
  api.post("/talk", async (c) => {
4704
5051
  const { chat: id, run: run3, name, text } = await c.req.json();
@@ -4715,21 +5062,22 @@ api.get("/shot", (c) => {
4715
5062
  const repo = wsOfRun(run3).path;
4716
5063
  if (file.startsWith(STATE_DIR)) {
4717
5064
  const abs2 = inside(repo, file);
4718
- if (!abs2 || !fs8.existsSync(abs2)) return c.notFound();
4719
- return new Response(fs8.readFileSync(abs2), { headers: { "content-type": abs2.endsWith(".jpg") || abs2.endsWith(".jpeg") ? "image/jpeg" : "image/png", "cache-control": "no-cache" } });
5065
+ if (!abs2 || !fs9.existsSync(abs2)) return c.notFound();
5066
+ return new Response(fs9.readFileSync(abs2), { headers: { "content-type": abs2.endsWith(".jpg") || abs2.endsWith(".jpeg") ? "image/jpeg" : "image/png", "cache-control": "no-cache" } });
4720
5067
  }
4721
5068
  const dir = attempt === "CURRENT" ? state?.best?.dir ?? null : state?.rounds?.flatMap((r) => r.attempts).find((a) => a.label === attempt || a.label?.toLowerCase() === attempt.toLowerCase())?.dir;
4722
5069
  if (!dir) return c.notFound();
4723
- const abs = inside(repo, path8.join(dir, file));
4724
- if (!abs || !fs8.existsSync(abs)) return c.notFound();
4725
- return new Response(fs8.readFileSync(abs), { headers: { "content-type": abs.endsWith(".jpg") || abs.endsWith(".jpeg") ? "image/jpeg" : "image/png", "cache-control": "no-cache" } });
5070
+ const abs = inside(repo, path9.join(dir, file));
5071
+ if (!abs || !fs9.existsSync(abs)) return c.notFound();
5072
+ return new Response(fs9.readFileSync(abs), { headers: { "content-type": abs.endsWith(".jpg") || abs.endsWith(".jpeg") ? "image/jpeg" : "image/png", "cache-control": "no-cache" } });
4726
5073
  });
4727
5074
  api.get("/sheet", (c) => {
4728
5075
  const file = c.req.query("f") ?? "";
4729
- const ws = workspaces.list().find((w) => fs8.existsSync(path8.join(w.path, file))) ?? workspaces.get();
5076
+ const ws = c.req.query("run") ? wsOfRun(c.req.query("run")) : workspaces.list().find((w) => fs9.existsSync(path9.join(w.path, file)));
5077
+ if (!ws) return c.notFound();
4730
5078
  const abs = inside(ws.path, file);
4731
- if (!abs || !abs.includes(STATE_DIR) || !fs8.existsSync(abs)) return c.notFound();
4732
- return new Response(fs8.readFileSync(abs), { headers: { "content-type": "image/png", "cache-control": "no-cache" } });
5079
+ if (!abs || !abs.includes(STATE_DIR) || !fs9.existsSync(abs)) return c.notFound();
5080
+ return new Response(fs9.readFileSync(abs), { headers: { "content-type": "image/png", "cache-control": "no-cache" } });
4733
5081
  });
4734
5082
  api.get("/diff", async (c) => {
4735
5083
  const run3 = c.req.query("run") ?? "";
@@ -4742,8 +5090,8 @@ api.get("/diff", async (c) => {
4742
5090
  const from = state.baseCommit;
4743
5091
  if (!to || !from) return c.text("");
4744
5092
  return c.text(await new Promise((resolve) => {
4745
- execFile("git", ["diff", "--stat", `${from}..${to}`], { cwd: wsOfRun(run3).path, maxBuffer: 1 << 26 }, (_e, stat) => {
4746
- execFile("git", ["diff", `${from}..${to}`], { cwd: wsOfRun(run3).path, maxBuffer: 1 << 26 }, (_e2, diff) => resolve(`${stat}
5093
+ execFile2("git", ["diff", "--stat", `${from}..${to}`], { cwd: wsOfRun(run3).path, maxBuffer: 1 << 26 }, (_e, stat) => {
5094
+ execFile2("git", ["diff", `${from}..${to}`], { cwd: wsOfRun(run3).path, maxBuffer: 1 << 26 }, (_e2, diff) => resolve(`${stat}
4747
5095
  ${diff}`.slice(0, 4e5)));
4748
5096
  });
4749
5097
  }));
@@ -4766,7 +5114,7 @@ api.get("/proposals", (c) => c.json(proposals));
4766
5114
  api.post("/proposals/:id/apply", (c) => {
4767
5115
  const p = proposals.find((x) => x.id === c.req.param("id"));
4768
5116
  if (!p) return c.json({ ok: false, error: "no such proposal" }, 404);
4769
- const cwd = p.ws ? workspaces.get(p.ws).path : CWD;
5117
+ const cwd = requireWorkspace(p.ws).path;
4770
5118
  acceptConfig(cwd, p.after);
4771
5119
  const w = p.ws ? watches.get(p.ws) : null;
4772
5120
  if (w) w.accepted = p.after;
@@ -4793,7 +5141,7 @@ api.post("/config", async (c) => {
4793
5141
  writeSetting(ws.path, String(key), value);
4794
5142
  const w = watchOf(ws.id);
4795
5143
  try {
4796
- w.accepted = JSON.parse(fs8.readFileSync(path8.join(ws.path, ".pi", "loops.json"), "utf8"));
5144
+ w.accepted = JSON.parse(fs9.readFileSync(path9.join(ws.path, ".pi", "loops.json"), "utf8"));
4797
5145
  } catch {
4798
5146
  }
4799
5147
  return c.json({ ok: true });
@@ -4810,7 +5158,7 @@ api.get("/size", async (c) => {
4810
5158
  const state = stateOf(run3);
4811
5159
  if (!state) return c.json({ bytes: 0, dirs: 0 });
4812
5160
  const dirs = (state.rounds ?? []).flatMap((r) => r.attempts).map((a) => a.dir).filter(Boolean);
4813
- const bytes = await new Promise((resolve) => execFile("du", ["-sk", ...dirs], { cwd: wsOfRun(run3).path }, (_e, out) => resolve(Number(String(out ?? "").split("\n").map((l) => Number(l.split(/\s+/)[0]) || 0).reduce((a, b) => a + b, 0)) * 1024)));
5161
+ const bytes = await new Promise((resolve) => execFile2("du", ["-sk", ...dirs], { cwd: wsOfRun(run3).path }, (_e, out) => resolve(Number(String(out ?? "").split("\n").map((l) => Number(l.split(/\s+/)[0]) || 0).reduce((a, b) => a + b, 0)) * 1024)));
4814
5162
  return c.json({ bytes, dirs: dirs.length });
4815
5163
  });
4816
5164
  api.get("/files", (c) => {
@@ -4840,7 +5188,8 @@ api.get("/meta", (c) => c.json({
4840
5188
  }));
4841
5189
  app.route("/api", api);
4842
5190
  app.route("/api/v1", api);
4843
- var DIST = path8.join(APP_ROOT, "dist");
5191
+ app.all("/api/*", (c) => c.json({ error: `unknown API route: ${c.req.path}` }, 404));
5192
+ var DIST = path9.join(APP_ROOT, "dist");
4844
5193
  app.get("*", async (c) => {
4845
5194
  const url = c.req.path;
4846
5195
  if (DEV) {
@@ -4849,14 +5198,14 @@ app.get("*", async (c) => {
4849
5198
  return new Response(res.body, { status: res.status, headers: { "content-type": res.headers.get("content-type") ?? "text/html" } });
4850
5199
  }
4851
5200
  const abs = inside(DIST, url === "/" ? "index.html" : url.replace(/^\//, ""));
4852
- if (abs && fs8.existsSync(abs) && fs8.statSync(abs).isFile()) {
4853
- const ext = path8.extname(abs);
5201
+ if (abs && fs9.existsSync(abs) && fs9.statSync(abs).isFile()) {
5202
+ const ext = path9.extname(abs);
4854
5203
  const type = ext === ".js" ? "text/javascript" : ext === ".css" ? "text/css" : ext === ".woff2" ? "font/woff2" : ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png" : "text/html";
4855
- return new Response(fs8.readFileSync(abs), { headers: { "content-type": type } });
5204
+ return new Response(fs9.readFileSync(abs), { headers: { "content-type": type } });
4856
5205
  }
4857
- const index = path8.join(DIST, "index.html");
4858
- if (!fs8.existsSync(index)) return c.text("run `npm run build` first", 500);
4859
- return new Response(fs8.readFileSync(index), { headers: { "content-type": "text/html" } });
5206
+ const index = path9.join(DIST, "index.html");
5207
+ if (!fs9.existsSync(index)) return c.text("run `npm run build` first", 500);
5208
+ return new Response(fs9.readFileSync(index), { headers: { "content-type": "text/html" } });
4860
5209
  });
4861
5210
  var server = http.createServer(getRequestListener(app.fetch));
4862
5211
  setInterval(() => {
@@ -4876,7 +5225,7 @@ function openBrowser(url) {
4876
5225
  server.listen(PORT, HOST, () => {
4877
5226
  const reach = HOST === "0.0.0.0" ? "127.0.0.1" : HOST;
4878
5227
  const shown = reach.includes(":") ? `[${reach}]` : reach;
4879
- const base = `http://${shown}:${PORT}`;
5228
+ const base = PUBLIC_ORIGINS[0] ?? `http://${shown}:${PORT}`;
4880
5229
  const url = TOKEN ? `${base}/?server=${encodeURIComponent(base)}&token=${encodeURIComponent(TOKEN)}` : base;
4881
5230
  console.log(`
4882
5231
  open ${url}`);