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 +29 -5
- package/bin/pi-central.js +288 -81
- package/bin/pi-central.js.map +4 -4
- package/dist/ui/assets/{grammar-map-CcZdLTyL.js → grammar-map-zNF3jzH0.js} +1 -1
- package/dist/ui/assets/index-Cg14LRiC.css +1 -0
- package/dist/ui/assets/index-CxdjMFJ5.js +45 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-BC2CSxKc.js +0 -40
- package/dist/ui/assets/index-SAdkZfHm.css +0 -1
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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((
|
|
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:
|
|
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((
|
|
615
|
-
const timer = setTimeout(() =>
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
1950
|
-
|
|
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
|
|
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((
|
|
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", () =>
|
|
2026
|
-
stream.on("close", () =>
|
|
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 =
|
|
2311
|
+
stats = statSync6(path);
|
|
2106
2312
|
} catch {
|
|
2107
2313
|
continue;
|
|
2108
2314
|
}
|
|
2109
|
-
const
|
|
2110
|
-
if (!
|
|
2111
|
-
skipped.push({ path, reason:
|
|
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
|
-
|
|
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
|
|
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
|
|
2147
|
-
import { extname, join as join11, posix, resolve as
|
|
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
|
|
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 =
|
|
2203
|
-
const target =
|
|
2391
|
+
const rootPath = resolve8(root);
|
|
2392
|
+
const target = resolve8(candidate);
|
|
2204
2393
|
if (target === rootPath) return true;
|
|
2205
|
-
return target.startsWith(rootPath.endsWith(
|
|
2394
|
+
return target.startsWith(rootPath.endsWith(sep5) ? rootPath : rootPath + sep5);
|
|
2206
2395
|
}
|
|
2207
2396
|
function resolveStaticPath(root, requestTarget) {
|
|
2208
|
-
const rootPath =
|
|
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 =
|
|
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 =
|
|
2418
|
+
const root = resolve8(options.root);
|
|
2230
2419
|
const csp = options.csp ?? UI_CSP;
|
|
2231
|
-
const indexPath =
|
|
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
|
|
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 =
|
|
2488
|
+
const info = statSync7(path);
|
|
2300
2489
|
const type = CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
2301
|
-
const immutable = path.includes(`${
|
|
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(
|
|
2507
|
+
return existsSync6(resolve8(root, "index.html"));
|
|
2319
2508
|
}
|
|
2320
2509
|
|
|
2321
2510
|
// packages/orchestrator/src/transport.ts
|
|
2322
|
-
var
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
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({
|
|
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((
|
|
2714
|
-
if (server) await new Promise((
|
|
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
|
|
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 } =
|
|
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
|