pi-central 0.4.0 → 0.5.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/README.md CHANGED
@@ -110,19 +110,43 @@ passed `--port`.
110
110
  ### From npm — what a user does
111
111
 
112
112
  ```bash
113
- npm install --global pi-central
113
+ npm install --global pi-central # inert: this package runs no install scripts
114
+ npm audit signatures # verify BEFORE the first run
114
115
  pi-central
115
116
  ```
116
117
 
117
- Or without installing it at all:
118
+ **The order is the point, and it is only sound because of the comment on line one.** `npm install`
119
+ executes no code from this package — it declares no `preinstall`, `install`, `postinstall` or `prepare`
120
+ script, and a test asserts that stays true. So there is a window between "the bytes are on disk" and
121
+ "anything has run" in which you can check what you got.
118
122
 
119
- ```bash
120
- npx pi-central
121
- ```
123
+ `npx pi-central` has no such window: it fetches and executes in one step. It is convenient and it is the
124
+ least verifiable way to run this. If you use it, **pin an exact version** (`npx pi-central@<version>`, where
125
+ `<version>` is what `pi-central --version` reports for the copy you mean) so you at least know which artifact
126
+ you asked for. The number is not written here on purpose: a README that restates a version goes stale the
127
+ moment the next release ships, and `scripts/check-version.mjs` refuses to let one.
122
128
 
123
129
  Once it is running, [install it as an app](#install-it-as-an-app) and you will not need the terminal
124
130
  again until the next reboot.
125
131
 
132
+ #### Verify what you installed
133
+
134
+ ```bash
135
+ npm audit signatures
136
+ ```
137
+
138
+ Every release carries a **provenance attestation** — a signed statement, minted by the release
139
+ workflow's OIDC identity rather than a stored token, that this tarball came from this commit via this
140
+ workflow. That command checks it, along with the registry signature.
141
+
142
+ **Be clear about what this does and does not do.** It is a check you have to run; npm does not refuse
143
+ an unattested tarball, and neither does the app. And it answers *"did the publisher produce this?"* —
144
+ **not** *"is the publisher trustworthy?"*. Pi Central runs **outside** the sandbox (see the constraint
145
+ above) with your privileges, deliberately, because it is the component that spawns the sandboxed
146
+ sessions. A malicious release of it would not need an exploit; it would have exactly what you have.
147
+ The attestation is what lets you tell a tampered tarball from a genuine one. It cannot make a genuine
148
+ tarball safe.
149
+
126
150
  The package is **one bundled ESM file plus the built UI**, with a single runtime dependency (`ws`),
127
151
  and `pi-central --version` reports the version of the package it was actually shipped in.
128
152
 
package/bin/pi-central.js CHANGED
@@ -25,7 +25,7 @@ function isBlockingDialog(record) {
25
25
  }
26
26
 
27
27
  // packages/protocol/src/envelopes.ts
28
- var PROTOCOL_VERSION = 3;
28
+ var PROTOCOL_VERSION = 4;
29
29
 
30
30
  // packages/protocol/src/own-installation.ts
31
31
  var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
@@ -562,13 +562,13 @@ var RpcProcess = class extends EventEmitter {
562
562
  const child = this.#child;
563
563
  if (!child || !child.stdin.writable) return Promise.reject(new Error("session is not running"));
564
564
  const id = `c${++this.#seq}`;
565
- return new Promise((resolve8, reject) => {
565
+ return new Promise((resolve9, reject) => {
566
566
  const timer = setTimeout(() => {
567
567
  this.#pending.delete(id);
568
568
  reject(new Error(`timeout after ${timeoutMs}ms waiting for ${command.type}`));
569
569
  }, timeoutMs);
570
570
  timer.unref?.();
571
- this.#pending.set(id, { resolve: resolve8, reject, timer });
571
+ this.#pending.set(id, { resolve: resolve9, reject, timer });
572
572
  child.stdin.write(encodeLine({ ...command, id }));
573
573
  });
574
574
  }
@@ -611,12 +611,12 @@ var RpcProcess = class extends EventEmitter {
611
611
  #waitForExit(ms) {
612
612
  const child = this.#child;
613
613
  if (!child || this.#exited) return Promise.resolve(true);
614
- return new Promise((resolve8) => {
615
- const timer = setTimeout(() => resolve8(this.#exited), ms);
614
+ return new Promise((resolve9) => {
615
+ const timer = setTimeout(() => resolve9(this.#exited), ms);
616
616
  timer.unref?.();
617
617
  child.once("exit", () => {
618
618
  clearTimeout(timer);
619
- resolve8(true);
619
+ resolve9(true);
620
620
  });
621
621
  });
622
622
  }
@@ -629,8 +629,41 @@ var RpcProcess = class extends EventEmitter {
629
629
  }
630
630
  };
631
631
 
632
+ // packages/orchestrator/src/retry.ts
633
+ import { statSync as statSync3 } from "node:fs";
634
+ function retryRefusal(session) {
635
+ if (session.state === "starting" || session.state === "streaming" || session.state === "awaiting-approval") {
636
+ return "This session is still running. Stop it before retrying.";
637
+ }
638
+ if (session.state !== "failed") {
639
+ return `Only a failed session can be retried; this one is ${session.state}.`;
640
+ }
641
+ return directoryRefusal(session.cwd);
642
+ }
643
+ function directoryRefusal(cwd) {
644
+ let stats;
645
+ try {
646
+ stats = statSync3(cwd);
647
+ } catch (error) {
648
+ return refusalForCode(error.code, cwd);
649
+ }
650
+ if (!stats.isDirectory()) {
651
+ return `Cannot retry: ${cwd} is no longer a directory.`;
652
+ }
653
+ return void 0;
654
+ }
655
+ function refusalForCode(code, cwd) {
656
+ if (code === "ENOENT") {
657
+ return `Cannot retry: ${cwd} no longer exists. Open a directory that exists, or start a new session.`;
658
+ }
659
+ if (code === "EACCES" || code === "EPERM") {
660
+ return `Cannot retry: ${cwd} is not readable by this process (${code}). The directory exists; its permissions changed.`;
661
+ }
662
+ return `Cannot retry: ${cwd} cannot be used (${code ?? "unknown error"}).`;
663
+ }
664
+
632
665
  // packages/orchestrator/src/resume.ts
633
- import { accessSync as accessSync2, constants as constants2, statSync as statSync3 } from "node:fs";
666
+ import { accessSync as accessSync2, constants as constants2, statSync as statSync4 } from "node:fs";
634
667
  import { resolve as resolve4 } from "node:path";
635
668
  function checkResumable(session) {
636
669
  if (!session.cwd) {
@@ -643,7 +676,7 @@ function checkResumable(session) {
643
676
  }
644
677
  let stats;
645
678
  try {
646
- stats = statSync3(session.cwd);
679
+ stats = statSync4(session.cwd);
647
680
  } catch (error) {
648
681
  const code = error.code;
649
682
  const unreadable = code === "EACCES" || code === "EPERM";
@@ -1129,7 +1162,7 @@ function renderPolicyFragment(options = {}) {
1129
1162
  }
1130
1163
 
1131
1164
  // packages/orchestrator/src/files.ts
1132
- import { lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync4 } from "node:fs";
1165
+ import { lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync5 } from "node:fs";
1133
1166
  import { isAbsolute, join as join7, resolve as resolve6, sep as sep3 } from "node:path";
1134
1167
  var MAX_FILE_BYTES = 256 * 1024;
1135
1168
  var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
@@ -1270,7 +1303,7 @@ function describeEntry(fullPath, name) {
1270
1303
  }
1271
1304
  function linkTargetOf(fullPath) {
1272
1305
  try {
1273
- const stats = statSync4(fullPath);
1306
+ const stats = statSync5(fullPath);
1274
1307
  if (stats.isDirectory()) return "directory";
1275
1308
  if (stats.isFile()) return "file";
1276
1309
  return "other";
@@ -1524,6 +1557,56 @@ var Supervisor = class extends EventEmitter2 {
1524
1557
  }
1525
1558
  return void 0;
1526
1559
  }
1560
+ /**
1561
+ * Re-spawn a session that failed, in the directory it already had (issue #11).
1562
+ *
1563
+ * ## Why this is not "stop, then start"
1564
+ *
1565
+ * The reader is asking for THIS session to try again. A client-side stop+start would put a second row in
1566
+ * the rail beside the failed one, and the roster (keyed by session file) would then have two live entries
1567
+ * pointing at one conversation. Reusing the id keeps the row where the reader put it.
1568
+ *
1569
+ * ## Refusals, all before anything is spawned
1570
+ *
1571
+ * - **Not failed** → refused. A silent restart is how a crash loop hides itself, and a running session is
1572
+ * not broken.
1573
+ * - **Directory gone or unreadable** → refused with the reason, for the same purpose `resume` refuses: RPC
1574
+ * Pi exits 1 on a missing directory rather than prompting (C2), so re-spawning would produce a second
1575
+ * failure that says less than the first.
1576
+ *
1577
+ * ## What is preserved
1578
+ *
1579
+ * `previousError` holds the last failure. Clearing `error` is what makes the retry a retry — the composer
1580
+ * becomes usable again — but erasing the evidence would leave a reader unable to compare attempts.
1581
+ */
1582
+ async retry(id) {
1583
+ const existing = this.#sessions.get(id);
1584
+ if (existing === void 0) return { ok: false, message: `no such session: ${id}` };
1585
+ const refusal = retryRefusal(existing);
1586
+ if (refusal !== void 0) return { ok: false, message: refusal };
1587
+ const previous = this.#processes.get(id);
1588
+ this.#processes.delete(id);
1589
+ await previous?.stop().catch(() => void 0);
1590
+ return this.#spawn({
1591
+ id,
1592
+ cwd: existing.cwd,
1593
+ mode: existing.mode,
1594
+ /*
1595
+ * No `--name` when the session already has a file.
1596
+ *
1597
+ * `resume` makes the same choice for the same reason: the session carries its own name, and re-applying
1598
+ * one appends a redundant `session_info` record to a file we did not create. A retry of a session that
1599
+ * already wrote records is exactly that case. When there is no file yet the name is still needed, so it
1600
+ * is passed only then.
1601
+ */
1602
+ ...existing.sessionFile === void 0 ? { name: existing.name } : {},
1603
+ sessionPath: existing.sessionFile,
1604
+ previousError: existing.error ?? existing.previousError
1605
+ // `enable` and `env` are not part of `LiveSession`, so a retry cannot reproduce logins granted per
1606
+ // spawn. Stated here rather than silently lost: a retried session may come back with fewer
1607
+ // capabilities than the original, which is visible in the composer when a credential is missing.
1608
+ });
1609
+ }
1527
1610
  #spawn(input) {
1528
1611
  const env = { ...agentRoutingEnv(this.#config.agentRoot), ...input.env ?? {} };
1529
1612
  const providerAuth = inspectProviderAuth(this.#config.providerAgentDir);
@@ -1587,7 +1670,8 @@ var Supervisor = class extends EventEmitter2 {
1587
1670
  ...ownInstallationWarning(input.cwd, this.#installationRoot) !== void 0 ? [ownInstallationWarning(input.cwd, this.#installationRoot)] : []
1588
1671
  ],
1589
1672
  startedAt: Date.now(),
1590
- ...input.sessionPath ? { sessionFile: input.sessionPath } : {}
1673
+ ...input.sessionPath ? { sessionFile: input.sessionPath } : {},
1674
+ ...input.previousError ? { previousError: input.previousError } : {}
1591
1675
  };
1592
1676
  this.#sessions.set(session.id, session);
1593
1677
  const rpc = new RpcProcess(this.#buildLaunch(policy.launch, input.sessionPath));
@@ -1946,13 +2030,48 @@ ${rpc.stderrTail}` : ""}`;
1946
2030
  }
1947
2031
  };
1948
2032
  function describeExitFailure(code, signal, stderr) {
1949
- const base = `pi exited (code=${code ?? "-"}, signal=${signal ?? "-"})${stderr ? `
1950
- ${stderr}` : ""}`;
2033
+ const reason = exitReason(code, signal);
2034
+ const tail = boundedTail(stderr);
2035
+ const base = `${reason}${tail ? `
2036
+ ${tail}` : ""}`;
1951
2037
  if (!/sandbox_apply|Operation not permitted/i.test(stderr)) return base;
1952
2038
  return `${base}
1953
2039
 
1954
2040
  This looks like Seatbelt refusing to nest (C1). The orchestrator must run from a plain terminal, not inside another sandbox, editor task, or agent harness -- because \`safehouse\` applies its policy with \`sandbox-exec\`, and that cannot run inside an existing sandbox.`;
1955
2041
  }
2042
+ var STDERR_TAIL_LINES = 12;
2043
+ function exitReason(code, signal) {
2044
+ const raw = `pi exited (code=${code ?? "-"}, signal=${signal ?? "-"})`;
2045
+ if (signal) return `${raw} \u2014 killed by ${signal}`;
2046
+ if (code !== null && code > 128 && code <= 128 + 64) {
2047
+ const name = signame(code - 128);
2048
+ if (name !== void 0) return `${raw} \u2014 killed by ${name}`;
2049
+ }
2050
+ return raw;
2051
+ }
2052
+ function signame(number) {
2053
+ const names = {
2054
+ 1: "SIGHUP",
2055
+ 2: "SIGINT",
2056
+ 3: "SIGQUIT",
2057
+ 6: "SIGABRT",
2058
+ 9: "SIGKILL",
2059
+ 11: "SIGSEGV",
2060
+ 13: "SIGPIPE",
2061
+ 15: "SIGTERM"
2062
+ };
2063
+ return names[number];
2064
+ }
2065
+ function boundedTail(stderr) {
2066
+ const text = stderr.replace(/\s+$/, "");
2067
+ if (text === "") return "";
2068
+ const lines = text.split("\n");
2069
+ if (lines.length <= STDERR_TAIL_LINES) return text;
2070
+ const kept = lines.slice(-STDERR_TAIL_LINES);
2071
+ const omitted = lines.length - STDERR_TAIL_LINES;
2072
+ return `\u2026 ${omitted} earlier line${omitted === 1 ? "" : "s"} of output omitted
2073
+ ${kept.join("\n")}`;
2074
+ }
1956
2075
  function defaultName(cwd, id) {
1957
2076
  const base = cwd.split("/").filter(Boolean).pop();
1958
2077
  return base && base.length > 0 ? base : id;
@@ -2003,18 +2122,54 @@ function readPackageJson(path) {
2003
2122
  return void 0;
2004
2123
  }
2005
2124
  }
2125
+ var VERIFY_INSTALL_HINT = "npm audit signatures";
2006
2126
 
2007
2127
  // packages/orchestrator/src/registry.ts
2008
- import { createReadStream, readdirSync as readdirSync3, statSync as statSync5 } from "node:fs";
2009
- import { join as join10 } from "node:path";
2128
+ import { createReadStream, readdirSync as readdirSync3, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs";
2129
+ import { basename, dirname as dirname2, join as join10, resolve as resolve7, sep as sep4 } from "node:path";
2010
2130
  var KNOWN_SESSION_VERSIONS = /* @__PURE__ */ new Set([3]);
2011
2131
  var HEAD_BYTES = 256 * 1024;
2132
+ var TAIL_BYTES = 8 * 1024;
2012
2133
  function decodeSessionDir(dirName) {
2013
2134
  const inner = dirName.replace(/^--/, "").replace(/--$/, "");
2014
2135
  return `/${inner.replace(/-/g, "/")}`;
2015
2136
  }
2137
+ async function readLateSessionName(path) {
2138
+ let stats;
2139
+ try {
2140
+ stats = statSync6(path);
2141
+ } catch {
2142
+ return void 0;
2143
+ }
2144
+ const start = Math.max(0, stats.size - TAIL_BYTES);
2145
+ if (start === 0) return void 0;
2146
+ const text = await new Promise((resolve9) => {
2147
+ const chunks = [];
2148
+ const stream = createReadStream(path, { start, end: stats.size - 1 });
2149
+ stream.on("data", (chunk) => chunks.push(chunk));
2150
+ stream.on("error", () => resolve9(""));
2151
+ stream.on("close", () => resolve9(Buffer.concat(chunks).toString("utf8")));
2152
+ });
2153
+ if (text === "") return void 0;
2154
+ const lines = text.split("\n");
2155
+ lines.shift();
2156
+ let name;
2157
+ for (const line of lines) {
2158
+ if (!line.includes('"session_info"')) continue;
2159
+ try {
2160
+ const record = JSON.parse(line);
2161
+ if (record.type === "session_info" && typeof record.name === "string") {
2162
+ const trimmed = record.name.trim();
2163
+ if (trimmed.length > 0) name = trimmed;
2164
+ }
2165
+ } catch {
2166
+ continue;
2167
+ }
2168
+ }
2169
+ return name;
2170
+ }
2016
2171
  async function readHead(path) {
2017
- return new Promise((resolve8) => {
2172
+ return new Promise((resolve9) => {
2018
2173
  const chunks = [];
2019
2174
  let size = 0;
2020
2175
  const stream = createReadStream(path, { start: 0, end: HEAD_BYTES - 1 });
@@ -2022,8 +2177,8 @@ async function readHead(path) {
2022
2177
  chunks.push(chunk);
2023
2178
  size += chunk.length;
2024
2179
  });
2025
- stream.on("error", () => resolve8(""));
2026
- stream.on("close", () => resolve8(Buffer.concat(chunks, size).toString("utf8")));
2180
+ stream.on("error", () => resolve9(""));
2181
+ stream.on("close", () => resolve9(Buffer.concat(chunks, size).toString("utf8")));
2027
2182
  });
2028
2183
  }
2029
2184
  function parseHead(text) {
@@ -2079,6 +2234,57 @@ function flattenContent(content) {
2079
2234
  }
2080
2235
  return "";
2081
2236
  }
2237
+ async function describeSessionFile(config, path, projectDir, stats) {
2238
+ const head = await readHead(path);
2239
+ const parsed = parseHead(head);
2240
+ if (!parsed) return { ok: false, reason: "not a Pi session file (no valid header)" };
2241
+ const lateName = stats.size > HEAD_BYTES ? await readLateSessionName(path) : void 0;
2242
+ if (!KNOWN_SESSION_VERSIONS.has(parsed.header.version)) {
2243
+ return { ok: false, reason: `unknown session version ${parsed.header.version}` };
2244
+ }
2245
+ const cwd = parsed.header.cwd || decodeSessionDir(projectDir);
2246
+ return {
2247
+ ok: true,
2248
+ session: {
2249
+ path,
2250
+ id: parsed.header.id,
2251
+ cwd,
2252
+ // The late name wins: it is the most recent rename, and the head's is the earliest one.
2253
+ ...lateName ?? parsed.name ? { name: lateName ?? parsed.name } : {},
2254
+ created: parsed.header.timestamp,
2255
+ modifiedMs: stats.mtimeMs,
2256
+ bytes: stats.size,
2257
+ messageCount: parsed.messageCount,
2258
+ firstMessage: parsed.firstMessage,
2259
+ cwdExists: directoryExists(cwd),
2260
+ scratch: isScratch(config, cwd)
2261
+ }
2262
+ };
2263
+ }
2264
+ async function sessionAt(config, requested) {
2265
+ if (typeof requested !== "string" || requested.length === 0 || requested.includes("\0")) return void 0;
2266
+ let root;
2267
+ let real;
2268
+ try {
2269
+ root = realpathSync2(config.sessionsRoot);
2270
+ real = realpathSync2(resolve7(requested));
2271
+ } catch {
2272
+ return void 0;
2273
+ }
2274
+ const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(sep4) ? parent : parent + sep4);
2275
+ if (!inside(root, real)) return void 0;
2276
+ if (!real.endsWith(".jsonl")) return void 0;
2277
+ let stats;
2278
+ try {
2279
+ stats = statSync6(real);
2280
+ } catch {
2281
+ return void 0;
2282
+ }
2283
+ if (!stats.isFile()) return void 0;
2284
+ const projectDir = basename(dirname2(real));
2285
+ const row = await describeSessionFile(config, real, projectDir, stats);
2286
+ return row.ok ? row.session : void 0;
2287
+ }
2082
2288
  async function discover(config, limit = 500) {
2083
2289
  const sessions = [];
2084
2290
  const skipped = [];
@@ -2102,33 +2308,16 @@ async function discover(config, limit = 500) {
2102
2308
  const path = join10(projectPath, entry.name);
2103
2309
  let stats;
2104
2310
  try {
2105
- stats = statSync5(path);
2311
+ stats = statSync6(path);
2106
2312
  } catch {
2107
2313
  continue;
2108
2314
  }
2109
- const parsed = parseHead(await readHead(path));
2110
- if (!parsed) {
2111
- skipped.push({ path, reason: "not a Pi session file (no valid header)" });
2112
- continue;
2113
- }
2114
- if (!KNOWN_SESSION_VERSIONS.has(parsed.header.version)) {
2115
- skipped.push({ path, reason: `unknown session version ${parsed.header.version}` });
2315
+ const row = await describeSessionFile(config, path, projectDir, stats);
2316
+ if (!row.ok) {
2317
+ skipped.push({ path, reason: row.reason });
2116
2318
  continue;
2117
2319
  }
2118
- const cwd = parsed.header.cwd || decodeSessionDir(projectDir);
2119
- sessions.push({
2120
- path,
2121
- id: parsed.header.id,
2122
- cwd,
2123
- ...parsed.name ? { name: parsed.name } : {},
2124
- created: parsed.header.timestamp,
2125
- modifiedMs: stats.mtimeMs,
2126
- bytes: stats.size,
2127
- messageCount: parsed.messageCount,
2128
- firstMessage: parsed.firstMessage,
2129
- cwdExists: directoryExists(cwd),
2130
- scratch: isScratch(config, cwd)
2131
- });
2320
+ sessions.push(row.session);
2132
2321
  }
2133
2322
  }
2134
2323
  sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
@@ -2136,15 +2325,15 @@ async function discover(config, limit = 500) {
2136
2325
  }
2137
2326
  function directoryExists(dir) {
2138
2327
  try {
2139
- return statSync5(dir).isDirectory();
2328
+ return statSync6(dir).isDirectory();
2140
2329
  } catch {
2141
2330
  return false;
2142
2331
  }
2143
2332
  }
2144
2333
 
2145
2334
  // packages/orchestrator/src/static-ui.ts
2146
- import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync6 } from "node:fs";
2147
- import { extname, join as join11, posix, resolve as resolve7, sep as sep4 } from "node:path";
2335
+ import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync7 } from "node:fs";
2336
+ import { extname, join as join11, posix, resolve as resolve8, sep as sep5 } from "node:path";
2148
2337
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2149
2338
  var UI_CSP = (
2150
2339
  // `blob:` is REQUIRED and is not covered by `'self'` (issue #111).
@@ -2181,7 +2370,7 @@ var CONTENT_TYPES = {
2181
2370
  };
2182
2371
  function resolveUiDistDir(env = process.env, from = import.meta.url) {
2183
2372
  const explicit = env["PI_CENTRAL_UI_DIST"];
2184
- if (explicit !== void 0 && explicit !== "") return resolve7(explicit);
2373
+ if (explicit !== void 0 && explicit !== "") return resolve8(explicit);
2185
2374
  const own = findOwnPackage(from);
2186
2375
  if (own === void 0) {
2187
2376
  return fileURLToPath3(new URL("../../ui/dist", from));
@@ -2199,13 +2388,13 @@ function runningFromSource(from) {
2199
2388
  return /[\\/]packages[\\/][^\\/]+[\\/]src[\\/]/.test(fileURLToPath3(from));
2200
2389
  }
2201
2390
  function isInside3(root, candidate) {
2202
- const rootPath = resolve7(root);
2203
- const target = resolve7(candidate);
2391
+ const rootPath = resolve8(root);
2392
+ const target = resolve8(candidate);
2204
2393
  if (target === rootPath) return true;
2205
- return target.startsWith(rootPath.endsWith(sep4) ? rootPath : rootPath + sep4);
2394
+ return target.startsWith(rootPath.endsWith(sep5) ? rootPath : rootPath + sep5);
2206
2395
  }
2207
2396
  function resolveStaticPath(root, requestTarget) {
2208
- const rootPath = resolve7(root);
2397
+ const rootPath = resolve8(root);
2209
2398
  const target = requestTarget.split("?")[0].split("#")[0];
2210
2399
  let decoded;
2211
2400
  try {
@@ -2219,16 +2408,16 @@ function resolveStaticPath(root, requestTarget) {
2219
2408
  return { kind: "denied", reason: "the request target contains a '..' segment" };
2220
2409
  }
2221
2410
  const relative = posix.normalize(decoded).replace(/^\/+/, "");
2222
- const candidate = resolve7(rootPath, relative);
2411
+ const candidate = resolve8(rootPath, relative);
2223
2412
  if (!isInside3(rootPath, candidate)) {
2224
2413
  return { kind: "denied", reason: "the request target resolves outside the served UI root" };
2225
2414
  }
2226
2415
  return { kind: "file", path: candidate };
2227
2416
  }
2228
2417
  function createUiRequestHandler(options) {
2229
- const root = resolve7(options.root);
2418
+ const root = resolve8(options.root);
2230
2419
  const csp = options.csp ?? UI_CSP;
2231
- const indexPath = resolve7(root, "index.html");
2420
+ const indexPath = resolve8(root, "index.html");
2232
2421
  return (req, res) => {
2233
2422
  const method = req.method ?? "GET";
2234
2423
  if (method !== "GET" && method !== "HEAD") {
@@ -2279,7 +2468,7 @@ Build it with:
2279
2468
  }
2280
2469
  function isReadableFile(path) {
2281
2470
  try {
2282
- return statSync6(path).isFile();
2471
+ return statSync7(path).isFile();
2283
2472
  } catch {
2284
2473
  return false;
2285
2474
  }
@@ -2296,9 +2485,9 @@ function sendText(res, method, status, body, csp) {
2296
2485
  else res.end(body);
2297
2486
  }
2298
2487
  function sendFile(res, method, path, csp) {
2299
- const info = statSync6(path);
2488
+ const info = statSync7(path);
2300
2489
  const type = CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
2301
- const immutable = path.includes(`${sep4}assets${sep4}`);
2490
+ const immutable = path.includes(`${sep5}assets${sep5}`);
2302
2491
  res.writeHead(200, {
2303
2492
  "content-type": type,
2304
2493
  "content-length": String(info.size),
@@ -2315,27 +2504,29 @@ function sendFile(res, method, path, csp) {
2315
2504
  stream.pipe(res);
2316
2505
  }
2317
2506
  function hasBuiltUi(root) {
2318
- return existsSync6(resolve7(root, "index.html"));
2507
+ return existsSync6(resolve8(root, "index.html"));
2319
2508
  }
2320
2509
 
2321
2510
  // packages/orchestrator/src/transport.ts
2322
- var KNOWN_CONTROL_KINDS = /* @__PURE__ */ new Set([
2323
- "list",
2324
- "start",
2325
- "prompt",
2326
- "clearQueue",
2327
- "abort",
2328
- "stop",
2329
- "rename",
2330
- "setModel",
2331
- "listModels",
2332
- "setThinkingLevel",
2333
- "listThinkingLevels",
2334
- "answerDialog",
2335
- "resume",
2336
- "listFiles",
2337
- "readFile"
2338
- ]);
2511
+ var CONTROL_KINDS = {
2512
+ list: true,
2513
+ start: true,
2514
+ prompt: true,
2515
+ clearQueue: true,
2516
+ abort: true,
2517
+ stop: true,
2518
+ rename: true,
2519
+ setModel: true,
2520
+ listModels: true,
2521
+ setThinkingLevel: true,
2522
+ listThinkingLevels: true,
2523
+ answerDialog: true,
2524
+ resume: true,
2525
+ retry: true,
2526
+ listFiles: true,
2527
+ readFile: true
2528
+ };
2529
+ var KNOWN_CONTROL_KINDS = new Set(Object.keys(CONTROL_KINDS));
2339
2530
  function parseControl(raw) {
2340
2531
  let parsed;
2341
2532
  try {
@@ -2399,11 +2590,11 @@ var Transport = class {
2399
2590
  this.#server = server;
2400
2591
  this.#wss = wss;
2401
2592
  wss.on("connection", (socket) => this.#onConnection(socket));
2402
- await new Promise((resolve8, reject) => {
2593
+ await new Promise((resolve9, reject) => {
2403
2594
  server.once("error", reject);
2404
2595
  server.listen(this.#options.port, this.#options.host, () => {
2405
2596
  server.off("error", reject);
2406
- resolve8();
2597
+ resolve9();
2407
2598
  });
2408
2599
  });
2409
2600
  const addr = server.address();
@@ -2535,10 +2726,12 @@ var Transport = class {
2535
2726
  return;
2536
2727
  }
2537
2728
  case "resume": {
2538
- const discovered = await discover(this.#config);
2539
- const target = discovered.sessions.find((s) => s.path === control.path);
2729
+ const target = await sessionAt(this.#config, control.path);
2540
2730
  if (!target) {
2541
- this.#broadcast({ kind: "error", message: `no such session: ${control.path}` });
2731
+ this.#broadcast({
2732
+ kind: "error",
2733
+ message: `no such session: ${control.path}`
2734
+ });
2542
2735
  return;
2543
2736
  }
2544
2737
  const result = this.#supervisor.resume(target, control.mode ? { mode: control.mode } : {});
@@ -2549,6 +2742,15 @@ var Transport = class {
2549
2742
  this.#broadcast({ kind: "session", session: result.session });
2550
2743
  return;
2551
2744
  }
2745
+ case "retry": {
2746
+ const result = await this.#supervisor.retry(control.sessionId);
2747
+ if (!result.ok) {
2748
+ this.#broadcast({ kind: "error", message: result.message });
2749
+ return;
2750
+ }
2751
+ this.#broadcast({ kind: "session", session: result.session });
2752
+ return;
2753
+ }
2552
2754
  case "prompt":
2553
2755
  await this.#supervisor.prompt(
2554
2756
  control.sessionId,
@@ -2710,13 +2912,13 @@ var Transport = class {
2710
2912
  this.#wss = void 0;
2711
2913
  this.#server = void 0;
2712
2914
  this.#address = void 0;
2713
- if (wss) await new Promise((resolve8) => wss.close(() => resolve8()));
2714
- if (server) await new Promise((resolve8) => server.close(() => resolve8()));
2915
+ if (wss) await new Promise((resolve9) => wss.close(() => resolve9()));
2916
+ if (server) await new Promise((resolve9) => server.close(() => resolve9()));
2715
2917
  }
2716
2918
  };
2717
2919
 
2718
2920
  // packages/orchestrator/src/startup-error.ts
2719
- import { readFileSync as readFileSync6, statSync as statSync7 } from "node:fs";
2921
+ import { readFileSync as readFileSync6, statSync as statSync8 } from "node:fs";
2720
2922
  import { join as join12 } from "node:path";
2721
2923
  function explainStartupError(error, host, port) {
2722
2924
  const code = error?.code;
@@ -2756,7 +2958,7 @@ function describeBuiltUi(uiDir) {
2756
2958
  const html = readFileSync6(join12(uiDir, "index.html"), "utf8");
2757
2959
  const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
2758
2960
  if (entry === void 0) return "(no entry bundle named in index.html)";
2759
- const { mtime } = statSync7(join12(uiDir, "index.html"));
2961
+ const { mtime } = statSync8(join12(uiDir, "index.html"));
2760
2962
  return `${entry} (built ${mtime.toISOString().slice(0, 16).replace("T", " ")})`;
2761
2963
  } catch {
2762
2964
  return "(unreadable)";
@@ -2809,6 +3011,9 @@ function assertNotSandboxed(execPath = "/usr/bin/sandbox-exec") {
2809
3011
  }
2810
3012
 
2811
3013
  // packages/orchestrator/src/server.ts
3014
+ function ownPackageRoot() {
3015
+ return findOwnPackage()?.root ?? "unknown (no package.json found)";
3016
+ }
2812
3017
  function versionLine() {
2813
3018
  const own = findOwnPackage();
2814
3019
  if (own === void 0) return `${PACKAGE_NAME} (version unknown: no package.json found)`;
@@ -2905,6 +3110,8 @@ async function main() {
2905
3110
  console.log(` default workspace: ${config.workspaceRoot}`);
2906
3111
  console.log(` agent root: ${config.agentRoot}${config.agentRootManaged ? " (managed)" : " (external)"}`);
2907
3112
  console.log(` isolation: ${options.mode}`);
3113
+ console.log(` running from: ${ownPackageRoot()}`);
3114
+ console.log(` verify this install: ${VERIFY_INSTALL_HINT} (see README, "Verify what you installed")`);
2908
3115
  if (config.installationRoot === void 0) {
2909
3116
  console.log(
2910
3117
  `Self-hosting check: could not identify this installation's own directory, so sessions are