pi-central 0.6.1 → 0.8.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 +11 -2
- package/bin/pi-central.js +721 -305
- package/bin/pi-central.js.map +4 -4
- package/dist/ui/assets/{grammar-map-BUAa5ICq.js → grammar-map-C3CccWB0.js} +1 -1
- package/dist/ui/assets/index-BaBrH8fV.js +50 -0
- package/dist/ui/assets/{index-BA5r5TxA.css → index-DGDX197N.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/index-BXDFZiai.js +0 -48
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 = 5;
|
|
29
29
|
|
|
30
30
|
// packages/protocol/src/own-installation.ts
|
|
31
31
|
var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
|
|
@@ -271,8 +271,8 @@ function isScratch(config, cwd) {
|
|
|
271
271
|
|
|
272
272
|
// packages/orchestrator/src/supervisor.ts
|
|
273
273
|
import { EventEmitter as EventEmitter2 } from "node:events";
|
|
274
|
-
import { existsSync as existsSync4, mkdirSync as
|
|
275
|
-
import { join as
|
|
274
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3 } from "node:fs";
|
|
275
|
+
import { join as join10 } from "node:path";
|
|
276
276
|
|
|
277
277
|
// packages/orchestrator/src/policy.ts
|
|
278
278
|
import { accessSync, constants, mkdirSync, statSync as statSync2, writeFileSync } from "node:fs";
|
|
@@ -492,6 +492,24 @@ var RpcProcess = class extends EventEmitter {
|
|
|
492
492
|
#stderr = "";
|
|
493
493
|
#stopping = false;
|
|
494
494
|
#exited = false;
|
|
495
|
+
/**
|
|
496
|
+
* True when the process never started at all (`spawn` failed).
|
|
497
|
+
*
|
|
498
|
+
* Node's contract for a failed spawn is `error` and **no `exit`**. Every wait below is on
|
|
499
|
+
* `exit`, so treating that case as "still running" made `stop()` wait out its timers for a
|
|
500
|
+
* process that does not exist — and because those timers are `unref`'d, the event loop could
|
|
501
|
+
* drain first and take the caller's promise with it.
|
|
502
|
+
*
|
|
503
|
+
* Found through the startup restore (issue #185): a fleet of restored sessions on a host where
|
|
504
|
+
* the binary is missing (C10 — `safehouse` is macOS-only) is a fleet of failed spawns, and
|
|
505
|
+
* shutdown then never finished. It was always reachable by hand; restoring made it the normal
|
|
506
|
+
* path.
|
|
507
|
+
*
|
|
508
|
+
* `child.pid === undefined` is the precise signal, which is why it is the condition rather than
|
|
509
|
+
* "any `error` event": a runtime error on a started process still has a pid, and it still gets
|
|
510
|
+
* its `exit`.
|
|
511
|
+
*/
|
|
512
|
+
#spawnFailed = false;
|
|
495
513
|
constructor(launch) {
|
|
496
514
|
super();
|
|
497
515
|
this.launch = launch;
|
|
@@ -516,19 +534,29 @@ var RpcProcess = class extends EventEmitter {
|
|
|
516
534
|
this.#stderr += text;
|
|
517
535
|
this.emit("stderr", text);
|
|
518
536
|
});
|
|
519
|
-
child.on("error", (error) =>
|
|
537
|
+
child.on("error", (error) => {
|
|
538
|
+
if (child.pid === void 0) {
|
|
539
|
+
this.#spawnFailed = true;
|
|
540
|
+
this.#rejectPending(`could not start ${this.launch.command}: ${error.message}`);
|
|
541
|
+
}
|
|
542
|
+
this.emit("failed", error);
|
|
543
|
+
});
|
|
520
544
|
child.on("exit", (code, signal) => {
|
|
521
545
|
this.#exited = true;
|
|
522
546
|
for (const record of this.#decoder.flush()) this.#dispatch(record);
|
|
523
|
-
|
|
524
|
-
clearTimeout(pending.timer);
|
|
525
|
-
pending.reject(new Error(`pi exited before responding (code=${code ?? "-"}, signal=${signal ?? "-"})`));
|
|
526
|
-
}
|
|
527
|
-
this.#pending.clear();
|
|
547
|
+
this.#rejectPending(`pi exited before responding (code=${code ?? "-"}, signal=${signal ?? "-"})`);
|
|
528
548
|
this.emit("exit", { code, signal, stderr: this.stderrTail });
|
|
529
549
|
});
|
|
530
550
|
return this;
|
|
531
551
|
}
|
|
552
|
+
/** Reject every in-flight command with one reason, clearing their timers. */
|
|
553
|
+
#rejectPending(reason) {
|
|
554
|
+
for (const [, pending] of this.#pending) {
|
|
555
|
+
clearTimeout(pending.timer);
|
|
556
|
+
pending.reject(new Error(reason));
|
|
557
|
+
}
|
|
558
|
+
this.#pending.clear();
|
|
559
|
+
}
|
|
532
560
|
#dispatch(record) {
|
|
533
561
|
if (record.type === "response" && typeof record.id === "string") {
|
|
534
562
|
const pending = this.#pending.get(record.id);
|
|
@@ -562,13 +590,13 @@ var RpcProcess = class extends EventEmitter {
|
|
|
562
590
|
const child = this.#child;
|
|
563
591
|
if (!child || !child.stdin.writable) return Promise.reject(new Error("session is not running"));
|
|
564
592
|
const id = `c${++this.#seq}`;
|
|
565
|
-
return new Promise((
|
|
593
|
+
return new Promise((resolve10, reject) => {
|
|
566
594
|
const timer = setTimeout(() => {
|
|
567
595
|
this.#pending.delete(id);
|
|
568
596
|
reject(new Error(`timeout after ${timeoutMs}ms waiting for ${command.type}`));
|
|
569
597
|
}, timeoutMs);
|
|
570
598
|
timer.unref?.();
|
|
571
|
-
this.#pending.set(id, { resolve:
|
|
599
|
+
this.#pending.set(id, { resolve: resolve10, reject, timer });
|
|
572
600
|
child.stdin.write(encodeLine({ ...command, id }));
|
|
573
601
|
});
|
|
574
602
|
}
|
|
@@ -590,7 +618,7 @@ var RpcProcess = class extends EventEmitter {
|
|
|
590
618
|
if (this.#stopping) return;
|
|
591
619
|
this.#stopping = true;
|
|
592
620
|
const child = this.#child;
|
|
593
|
-
if (!child || this.#exited) return;
|
|
621
|
+
if (!child || this.#exited || this.#spawnFailed) return;
|
|
594
622
|
try {
|
|
595
623
|
child.stdin.end();
|
|
596
624
|
} catch {
|
|
@@ -610,14 +638,16 @@ var RpcProcess = class extends EventEmitter {
|
|
|
610
638
|
}
|
|
611
639
|
#waitForExit(ms) {
|
|
612
640
|
const child = this.#child;
|
|
613
|
-
if (!child || this.#exited) return Promise.resolve(true);
|
|
614
|
-
return new Promise((
|
|
615
|
-
const
|
|
616
|
-
timer.unref?.();
|
|
617
|
-
child.once("exit", () => {
|
|
641
|
+
if (!child || this.#exited || this.#spawnFailed) return Promise.resolve(true);
|
|
642
|
+
return new Promise((resolve10) => {
|
|
643
|
+
const settle = () => {
|
|
618
644
|
clearTimeout(timer);
|
|
619
|
-
|
|
620
|
-
}
|
|
645
|
+
resolve10(true);
|
|
646
|
+
};
|
|
647
|
+
const timer = setTimeout(() => resolve10(this.#exited || this.#spawnFailed), ms);
|
|
648
|
+
timer.unref?.();
|
|
649
|
+
child.once("exit", settle);
|
|
650
|
+
this.once("failed", settle);
|
|
621
651
|
});
|
|
622
652
|
}
|
|
623
653
|
get stderrTail() {
|
|
@@ -625,7 +655,7 @@ var RpcProcess = class extends EventEmitter {
|
|
|
625
655
|
}
|
|
626
656
|
get running() {
|
|
627
657
|
const child = this.#child;
|
|
628
|
-
return child !== null && !this.#exited;
|
|
658
|
+
return child !== null && !this.#exited && !this.#spawnFailed;
|
|
629
659
|
}
|
|
630
660
|
};
|
|
631
661
|
|
|
@@ -716,18 +746,336 @@ Restore it, or start a new session in the directory you want to work in. Pi Cent
|
|
|
716
746
|
return { ok: true, cwd: resolve4(session.cwd) };
|
|
717
747
|
}
|
|
718
748
|
|
|
749
|
+
// packages/orchestrator/src/open-sessions.ts
|
|
750
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
751
|
+
import { dirname, join as join5, resolve as resolve5 } from "node:path";
|
|
752
|
+
function normalizeSessionFile(sessionFile) {
|
|
753
|
+
try {
|
|
754
|
+
return realpathSync(sessionFile);
|
|
755
|
+
} catch {
|
|
756
|
+
return resolve5(sessionFile);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function openSessionsPath(stateRoot) {
|
|
760
|
+
return join5(stateRoot, "open-sessions.json");
|
|
761
|
+
}
|
|
762
|
+
function readOpenSessions(stateRoot) {
|
|
763
|
+
const path = openSessionsPath(stateRoot);
|
|
764
|
+
let text;
|
|
765
|
+
try {
|
|
766
|
+
text = readFileSync2(path, "utf8");
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (error.code === "ENOENT") return { sessions: [] };
|
|
769
|
+
return { sessions: [], note: `could not read ${path}: ${error.message}` };
|
|
770
|
+
}
|
|
771
|
+
let parsed;
|
|
772
|
+
try {
|
|
773
|
+
parsed = JSON.parse(text);
|
|
774
|
+
} catch {
|
|
775
|
+
return { sessions: [], note: `${path} is not valid JSON, so no sessions were restored` };
|
|
776
|
+
}
|
|
777
|
+
const shape = parsed;
|
|
778
|
+
if (typeof shape !== "object" || shape === null || shape.version !== 1 || !Array.isArray(shape.sessions)) {
|
|
779
|
+
return { sessions: [], note: `${path} is not a recognised open-sessions file, so no sessions were restored` };
|
|
780
|
+
}
|
|
781
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
782
|
+
for (const entry of shape.sessions) {
|
|
783
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
784
|
+
const { sessionFile, mode } = entry;
|
|
785
|
+
if (typeof sessionFile !== "string" || sessionFile.length === 0) continue;
|
|
786
|
+
if (sessionFile.includes("\0")) continue;
|
|
787
|
+
const canonical = normalizeSessionFile(sessionFile);
|
|
788
|
+
byFile.set(canonical, { sessionFile: canonical, mode: mode === "direct" ? "direct" : "sandboxed" });
|
|
789
|
+
}
|
|
790
|
+
return { sessions: [...byFile.values()] };
|
|
791
|
+
}
|
|
792
|
+
function writeOpenSessions(stateRoot, sessions) {
|
|
793
|
+
const path = openSessionsPath(stateRoot);
|
|
794
|
+
const body = { version: 1, sessions };
|
|
795
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
796
|
+
try {
|
|
797
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
798
|
+
writeFileSync2(temporary, `${JSON.stringify(body, null, 2)}
|
|
799
|
+
`, "utf8");
|
|
800
|
+
renameSync(temporary, path);
|
|
801
|
+
return { ok: true };
|
|
802
|
+
} catch (error) {
|
|
803
|
+
return { ok: false, message: `could not write ${path}: ${error.message}` };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
var OpenSessionStore = class {
|
|
807
|
+
#stateRoot;
|
|
808
|
+
#entries = /* @__PURE__ */ new Map();
|
|
809
|
+
#warned = false;
|
|
810
|
+
constructor(stateRoot) {
|
|
811
|
+
this.#stateRoot = stateRoot;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Load the remembered set from disk, reporting anything the reader should know.
|
|
815
|
+
*
|
|
816
|
+
* Separate from the constructor because it touches the filesystem and can produce a note the
|
|
817
|
+
* startup log must print — the same shape `readOpenSessions` returns.
|
|
818
|
+
*/
|
|
819
|
+
load() {
|
|
820
|
+
const { sessions, note } = readOpenSessions(this.#stateRoot);
|
|
821
|
+
this.#entries = new Map(sessions.map((entry) => [entry.sessionFile, entry]));
|
|
822
|
+
return note === void 0 ? { sessions } : { sessions, note };
|
|
823
|
+
}
|
|
824
|
+
/** Every remembered session, in insertion order. */
|
|
825
|
+
list() {
|
|
826
|
+
return [...this.#entries.values()];
|
|
827
|
+
}
|
|
828
|
+
has(sessionFile) {
|
|
829
|
+
return this.#entries.has(normalizeSessionFile(sessionFile));
|
|
830
|
+
}
|
|
831
|
+
/** Remember a session. A no-op (and no write) when nothing changed. */
|
|
832
|
+
remember(sessionFile, mode) {
|
|
833
|
+
if (sessionFile === void 0) return;
|
|
834
|
+
const canonical = normalizeSessionFile(sessionFile);
|
|
835
|
+
const existing = this.#entries.get(canonical);
|
|
836
|
+
if (existing !== void 0 && existing.mode === mode) return;
|
|
837
|
+
this.#entries.set(canonical, { sessionFile: canonical, mode });
|
|
838
|
+
this.#changed();
|
|
839
|
+
}
|
|
840
|
+
/** Forget a session — an EXPLICIT stop, never a shutdown. */
|
|
841
|
+
forget(sessionFile) {
|
|
842
|
+
if (sessionFile === void 0) return;
|
|
843
|
+
if (!this.#entries.delete(normalizeSessionFile(sessionFile))) return;
|
|
844
|
+
this.#changed();
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Write the current set.
|
|
848
|
+
*
|
|
849
|
+
* The warning is printed once per process: a state root that cannot be written would otherwise
|
|
850
|
+
* produce a line on every session start and stop, which trains a reader to ignore it.
|
|
851
|
+
*/
|
|
852
|
+
#changed() {
|
|
853
|
+
const result = writeOpenSessions(this.#stateRoot, this.list());
|
|
854
|
+
if (result.ok || this.#warned) return;
|
|
855
|
+
this.#warned = true;
|
|
856
|
+
console.error(`Sessions will not be restored after a restart: ${result.message}`);
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// packages/orchestrator/src/registry.ts
|
|
861
|
+
import { createReadStream, readdirSync as readdirSync2, realpathSync as realpathSync2, statSync as statSync5 } from "node:fs";
|
|
862
|
+
import { basename, dirname as dirname2, join as join6, resolve as resolve6, sep as sep2 } from "node:path";
|
|
863
|
+
var KNOWN_SESSION_VERSIONS = /* @__PURE__ */ new Set([3]);
|
|
864
|
+
var HEAD_BYTES = 256 * 1024;
|
|
865
|
+
var TAIL_BYTES = 8 * 1024;
|
|
866
|
+
function decodeSessionDir(dirName) {
|
|
867
|
+
const inner = dirName.replace(/^--/, "").replace(/--$/, "");
|
|
868
|
+
return `/${inner.replace(/-/g, "/")}`;
|
|
869
|
+
}
|
|
870
|
+
async function readLateSessionName(path) {
|
|
871
|
+
let stats;
|
|
872
|
+
try {
|
|
873
|
+
stats = statSync5(path);
|
|
874
|
+
} catch {
|
|
875
|
+
return void 0;
|
|
876
|
+
}
|
|
877
|
+
const start = Math.max(0, stats.size - TAIL_BYTES);
|
|
878
|
+
if (start === 0) return void 0;
|
|
879
|
+
const text = await new Promise((resolve10) => {
|
|
880
|
+
const chunks = [];
|
|
881
|
+
const stream = createReadStream(path, { start, end: stats.size - 1 });
|
|
882
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
883
|
+
stream.on("error", () => resolve10(""));
|
|
884
|
+
stream.on("close", () => resolve10(Buffer.concat(chunks).toString("utf8")));
|
|
885
|
+
});
|
|
886
|
+
if (text === "") return void 0;
|
|
887
|
+
const lines = text.split("\n");
|
|
888
|
+
lines.shift();
|
|
889
|
+
let name;
|
|
890
|
+
for (const line of lines) {
|
|
891
|
+
if (!line.includes('"session_info"')) continue;
|
|
892
|
+
try {
|
|
893
|
+
const record = JSON.parse(line);
|
|
894
|
+
if (record.type === "session_info" && typeof record.name === "string") {
|
|
895
|
+
const trimmed = record.name.trim();
|
|
896
|
+
if (trimmed.length > 0) name = trimmed;
|
|
897
|
+
}
|
|
898
|
+
} catch {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return name;
|
|
903
|
+
}
|
|
904
|
+
async function readHead(path) {
|
|
905
|
+
return new Promise((resolve10) => {
|
|
906
|
+
const chunks = [];
|
|
907
|
+
let size = 0;
|
|
908
|
+
const stream = createReadStream(path, { start: 0, end: HEAD_BYTES - 1 });
|
|
909
|
+
stream.on("data", (chunk) => {
|
|
910
|
+
chunks.push(chunk);
|
|
911
|
+
size += chunk.length;
|
|
912
|
+
});
|
|
913
|
+
stream.on("error", () => resolve10(""));
|
|
914
|
+
stream.on("close", () => resolve10(Buffer.concat(chunks, size).toString("utf8")));
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
function parseHead(text) {
|
|
918
|
+
const lines = text.split("\n");
|
|
919
|
+
let header = null;
|
|
920
|
+
let name;
|
|
921
|
+
let firstMessage = "";
|
|
922
|
+
let messageCount = 0;
|
|
923
|
+
for (const line of lines) {
|
|
924
|
+
if (!line) continue;
|
|
925
|
+
let record;
|
|
926
|
+
try {
|
|
927
|
+
record = JSON.parse(line);
|
|
928
|
+
} catch {
|
|
929
|
+
if (!header) continue;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
if (!header) {
|
|
933
|
+
if (record.type !== "session") return null;
|
|
934
|
+
if (typeof record.id !== "string" || typeof record.cwd !== "string") return null;
|
|
935
|
+
header = {
|
|
936
|
+
type: "session",
|
|
937
|
+
version: Number(record.version ?? 0),
|
|
938
|
+
id: record.id,
|
|
939
|
+
timestamp: String(record.timestamp ?? ""),
|
|
940
|
+
cwd: record.cwd,
|
|
941
|
+
...typeof record.parentSession === "string" ? { parentSession: record.parentSession } : {}
|
|
942
|
+
};
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (record.type === "session_info" && typeof record.name === "string") {
|
|
946
|
+
name = record.name.trim() || void 0;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (record.type === "message") {
|
|
950
|
+
messageCount += 1;
|
|
951
|
+
if (!firstMessage) {
|
|
952
|
+
const message = record.message;
|
|
953
|
+
if (message?.role === "user") firstMessage = flattenContent(message.content).slice(0, 200);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return header ? { header, name, firstMessage, messageCount } : null;
|
|
958
|
+
}
|
|
959
|
+
function flattenContent(content) {
|
|
960
|
+
if (typeof content === "string") return content;
|
|
961
|
+
if (Array.isArray(content)) {
|
|
962
|
+
return content.map((part) => {
|
|
963
|
+
if (typeof part === "string") return part;
|
|
964
|
+
if (part && typeof part === "object" && "text" in part) return String(part.text);
|
|
965
|
+
return "";
|
|
966
|
+
}).join(" ");
|
|
967
|
+
}
|
|
968
|
+
return "";
|
|
969
|
+
}
|
|
970
|
+
async function describeSessionFile(config, path, projectDir, stats) {
|
|
971
|
+
const head = await readHead(path);
|
|
972
|
+
const parsed = parseHead(head);
|
|
973
|
+
if (!parsed) return { ok: false, reason: "not a Pi session file (no valid header)" };
|
|
974
|
+
const lateName = stats.size > HEAD_BYTES ? await readLateSessionName(path) : void 0;
|
|
975
|
+
if (!KNOWN_SESSION_VERSIONS.has(parsed.header.version)) {
|
|
976
|
+
return { ok: false, reason: `unknown session version ${parsed.header.version}` };
|
|
977
|
+
}
|
|
978
|
+
const cwd = parsed.header.cwd || decodeSessionDir(projectDir);
|
|
979
|
+
return {
|
|
980
|
+
ok: true,
|
|
981
|
+
session: {
|
|
982
|
+
path,
|
|
983
|
+
id: parsed.header.id,
|
|
984
|
+
cwd,
|
|
985
|
+
// The late name wins: it is the most recent rename, and the head's is the earliest one.
|
|
986
|
+
...lateName ?? parsed.name ? { name: lateName ?? parsed.name } : {},
|
|
987
|
+
created: parsed.header.timestamp,
|
|
988
|
+
modifiedMs: stats.mtimeMs,
|
|
989
|
+
bytes: stats.size,
|
|
990
|
+
messageCount: parsed.messageCount,
|
|
991
|
+
firstMessage: parsed.firstMessage,
|
|
992
|
+
cwdExists: directoryExists(cwd),
|
|
993
|
+
scratch: isScratch(config, cwd)
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
async function sessionAt(config, requested) {
|
|
998
|
+
if (typeof requested !== "string" || requested.length === 0 || requested.includes("\0")) return void 0;
|
|
999
|
+
let root;
|
|
1000
|
+
let real;
|
|
1001
|
+
try {
|
|
1002
|
+
root = realpathSync2(config.sessionsRoot);
|
|
1003
|
+
real = realpathSync2(resolve6(requested));
|
|
1004
|
+
} catch {
|
|
1005
|
+
return void 0;
|
|
1006
|
+
}
|
|
1007
|
+
const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(sep2) ? parent : parent + sep2);
|
|
1008
|
+
if (!inside(root, real)) return void 0;
|
|
1009
|
+
if (!real.endsWith(".jsonl")) return void 0;
|
|
1010
|
+
let stats;
|
|
1011
|
+
try {
|
|
1012
|
+
stats = statSync5(real);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return void 0;
|
|
1015
|
+
}
|
|
1016
|
+
if (!stats.isFile()) return void 0;
|
|
1017
|
+
const projectDir = basename(dirname2(real));
|
|
1018
|
+
const row = await describeSessionFile(config, real, projectDir, stats);
|
|
1019
|
+
return row.ok ? row.session : void 0;
|
|
1020
|
+
}
|
|
1021
|
+
async function discover(config, limit = 500) {
|
|
1022
|
+
const sessions = [];
|
|
1023
|
+
const skipped = [];
|
|
1024
|
+
let projectDirs;
|
|
1025
|
+
try {
|
|
1026
|
+
projectDirs = readdirSync2(config.sessionsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
1027
|
+
} catch {
|
|
1028
|
+
return { sessions, total: 0, skipped };
|
|
1029
|
+
}
|
|
1030
|
+
for (const projectDir of projectDirs) {
|
|
1031
|
+
const projectPath = join6(config.sessionsRoot, projectDir);
|
|
1032
|
+
let entries;
|
|
1033
|
+
try {
|
|
1034
|
+
entries = readdirSync2(projectPath, { withFileTypes: true });
|
|
1035
|
+
} catch {
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
for (const entry of entries) {
|
|
1039
|
+
if (!entry.name.endsWith(".jsonl")) continue;
|
|
1040
|
+
if (!entry.isFile()) continue;
|
|
1041
|
+
const path = join6(projectPath, entry.name);
|
|
1042
|
+
let stats;
|
|
1043
|
+
try {
|
|
1044
|
+
stats = statSync5(path);
|
|
1045
|
+
} catch {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
const row = await describeSessionFile(config, path, projectDir, stats);
|
|
1049
|
+
if (!row.ok) {
|
|
1050
|
+
skipped.push({ path, reason: row.reason });
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
sessions.push(row.session);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
|
1057
|
+
return { sessions: sessions.slice(0, limit), total: sessions.length, skipped };
|
|
1058
|
+
}
|
|
1059
|
+
function directoryExists(dir) {
|
|
1060
|
+
try {
|
|
1061
|
+
return statSync5(dir).isDirectory();
|
|
1062
|
+
} catch {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
719
1067
|
// packages/orchestrator/src/capabilities.ts
|
|
720
1068
|
import { accessSync as accessSync3, constants as constants3 } from "node:fs";
|
|
721
|
-
import { join as
|
|
1069
|
+
import { join as join8 } from "node:path";
|
|
722
1070
|
|
|
723
1071
|
// packages/orchestrator/src/signing.ts
|
|
724
|
-
import { existsSync as existsSync3, readFileSync as
|
|
725
|
-
import { join as
|
|
1072
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
1073
|
+
import { join as join7, resolve as resolve7, sep as sep3 } from "node:path";
|
|
726
1074
|
function signingKeyPath(root) {
|
|
727
|
-
return
|
|
1075
|
+
return join7(root, "ssh", "github_login_ed25519");
|
|
728
1076
|
}
|
|
729
1077
|
function allowedSignersPath(root) {
|
|
730
|
-
return
|
|
1078
|
+
return join7(root, "ssh", "allowed_signers");
|
|
731
1079
|
}
|
|
732
1080
|
var KEYS = {
|
|
733
1081
|
[normalizeKey("commit.gpgsign")]: "commitGpgsign",
|
|
@@ -769,10 +1117,10 @@ function readSigning(text) {
|
|
|
769
1117
|
return signing;
|
|
770
1118
|
}
|
|
771
1119
|
function isInside2(root, path) {
|
|
772
|
-
const from =
|
|
773
|
-
const to =
|
|
1120
|
+
const from = resolve7(root);
|
|
1121
|
+
const to = resolve7(path);
|
|
774
1122
|
if (to === from) return true;
|
|
775
|
-
return to.startsWith(from.endsWith(
|
|
1123
|
+
return to.startsWith(from.endsWith(sep3) ? from : from + sep3);
|
|
776
1124
|
}
|
|
777
1125
|
function auditSigning(settings, root) {
|
|
778
1126
|
const problems = [];
|
|
@@ -821,16 +1169,16 @@ function signingMaterial(root) {
|
|
|
821
1169
|
}
|
|
822
1170
|
function signingFixCommands(root) {
|
|
823
1171
|
return [
|
|
824
|
-
`git config --file ${
|
|
825
|
-
`git config --file ${
|
|
826
|
-
`git config --file ${
|
|
827
|
-
`git config --file ${
|
|
828
|
-
`git config --file ${
|
|
1172
|
+
`git config --file ${join7(root, "gitconfig")} commit.gpgsign true`,
|
|
1173
|
+
`git config --file ${join7(root, "gitconfig")} tag.gpgsign true`,
|
|
1174
|
+
`git config --file ${join7(root, "gitconfig")} gpg.format ssh`,
|
|
1175
|
+
`git config --file ${join7(root, "gitconfig")} user.signingkey ${signingKeyPath(root)}`,
|
|
1176
|
+
`git config --file ${join7(root, "gitconfig")} gpg.ssh.allowedSignersFile ${allowedSignersPath(root)}`
|
|
829
1177
|
];
|
|
830
1178
|
}
|
|
831
1179
|
|
|
832
1180
|
// packages/orchestrator/src/capabilities.ts
|
|
833
|
-
import { readFileSync as
|
|
1181
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
834
1182
|
function isReadable(path) {
|
|
835
1183
|
try {
|
|
836
1184
|
accessSync3(path, constants3.R_OK);
|
|
@@ -863,7 +1211,7 @@ function assessAgentCapabilities(input) {
|
|
|
863
1211
|
// denies it", rather than firing on every direct-mode session.
|
|
864
1212
|
!sandboxed || fragment !== void 0 && fragmentGrants(fragment, path)
|
|
865
1213
|
);
|
|
866
|
-
const gitconfigPath =
|
|
1214
|
+
const gitconfigPath = join8(root, "gitconfig");
|
|
867
1215
|
if (!store.has.gitconfig) {
|
|
868
1216
|
findings.push({
|
|
869
1217
|
id: "gitconfig",
|
|
@@ -883,7 +1231,7 @@ function assessAgentCapabilities(input) {
|
|
|
883
1231
|
message: `the session's policy does not grant ${root}, while GIT_CONFIG_GLOBAL points into it. The agent would be configured to read a file its own sandbox denies \u2014 a Pi Central bug, not a setup step.`
|
|
884
1232
|
});
|
|
885
1233
|
}
|
|
886
|
-
const ghDir =
|
|
1234
|
+
const ghDir = join8(root, "gh");
|
|
887
1235
|
if (!store.has.ghConfig) {
|
|
888
1236
|
findings.push({
|
|
889
1237
|
id: "gh-config",
|
|
@@ -907,11 +1255,11 @@ function assessAgentCapabilities(input) {
|
|
|
907
1255
|
findings.push({
|
|
908
1256
|
id: "ssh-key",
|
|
909
1257
|
state: "not-provisioned",
|
|
910
|
-
message: `the agent has no SSH key under ${
|
|
1258
|
+
message: `the agent has no SSH key under ${join8(root, "ssh")}. It can still push over HTTPS with its gh token, but not over SSH, and it cannot sign commits. Provision a key to use either.`
|
|
911
1259
|
});
|
|
912
1260
|
}
|
|
913
1261
|
if (store.has.gitconfig) {
|
|
914
|
-
const gitconfigPath2 =
|
|
1262
|
+
const gitconfigPath2 = join8(root, "gitconfig");
|
|
915
1263
|
const settings = readSigning(readGitconfigText(gitconfigPath2));
|
|
916
1264
|
const audit = auditSigning(settings, root);
|
|
917
1265
|
const material = signingMaterial(root);
|
|
@@ -961,7 +1309,7 @@ ${commands}`
|
|
|
961
1309
|
}
|
|
962
1310
|
function readGitconfigText(path) {
|
|
963
1311
|
try {
|
|
964
|
-
return
|
|
1312
|
+
return readFileSync4(path, "utf8");
|
|
965
1313
|
} catch {
|
|
966
1314
|
return "";
|
|
967
1315
|
}
|
|
@@ -1162,15 +1510,15 @@ function renderPolicyFragment(options = {}) {
|
|
|
1162
1510
|
}
|
|
1163
1511
|
|
|
1164
1512
|
// packages/orchestrator/src/files.ts
|
|
1165
|
-
import { lstatSync, readdirSync as
|
|
1166
|
-
import { isAbsolute, join as
|
|
1513
|
+
import { lstatSync, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync3, statSync as statSync6 } from "node:fs";
|
|
1514
|
+
import { isAbsolute, join as join9, resolve as resolve8, sep as sep4 } from "node:path";
|
|
1167
1515
|
var MAX_FILE_BYTES = 256 * 1024;
|
|
1168
1516
|
var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
1169
1517
|
var BINARY_SNIFF_BYTES = 8 * 1024;
|
|
1170
1518
|
var DEFAULT_ENTRY_LIMIT = 2e3;
|
|
1171
1519
|
function containsPath(parent, child) {
|
|
1172
1520
|
if (parent === child) return true;
|
|
1173
|
-
const withSep = parent.endsWith(
|
|
1521
|
+
const withSep = parent.endsWith(sep4) ? parent : parent + sep4;
|
|
1174
1522
|
return child.startsWith(withSep);
|
|
1175
1523
|
}
|
|
1176
1524
|
function fromErrno(error, what, relPath) {
|
|
@@ -1216,11 +1564,11 @@ function resolveWithin(root, relative) {
|
|
|
1216
1564
|
const relPath = normalizeRelative(relative);
|
|
1217
1565
|
let realRoot;
|
|
1218
1566
|
try {
|
|
1219
|
-
realRoot =
|
|
1567
|
+
realRoot = realpathSync3(root);
|
|
1220
1568
|
} catch (error) {
|
|
1221
1569
|
return { ok: false, error: fromErrno(error, "the session's working directory is unavailable:", "") };
|
|
1222
1570
|
}
|
|
1223
|
-
const candidate =
|
|
1571
|
+
const candidate = resolve8(realRoot, relPath);
|
|
1224
1572
|
if (!containsPath(realRoot, candidate)) {
|
|
1225
1573
|
return {
|
|
1226
1574
|
ok: false,
|
|
@@ -1232,7 +1580,7 @@ function resolveWithin(root, relative) {
|
|
|
1232
1580
|
}
|
|
1233
1581
|
let real;
|
|
1234
1582
|
try {
|
|
1235
|
-
real =
|
|
1583
|
+
real = realpathSync3(candidate);
|
|
1236
1584
|
} catch (error) {
|
|
1237
1585
|
return { ok: false, error: fromErrno(error, "cannot open", relPath) };
|
|
1238
1586
|
}
|
|
@@ -1266,7 +1614,7 @@ function listDirectory(root, relative, options = {}) {
|
|
|
1266
1614
|
const includeHidden = options.includeHidden ?? false;
|
|
1267
1615
|
let names;
|
|
1268
1616
|
try {
|
|
1269
|
-
names =
|
|
1617
|
+
names = readdirSync3(path);
|
|
1270
1618
|
} catch (error) {
|
|
1271
1619
|
return { ok: false, error: fromErrno(error, "cannot list", relPath) };
|
|
1272
1620
|
}
|
|
@@ -1278,7 +1626,7 @@ function listDirectory(root, relative, options = {}) {
|
|
|
1278
1626
|
truncated = true;
|
|
1279
1627
|
break;
|
|
1280
1628
|
}
|
|
1281
|
-
entries.push(describeEntry(
|
|
1629
|
+
entries.push(describeEntry(join9(path, name), name));
|
|
1282
1630
|
}
|
|
1283
1631
|
entries.sort((a, b) => {
|
|
1284
1632
|
const aDir = a.kind === "directory" ? 0 : 1;
|
|
@@ -1294,16 +1642,18 @@ function describeEntry(fullPath, name) {
|
|
|
1294
1642
|
if (stats.isSymbolicLink()) {
|
|
1295
1643
|
return { name, kind: "symlink", linkTarget: linkTargetOf(fullPath), hidden: isHidden(name) };
|
|
1296
1644
|
}
|
|
1297
|
-
if (stats.isDirectory()) return { name, kind: "directory", hidden: isHidden(name) };
|
|
1298
|
-
if (stats.isFile())
|
|
1299
|
-
|
|
1645
|
+
if (stats.isDirectory()) return { name, kind: "directory", mtimeMs: stats.mtimeMs, hidden: isHidden(name) };
|
|
1646
|
+
if (stats.isFile()) {
|
|
1647
|
+
return { name, kind: "file", size: stats.size, mtimeMs: stats.mtimeMs, hidden: isHidden(name) };
|
|
1648
|
+
}
|
|
1649
|
+
return { name, kind: "other", mtimeMs: stats.mtimeMs, hidden: isHidden(name) };
|
|
1300
1650
|
} catch {
|
|
1301
1651
|
return { name, kind: "other", hidden: isHidden(name), unreadable: true };
|
|
1302
1652
|
}
|
|
1303
1653
|
}
|
|
1304
1654
|
function linkTargetOf(fullPath) {
|
|
1305
1655
|
try {
|
|
1306
|
-
const stats =
|
|
1656
|
+
const stats = statSync6(fullPath);
|
|
1307
1657
|
if (stats.isDirectory()) return "directory";
|
|
1308
1658
|
if (stats.isFile()) return "file";
|
|
1309
1659
|
return "other";
|
|
@@ -1335,7 +1685,7 @@ function readFileText(root, relative) {
|
|
|
1335
1685
|
}
|
|
1336
1686
|
let buffer;
|
|
1337
1687
|
try {
|
|
1338
|
-
buffer =
|
|
1688
|
+
buffer = readFileSync5(path);
|
|
1339
1689
|
} catch (error) {
|
|
1340
1690
|
return { ok: false, error: fromErrno(error, "cannot read", relPath) };
|
|
1341
1691
|
}
|
|
@@ -1408,7 +1758,7 @@ function readFileForDisplay(root, relative) {
|
|
|
1408
1758
|
}
|
|
1409
1759
|
let buffer;
|
|
1410
1760
|
try {
|
|
1411
|
-
buffer =
|
|
1761
|
+
buffer = readFileSync5(path);
|
|
1412
1762
|
} catch (error) {
|
|
1413
1763
|
return { ok: false, error: fromErrno(error, "cannot read", relPath) };
|
|
1414
1764
|
}
|
|
@@ -1479,16 +1829,91 @@ var blockingDialogMethods = [...BLOCKING_DIALOG_METHODS];
|
|
|
1479
1829
|
var fireAndForgetUiMethods = [...FIRE_AND_FORGET_UI_METHODS];
|
|
1480
1830
|
|
|
1481
1831
|
// packages/orchestrator/src/supervisor.ts
|
|
1832
|
+
var EMPTY_PROMPT_WITH_IMAGES = "See the attached image.";
|
|
1482
1833
|
var Supervisor = class extends EventEmitter2 {
|
|
1483
1834
|
#config;
|
|
1484
1835
|
#sessions = /* @__PURE__ */ new Map();
|
|
1485
1836
|
#processes = /* @__PURE__ */ new Map();
|
|
1486
1837
|
#created = 0;
|
|
1487
|
-
|
|
1838
|
+
/**
|
|
1839
|
+
* Which sessions were open, so a restart can reopen them (issue #185).
|
|
1840
|
+
*
|
|
1841
|
+
* Loaded eagerly rather than on first use: the startup restore reads it before any session
|
|
1842
|
+
* exists, and a lazily-created store would be a second code path that only the first launch takes.
|
|
1843
|
+
*/
|
|
1844
|
+
#open;
|
|
1845
|
+
/**
|
|
1846
|
+
* Whether starting and stopping a session changes the remembered set (issue #185).
|
|
1847
|
+
*
|
|
1848
|
+
* Off for the one-shot CLI (`run`, `resume`), and that is a correctness requirement rather than a
|
|
1849
|
+
* preference: the CLI exits immediately after stopping its session, so recording it would leave an
|
|
1850
|
+
* entry the web app would faithfully reopen on its next launch — a session nobody has open, spawned
|
|
1851
|
+
* behind the surprise of whoever started the orchestrator. It would also rewrite the file the
|
|
1852
|
+
* running web app owns, from a second process.
|
|
1853
|
+
*
|
|
1854
|
+
* On for the long-running server, which is the thing a restart can lose.
|
|
1855
|
+
*/
|
|
1856
|
+
#rememberOpenSessions;
|
|
1857
|
+
constructor(config, options = {}) {
|
|
1488
1858
|
super();
|
|
1489
1859
|
this.#config = config;
|
|
1860
|
+
this.#open = new OpenSessionStore(config.stateRoot);
|
|
1861
|
+
this.#rememberOpenSessions = options.rememberOpenSessions === true;
|
|
1490
1862
|
this.#installationRoot = resolveInstallationRoot();
|
|
1491
1863
|
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Reopen the sessions that were open when this process last ran (issue #185).
|
|
1866
|
+
*
|
|
1867
|
+
* Called once at startup by the entry point, deliberately NOT from the constructor: restoring
|
|
1868
|
+
* spawns Pi, and a constructor that spawned processes would make every test that builds a
|
|
1869
|
+
* supervisor launch sandboxes (C1 — they cannot, in CI).
|
|
1870
|
+
*
|
|
1871
|
+
* Each entry goes through the SAME path a reader's click does — `sessionAt` for the file, then
|
|
1872
|
+
* `resume` for the spawn — so restore inherits every rule already established there: the file
|
|
1873
|
+
* must still be a valid Pi session, the recorded cwd must still exist (C2), and the cwd is never
|
|
1874
|
+
* retargeted (ADR-0008). A second implementation of "is this resumable" is exactly the drift
|
|
1875
|
+
* `registry.ts` refuses elsewhere.
|
|
1876
|
+
*
|
|
1877
|
+
* Three outcomes, and the difference between them is the point:
|
|
1878
|
+
*
|
|
1879
|
+
* - **the file is gone** → dropped from the set. A deleted session cannot come back, and keeping
|
|
1880
|
+
* it would mean reporting the same absence on every launch forever.
|
|
1881
|
+
* - **the cwd is gone** → reported and KEPT. This is the case that must not be forgotten: a
|
|
1882
|
+
* worktree on an unmounted volume, a directory restored later in the day. Dropping it would
|
|
1883
|
+
* turn a temporary absence into permanent loss of the reader's rail.
|
|
1884
|
+
* - **it resumed** → nothing to say.
|
|
1885
|
+
*
|
|
1886
|
+
* Returns a summary rather than logging, so the entry point owns the wording and a test can assert
|
|
1887
|
+
* the decisions without capturing stderr.
|
|
1888
|
+
*/
|
|
1889
|
+
async restoreOpenSessions() {
|
|
1890
|
+
const loaded = this.#open.load();
|
|
1891
|
+
const report = {
|
|
1892
|
+
...loaded.note === void 0 ? {} : { note: loaded.note },
|
|
1893
|
+
restored: [],
|
|
1894
|
+
missingFile: [],
|
|
1895
|
+
unavailable: []
|
|
1896
|
+
};
|
|
1897
|
+
for (const entry of loaded.sessions) {
|
|
1898
|
+
const session = await sessionAt(this.#config, entry.sessionFile);
|
|
1899
|
+
if (session === void 0) {
|
|
1900
|
+
this.#open.forget(entry.sessionFile);
|
|
1901
|
+
report.missingFile.push(entry.sessionFile);
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
const result = this.resume(session, { mode: entry.mode });
|
|
1905
|
+
if (!result.ok) {
|
|
1906
|
+
report.unavailable.push({ sessionFile: entry.sessionFile, message: result.message });
|
|
1907
|
+
continue;
|
|
1908
|
+
}
|
|
1909
|
+
report.restored.push(result.session);
|
|
1910
|
+
}
|
|
1911
|
+
return report;
|
|
1912
|
+
}
|
|
1913
|
+
/** The remembered open sessions, for diagnosis and tests. */
|
|
1914
|
+
openSessions() {
|
|
1915
|
+
return this.#open.list();
|
|
1916
|
+
}
|
|
1492
1917
|
list() {
|
|
1493
1918
|
return [...this.#sessions.values()];
|
|
1494
1919
|
}
|
|
@@ -1519,7 +1944,7 @@ var Supervisor = class extends EventEmitter2 {
|
|
|
1519
1944
|
} else {
|
|
1520
1945
|
try {
|
|
1521
1946
|
cwd = scratchDirFor(this.#config, id);
|
|
1522
|
-
|
|
1947
|
+
mkdirSync3(cwd, { recursive: true });
|
|
1523
1948
|
} catch (error) {
|
|
1524
1949
|
return { ok: false, message: error.message };
|
|
1525
1950
|
}
|
|
@@ -1539,15 +1964,18 @@ var Supervisor = class extends EventEmitter2 {
|
|
|
1539
1964
|
const live = this.#findLiveByPath(session.path);
|
|
1540
1965
|
if (live) return { ok: true, session: live };
|
|
1541
1966
|
const id = `s${++this.#created}`;
|
|
1542
|
-
|
|
1967
|
+
const mode = options.mode ?? "sandboxed";
|
|
1968
|
+
const spawned = this.#spawn({
|
|
1543
1969
|
id,
|
|
1544
1970
|
cwd: readiness.cwd,
|
|
1545
|
-
mode
|
|
1971
|
+
mode,
|
|
1546
1972
|
// No name: the session already carries its own, and re-applying one would append a
|
|
1547
1973
|
// redundant session_info entry to a file we did not create.
|
|
1548
1974
|
...options.env ? { env: options.env } : {},
|
|
1549
1975
|
sessionPath: session.path
|
|
1550
1976
|
});
|
|
1977
|
+
if (spawned.ok && this.#rememberOpenSessions) this.#open.remember(session.path, mode);
|
|
1978
|
+
return spawned;
|
|
1551
1979
|
}
|
|
1552
1980
|
#findLiveByPath(sessionPath) {
|
|
1553
1981
|
for (const session of this.#sessions.values()) {
|
|
@@ -1610,7 +2038,7 @@ var Supervisor = class extends EventEmitter2 {
|
|
|
1610
2038
|
#spawn(input) {
|
|
1611
2039
|
const env = { ...agentRoutingEnv(this.#config.agentRoot), ...input.env ?? {} };
|
|
1612
2040
|
const providerAuth = inspectProviderAuth(this.#config.providerAgentDir);
|
|
1613
|
-
const keychainPath =
|
|
2041
|
+
const keychainPath = join10(this.#config.agentRoot, KEYCHAIN_FILE);
|
|
1614
2042
|
const keychainNeeded = providerAuth.needsKeychain;
|
|
1615
2043
|
const keychainPresent = existsSync4(keychainPath);
|
|
1616
2044
|
const fragment = input.mode === "sandboxed" ? renderPolicyFragment({
|
|
@@ -1696,6 +2124,22 @@ var Supervisor = class extends EventEmitter2 {
|
|
|
1696
2124
|
void this.#refreshState(session.id);
|
|
1697
2125
|
return { ok: true, session };
|
|
1698
2126
|
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Persist a session once its FILE is known (issue #185).
|
|
2129
|
+
*
|
|
2130
|
+
* Called from the two places that learn it: `#refreshState` (the first `get_state` of a new
|
|
2131
|
+
* session, where Pi reports the file it just created) and `retry` of a session that already had
|
|
2132
|
+
* one. Recording the Pi Central handle here instead would be the "keyed by handle" mistake
|
|
2133
|
+
* `roster.ts` documents: `sN` is per-process, and after a restart it addresses a different
|
|
2134
|
+
* conversation.
|
|
2135
|
+
*
|
|
2136
|
+
* A resumed session is already known — `resume` records it synchronously — so the store's own
|
|
2137
|
+
* "nothing changed, no write" check keeps this from writing on every state refresh.
|
|
2138
|
+
*/
|
|
2139
|
+
#rememberSession(session) {
|
|
2140
|
+
if (!this.#rememberOpenSessions) return;
|
|
2141
|
+
this.#open.remember(session.sessionFile, session.mode);
|
|
2142
|
+
}
|
|
1699
2143
|
/**
|
|
1700
2144
|
* A resumed session reopens its file (`--session <path>`); a new session is left to Pi's
|
|
1701
2145
|
* default naming in its workspace.
|
|
@@ -1719,6 +2163,7 @@ var Supervisor = class extends EventEmitter2 {
|
|
|
1719
2163
|
session.provider = model?.provider;
|
|
1720
2164
|
session.thinkingLevel = state.thinkingLevel;
|
|
1721
2165
|
if (typeof state.sessionFile === "string") session.sessionFile = state.sessionFile;
|
|
2166
|
+
this.#rememberSession(session);
|
|
1722
2167
|
if (typeof state.sessionId === "string" && state.sessionId) session.sessionId = state.sessionId;
|
|
1723
2168
|
if (typeof state.sessionName === "string" && state.sessionName) session.name = state.sessionName;
|
|
1724
2169
|
session.messageCount = Number(state.messageCount ?? session.messageCount);
|
|
@@ -1848,22 +2293,32 @@ ${rpc.stderrTail}` : ""}`;
|
|
|
1848
2293
|
this.#patch(id);
|
|
1849
2294
|
}
|
|
1850
2295
|
/**
|
|
1851
|
-
* Send a prompt. The command shape is closed; the message
|
|
2296
|
+
* Send a prompt. The command shape is closed; the message and its images are the only free values.
|
|
1852
2297
|
*
|
|
1853
2298
|
* `behavior` decides what a message means mid-run, and it is the caller's choice rather than
|
|
1854
2299
|
* this method's: Pi REJECTS a streaming `prompt` with no `streamingBehavior` (`"Agent is
|
|
1855
2300
|
* already processing. Specify streamingBehavior ('steer' or 'followUp')"`), so defaulting it
|
|
1856
2301
|
* silently would decide for the user something the composer is there to ask (issue #3). When
|
|
1857
2302
|
* idle there is nothing to steer into, so the flag is dropped rather than sent.
|
|
2303
|
+
*
|
|
2304
|
+
* `images` arrive ALREADY VALIDATED (`prompt-images.ts`) and are mapped to Pi's shape here — the one
|
|
2305
|
+
* place a control becomes Pi's stdin, which is the right place for the spelling to change. Pi wants
|
|
2306
|
+
* `{ type: "image", data, mimeType }`; the wire carries `{ mediaType, data }` because `type` is the
|
|
2307
|
+
* discriminant of every record on that wire and reusing it inside a payload makes a log ambiguous.
|
|
1858
2308
|
*/
|
|
1859
|
-
async prompt(id, message, behavior) {
|
|
2309
|
+
async prompt(id, message, behavior, images = []) {
|
|
1860
2310
|
const rpc = this.#processes.get(id);
|
|
1861
2311
|
const session = this.#sessions.get(id);
|
|
1862
2312
|
if (!rpc || !session) throw new Error(`no such session: ${id}`);
|
|
2313
|
+
const text = message.trim().length === 0 && images.length > 0 ? EMPTY_PROMPT_WITH_IMAGES : message;
|
|
2314
|
+
const withImages = images.length > 0 ? { images: images.map((image) => ({ type: "image", data: image.data, mimeType: image.mediaType })) } : {};
|
|
1863
2315
|
if (session.state === "streaming") {
|
|
1864
|
-
await rpc.send(
|
|
2316
|
+
await rpc.send(
|
|
2317
|
+
{ type: "prompt", message: text, streamingBehavior: behavior ?? "followUp", ...withImages },
|
|
2318
|
+
6e4
|
|
2319
|
+
);
|
|
1865
2320
|
} else {
|
|
1866
|
-
await rpc.send({ type: "prompt", message }, 6e4);
|
|
2321
|
+
await rpc.send({ type: "prompt", message: text, ...withImages }, 6e4);
|
|
1867
2322
|
}
|
|
1868
2323
|
}
|
|
1869
2324
|
/**
|
|
@@ -1916,7 +2371,20 @@ ${rpc.stderrTail}` : ""}`;
|
|
|
1916
2371
|
provider: String(model["provider"] ?? ""),
|
|
1917
2372
|
id: String(model["id"] ?? ""),
|
|
1918
2373
|
...typeof model["name"] === "string" ? { name: model["name"] } : {},
|
|
1919
|
-
...typeof model["reasoning"] === "boolean" ? { reasoning: model["reasoning"] } : {}
|
|
2374
|
+
...typeof model["reasoning"] === "boolean" ? { reasoning: model["reasoning"] } : {},
|
|
2375
|
+
/*
|
|
2376
|
+
* `input` decides whether the composer may offer a paste.
|
|
2377
|
+
*
|
|
2378
|
+
* Filtered to the two values the wire declares rather than passed through: Pi's catalogue may grow a
|
|
2379
|
+
* modality this version does not know, and an unrecognised member would widen the UI's type for a capability
|
|
2380
|
+
* nothing here can use. Absent when Pi did not report it — which is "unknown", NOT "text only", and the
|
|
2381
|
+
* difference decides whether the affordance is hidden or merely unflagged.
|
|
2382
|
+
*/
|
|
2383
|
+
...Array.isArray(model["input"]) ? {
|
|
2384
|
+
input: model["input"].filter(
|
|
2385
|
+
(value) => value === "text" || value === "image"
|
|
2386
|
+
)
|
|
2387
|
+
} : {}
|
|
1920
2388
|
})).filter((model) => model.provider !== "" && model.id !== "");
|
|
1921
2389
|
return { ok: true, models };
|
|
1922
2390
|
} catch (error) {
|
|
@@ -1972,12 +2440,36 @@ ${rpc.stderrTail}` : ""}`;
|
|
|
1972
2440
|
if (!session || !rpc) return;
|
|
1973
2441
|
session.state = "stopped";
|
|
1974
2442
|
session.dialogs = [];
|
|
2443
|
+
if (this.#rememberOpenSessions) this.#open.forget(session.sessionFile);
|
|
1975
2444
|
this.#patch(id);
|
|
1976
2445
|
await rpc.stop();
|
|
1977
2446
|
this.#processes.delete(id);
|
|
1978
2447
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
2448
|
+
/**
|
|
2449
|
+
* Stop every session, WITHOUT forgetting them (issue #185).
|
|
2450
|
+
*
|
|
2451
|
+
* This is the shutdown path (Ctrl-C, SIGTERM), and the distinction is the whole feature: the next
|
|
2452
|
+
* launch reopens what was open. `forget` exists for the one caller that really means "empty the
|
|
2453
|
+
* rail" — an explicit stop.
|
|
2454
|
+
*/
|
|
2455
|
+
async stopAll(options = {}) {
|
|
2456
|
+
const ids = [...this.#processes.keys()];
|
|
2457
|
+
if (options.forget === true) {
|
|
2458
|
+
await Promise.all(ids.map((id) => this.stop(id)));
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
await Promise.all(
|
|
2462
|
+
ids.map(async (id) => {
|
|
2463
|
+
const session = this.#sessions.get(id);
|
|
2464
|
+
const rpc = this.#processes.get(id);
|
|
2465
|
+
if (!session || !rpc) return;
|
|
2466
|
+
session.state = "stopped";
|
|
2467
|
+
session.dialogs = [];
|
|
2468
|
+
this.#patch(id);
|
|
2469
|
+
await rpc.stop();
|
|
2470
|
+
this.#processes.delete(id);
|
|
2471
|
+
})
|
|
2472
|
+
);
|
|
1981
2473
|
}
|
|
1982
2474
|
/**
|
|
1983
2475
|
* List a session's working directory (issue #8, ADR-0007).
|
|
@@ -2094,28 +2586,28 @@ import { createServer } from "node:http";
|
|
|
2094
2586
|
import { WebSocketServer } from "ws";
|
|
2095
2587
|
|
|
2096
2588
|
// packages/orchestrator/src/package-meta.ts
|
|
2097
|
-
import { existsSync as existsSync5, readFileSync as
|
|
2098
|
-
import { dirname, join as
|
|
2589
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
|
|
2590
|
+
import { dirname as dirname3, join as join11, parse } from "node:path";
|
|
2099
2591
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2100
2592
|
var PACKAGE_NAME = "pi-central";
|
|
2101
2593
|
function findOwnPackage(from = import.meta.url) {
|
|
2102
|
-
let directory =
|
|
2594
|
+
let directory = dirname3(fileURLToPath2(from));
|
|
2103
2595
|
for (; ; ) {
|
|
2104
|
-
const candidate =
|
|
2596
|
+
const candidate = join11(directory, "package.json");
|
|
2105
2597
|
if (existsSync5(candidate)) {
|
|
2106
2598
|
const parsed = readPackageJson(candidate);
|
|
2107
2599
|
if (parsed?.name === PACKAGE_NAME && typeof parsed.version === "string") {
|
|
2108
2600
|
return { path: candidate, root: directory, name: parsed.name, version: parsed.version };
|
|
2109
2601
|
}
|
|
2110
2602
|
}
|
|
2111
|
-
const parent =
|
|
2603
|
+
const parent = dirname3(directory);
|
|
2112
2604
|
if (parent === directory || directory === parse(directory).root) return void 0;
|
|
2113
2605
|
directory = parent;
|
|
2114
2606
|
}
|
|
2115
2607
|
}
|
|
2116
2608
|
function readPackageJson(path) {
|
|
2117
2609
|
try {
|
|
2118
|
-
const parsed = JSON.parse(
|
|
2610
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
2119
2611
|
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
2120
2612
|
return parsed;
|
|
2121
2613
|
} catch {
|
|
@@ -2124,216 +2616,93 @@ function readPackageJson(path) {
|
|
|
2124
2616
|
}
|
|
2125
2617
|
var VERIFY_INSTALL_HINT = "npm audit signatures";
|
|
2126
2618
|
|
|
2127
|
-
// packages/orchestrator/src/
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
}
|
|
2171
|
-
async function readHead(path) {
|
|
2172
|
-
return new Promise((resolve9) => {
|
|
2173
|
-
const chunks = [];
|
|
2174
|
-
let size = 0;
|
|
2175
|
-
const stream = createReadStream(path, { start: 0, end: HEAD_BYTES - 1 });
|
|
2176
|
-
stream.on("data", (chunk) => {
|
|
2177
|
-
chunks.push(chunk);
|
|
2178
|
-
size += chunk.length;
|
|
2179
|
-
});
|
|
2180
|
-
stream.on("error", () => resolve9(""));
|
|
2181
|
-
stream.on("close", () => resolve9(Buffer.concat(chunks, size).toString("utf8")));
|
|
2182
|
-
});
|
|
2619
|
+
// packages/orchestrator/src/prompt-images.ts
|
|
2620
|
+
var PROMPT_IMAGE_TYPES = [
|
|
2621
|
+
"image/png",
|
|
2622
|
+
"image/jpeg",
|
|
2623
|
+
"image/gif",
|
|
2624
|
+
"image/webp"
|
|
2625
|
+
];
|
|
2626
|
+
var MAX_PROMPT_IMAGE_BYTES = 3 * 1024 * 1024;
|
|
2627
|
+
function refuseImage(refusal, detail) {
|
|
2628
|
+
switch (refusal) {
|
|
2629
|
+
case "unsupported-type":
|
|
2630
|
+
return `an image of type ${detail?.type ?? "unknown"} cannot be sent \u2014 prompts accept PNG, JPEG, GIF and WebP`;
|
|
2631
|
+
case "too-large":
|
|
2632
|
+
return `that image is ${formatMegabytes(detail?.bytes ?? 0)} decoded, above the ${formatMegabytes(MAX_PROMPT_IMAGE_BYTES)} limit for a prompt \u2014 downscale it and paste again`;
|
|
2633
|
+
case "not-base64":
|
|
2634
|
+
return "that image's data is not valid base64 \u2014 it may have been truncated in transit";
|
|
2635
|
+
case "missing-type":
|
|
2636
|
+
return "an image arrived without a media type";
|
|
2637
|
+
case "missing-data":
|
|
2638
|
+
return "an image arrived with no data";
|
|
2639
|
+
case "not-an-object":
|
|
2640
|
+
return "an image arrived in an unrecognised shape";
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
function formatMegabytes(bytes) {
|
|
2644
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown size";
|
|
2645
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
2183
2646
|
}
|
|
2184
|
-
function
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2647
|
+
function base64Bytes(data) {
|
|
2648
|
+
if (data.length === 0) return 0;
|
|
2649
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
2650
|
+
return Math.floor(data.length * 3 / 4) - padding;
|
|
2651
|
+
}
|
|
2652
|
+
function isBase64(data) {
|
|
2653
|
+
if (data.length === 0 || data.length % 4 !== 0) return false;
|
|
2654
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(data);
|
|
2655
|
+
}
|
|
2656
|
+
function acceptImage(raw) {
|
|
2657
|
+
if (typeof raw !== "object" || raw === null) return { ok: false, reason: "not-an-object" };
|
|
2658
|
+
const candidate = raw;
|
|
2659
|
+
if (typeof candidate.mediaType !== "string" || candidate.mediaType.length === 0) {
|
|
2660
|
+
return { ok: false, reason: "missing-type" };
|
|
2661
|
+
}
|
|
2662
|
+
if (typeof candidate.data !== "string" || candidate.data.length === 0) {
|
|
2663
|
+
return { ok: false, reason: "missing-data" };
|
|
2664
|
+
}
|
|
2665
|
+
const mediaType = candidate.mediaType.toLowerCase();
|
|
2666
|
+
if (!PROMPT_IMAGE_TYPES.includes(mediaType)) return { ok: false, reason: "unsupported-type" };
|
|
2667
|
+
if (!isBase64(candidate.data)) return { ok: false, reason: "not-base64" };
|
|
2668
|
+
const bytes = base64Bytes(candidate.data);
|
|
2669
|
+
if (bytes > MAX_PROMPT_IMAGE_BYTES) return { ok: false, reason: "too-large" };
|
|
2670
|
+
return { ok: true, image: { mediaType, data: candidate.data } };
|
|
2671
|
+
}
|
|
2672
|
+
var MAX_PROMPT_IMAGES_ENCODED_BYTES = 6 * 1024 * 1024;
|
|
2673
|
+
function acceptImages(raw) {
|
|
2674
|
+
if (raw === void 0 || raw === null) return { ok: true, images: [] };
|
|
2675
|
+
if (!Array.isArray(raw)) return { ok: false, message: "images must be a list" };
|
|
2676
|
+
if (raw.length === 0) return { ok: true, images: [] };
|
|
2677
|
+
const images = [];
|
|
2678
|
+
for (const entry of raw) {
|
|
2679
|
+
const result = acceptImage(entry);
|
|
2680
|
+
if (!result.ok) {
|
|
2681
|
+
const type = typeof entry === "object" && entry !== null ? entry.mediaType : void 0;
|
|
2682
|
+
const bytes = typeof entry === "object" && entry !== null && typeof entry.data === "string" ? base64Bytes(entry.data) : void 0;
|
|
2683
|
+
return {
|
|
2684
|
+
ok: false,
|
|
2685
|
+
message: refuseImage(result.reason, {
|
|
2686
|
+
...typeof type === "string" ? { type } : {},
|
|
2687
|
+
...bytes !== void 0 ? { bytes } : {}
|
|
2688
|
+
})
|
|
2209
2689
|
};
|
|
2210
|
-
continue;
|
|
2211
2690
|
}
|
|
2212
|
-
|
|
2213
|
-
name = record.name.trim() || void 0;
|
|
2214
|
-
continue;
|
|
2215
|
-
}
|
|
2216
|
-
if (record.type === "message") {
|
|
2217
|
-
messageCount += 1;
|
|
2218
|
-
if (!firstMessage) {
|
|
2219
|
-
const message = record.message;
|
|
2220
|
-
if (message?.role === "user") firstMessage = flattenContent(message.content).slice(0, 200);
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
return header ? { header, name, firstMessage, messageCount } : null;
|
|
2225
|
-
}
|
|
2226
|
-
function flattenContent(content) {
|
|
2227
|
-
if (typeof content === "string") return content;
|
|
2228
|
-
if (Array.isArray(content)) {
|
|
2229
|
-
return content.map((part) => {
|
|
2230
|
-
if (typeof part === "string") return part;
|
|
2231
|
-
if (part && typeof part === "object" && "text" in part) return String(part.text);
|
|
2232
|
-
return "";
|
|
2233
|
-
}).join(" ");
|
|
2234
|
-
}
|
|
2235
|
-
return "";
|
|
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;
|
|
2691
|
+
images.push(result.image);
|
|
2273
2692
|
}
|
|
2274
|
-
const
|
|
2275
|
-
if (
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
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
|
-
}
|
|
2288
|
-
async function discover(config, limit = 500) {
|
|
2289
|
-
const sessions = [];
|
|
2290
|
-
const skipped = [];
|
|
2291
|
-
let projectDirs;
|
|
2292
|
-
try {
|
|
2293
|
-
projectDirs = readdirSync3(config.sessionsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
2294
|
-
} catch {
|
|
2295
|
-
return { sessions, total: 0, skipped };
|
|
2296
|
-
}
|
|
2297
|
-
for (const projectDir of projectDirs) {
|
|
2298
|
-
const projectPath = join10(config.sessionsRoot, projectDir);
|
|
2299
|
-
let entries;
|
|
2300
|
-
try {
|
|
2301
|
-
entries = readdirSync3(projectPath, { withFileTypes: true });
|
|
2302
|
-
} catch {
|
|
2303
|
-
continue;
|
|
2304
|
-
}
|
|
2305
|
-
for (const entry of entries) {
|
|
2306
|
-
if (!entry.name.endsWith(".jsonl")) continue;
|
|
2307
|
-
if (!entry.isFile()) continue;
|
|
2308
|
-
const path = join10(projectPath, entry.name);
|
|
2309
|
-
let stats;
|
|
2310
|
-
try {
|
|
2311
|
-
stats = statSync6(path);
|
|
2312
|
-
} catch {
|
|
2313
|
-
continue;
|
|
2314
|
-
}
|
|
2315
|
-
const row = await describeSessionFile(config, path, projectDir, stats);
|
|
2316
|
-
if (!row.ok) {
|
|
2317
|
-
skipped.push({ path, reason: row.reason });
|
|
2318
|
-
continue;
|
|
2319
|
-
}
|
|
2320
|
-
sessions.push(row.session);
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
|
2324
|
-
return { sessions: sessions.slice(0, limit), total: sessions.length, skipped };
|
|
2325
|
-
}
|
|
2326
|
-
function directoryExists(dir) {
|
|
2327
|
-
try {
|
|
2328
|
-
return statSync6(dir).isDirectory();
|
|
2329
|
-
} catch {
|
|
2330
|
-
return false;
|
|
2693
|
+
const encoded = images.reduce((total, image) => total + image.data.length, 0);
|
|
2694
|
+
if (encoded > MAX_PROMPT_IMAGES_ENCODED_BYTES) {
|
|
2695
|
+
return {
|
|
2696
|
+
ok: false,
|
|
2697
|
+
message: `these images total ${(encoded / (1024 * 1024)).toFixed(1)} MB encoded, above the ${(MAX_PROMPT_IMAGES_ENCODED_BYTES / (1024 * 1024)).toFixed(1)} MB limit for one prompt \u2014 remove one or downscale them`
|
|
2698
|
+
};
|
|
2331
2699
|
}
|
|
2700
|
+
return { ok: true, images };
|
|
2332
2701
|
}
|
|
2333
2702
|
|
|
2334
2703
|
// packages/orchestrator/src/static-ui.ts
|
|
2335
|
-
import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync7 } from "node:fs";
|
|
2336
|
-
import { extname, join as
|
|
2704
|
+
import { createReadStream as createReadStream2, existsSync as existsSync6, readFileSync as readFileSync7, statSync as statSync7 } from "node:fs";
|
|
2705
|
+
import { extname, join as join12, posix, resolve as resolve9, sep as sep5 } from "node:path";
|
|
2337
2706
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2338
2707
|
var UI_CSP = (
|
|
2339
2708
|
// `blob:` is REQUIRED and is not covered by `'self'` (issue #111).
|
|
@@ -2370,13 +2739,13 @@ var CONTENT_TYPES = {
|
|
|
2370
2739
|
};
|
|
2371
2740
|
function resolveUiDistDir(env = process.env, from = import.meta.url) {
|
|
2372
2741
|
const explicit = env["PI_CENTRAL_UI_DIST"];
|
|
2373
|
-
if (explicit !== void 0 && explicit !== "") return
|
|
2742
|
+
if (explicit !== void 0 && explicit !== "") return resolve9(explicit);
|
|
2374
2743
|
const own = findOwnPackage(from);
|
|
2375
2744
|
if (own === void 0) {
|
|
2376
2745
|
return fileURLToPath3(new URL("../../ui/dist", from));
|
|
2377
2746
|
}
|
|
2378
|
-
const packaged =
|
|
2379
|
-
const workspace =
|
|
2747
|
+
const packaged = join12(own.root, "dist", "ui");
|
|
2748
|
+
const workspace = join12(own.root, "packages", "ui", "dist");
|
|
2380
2749
|
const fromSource = runningFromSource(from);
|
|
2381
2750
|
const candidates = fromSource ? [workspace, packaged] : [packaged, workspace];
|
|
2382
2751
|
for (const candidate of candidates) {
|
|
@@ -2388,13 +2757,13 @@ function runningFromSource(from) {
|
|
|
2388
2757
|
return /[\\/]packages[\\/][^\\/]+[\\/]src[\\/]/.test(fileURLToPath3(from));
|
|
2389
2758
|
}
|
|
2390
2759
|
function isInside3(root, candidate) {
|
|
2391
|
-
const rootPath =
|
|
2392
|
-
const target =
|
|
2760
|
+
const rootPath = resolve9(root);
|
|
2761
|
+
const target = resolve9(candidate);
|
|
2393
2762
|
if (target === rootPath) return true;
|
|
2394
2763
|
return target.startsWith(rootPath.endsWith(sep5) ? rootPath : rootPath + sep5);
|
|
2395
2764
|
}
|
|
2396
2765
|
function resolveStaticPath(root, requestTarget) {
|
|
2397
|
-
const rootPath =
|
|
2766
|
+
const rootPath = resolve9(root);
|
|
2398
2767
|
const target = requestTarget.split("?")[0].split("#")[0];
|
|
2399
2768
|
let decoded;
|
|
2400
2769
|
try {
|
|
@@ -2408,16 +2777,16 @@ function resolveStaticPath(root, requestTarget) {
|
|
|
2408
2777
|
return { kind: "denied", reason: "the request target contains a '..' segment" };
|
|
2409
2778
|
}
|
|
2410
2779
|
const relative = posix.normalize(decoded).replace(/^\/+/, "");
|
|
2411
|
-
const candidate =
|
|
2780
|
+
const candidate = resolve9(rootPath, relative);
|
|
2412
2781
|
if (!isInside3(rootPath, candidate)) {
|
|
2413
2782
|
return { kind: "denied", reason: "the request target resolves outside the served UI root" };
|
|
2414
2783
|
}
|
|
2415
2784
|
return { kind: "file", path: candidate };
|
|
2416
2785
|
}
|
|
2417
2786
|
function createUiRequestHandler(options) {
|
|
2418
|
-
const root =
|
|
2787
|
+
const root = resolve9(options.root);
|
|
2419
2788
|
const csp = options.csp ?? UI_CSP;
|
|
2420
|
-
const indexPath =
|
|
2789
|
+
const indexPath = resolve9(root, "index.html");
|
|
2421
2790
|
return (req, res) => {
|
|
2422
2791
|
const method = req.method ?? "GET";
|
|
2423
2792
|
if (method !== "GET" && method !== "HEAD") {
|
|
@@ -2504,7 +2873,21 @@ function sendFile(res, method, path, csp) {
|
|
|
2504
2873
|
stream.pipe(res);
|
|
2505
2874
|
}
|
|
2506
2875
|
function hasBuiltUi(root) {
|
|
2507
|
-
return existsSync6(
|
|
2876
|
+
return existsSync6(resolve9(root, "index.html"));
|
|
2877
|
+
}
|
|
2878
|
+
function servedUiIdentity(root) {
|
|
2879
|
+
let html;
|
|
2880
|
+
let mtime;
|
|
2881
|
+
try {
|
|
2882
|
+
const indexPath = resolve9(root, "index.html");
|
|
2883
|
+
html = readFileSync7(indexPath, "utf8");
|
|
2884
|
+
mtime = statSync7(indexPath).mtime;
|
|
2885
|
+
} catch {
|
|
2886
|
+
return void 0;
|
|
2887
|
+
}
|
|
2888
|
+
const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
|
|
2889
|
+
if (entry === void 0) return void 0;
|
|
2890
|
+
return { entry, builtAt: mtime.toISOString() };
|
|
2508
2891
|
}
|
|
2509
2892
|
|
|
2510
2893
|
// packages/orchestrator/src/transport.ts
|
|
@@ -2590,11 +2973,11 @@ var Transport = class {
|
|
|
2590
2973
|
this.#server = server;
|
|
2591
2974
|
this.#wss = wss;
|
|
2592
2975
|
wss.on("connection", (socket) => this.#onConnection(socket));
|
|
2593
|
-
await new Promise((
|
|
2976
|
+
await new Promise((resolve10, reject) => {
|
|
2594
2977
|
server.once("error", reject);
|
|
2595
2978
|
server.listen(this.#options.port, this.#options.host, () => {
|
|
2596
2979
|
server.off("error", reject);
|
|
2597
|
-
|
|
2980
|
+
resolve10();
|
|
2598
2981
|
});
|
|
2599
2982
|
});
|
|
2600
2983
|
const addr = server.address();
|
|
@@ -2642,6 +3025,20 @@ var Transport = class {
|
|
|
2642
3025
|
};
|
|
2643
3026
|
return this.#install;
|
|
2644
3027
|
}
|
|
3028
|
+
/**
|
|
3029
|
+
* The install facts for ONE snapshot, with the served-UI identity read live (issue #184).
|
|
3030
|
+
*
|
|
3031
|
+
* `#installFacts` is cached because a package walk cannot change under a running process; the UI
|
|
3032
|
+
* directory CAN — a dev `vite build` replaces it while the orchestrator runs, and reading it is
|
|
3033
|
+
* one small file — so `ui` is resolved per snapshot rather than frozen into the cached facts. A
|
|
3034
|
+
* stale `ui` here would be the one value issue #184 exists for, reported wrong.
|
|
3035
|
+
*/
|
|
3036
|
+
#snapshotInstallFacts() {
|
|
3037
|
+
const facts = this.#installFacts();
|
|
3038
|
+
if (this.#options.uiDir === null) return facts;
|
|
3039
|
+
const ui = servedUiIdentity(this.#options.uiDir);
|
|
3040
|
+
return ui === void 0 ? facts : { ...facts, ui };
|
|
3041
|
+
}
|
|
2645
3042
|
async #sendSnapshot(socket) {
|
|
2646
3043
|
let history = [];
|
|
2647
3044
|
let historyTotal = 0;
|
|
@@ -2659,7 +3056,7 @@ var Transport = class {
|
|
|
2659
3056
|
historyTotal,
|
|
2660
3057
|
home: this.#config.home,
|
|
2661
3058
|
installationRoot: this.#config.installationRoot,
|
|
2662
|
-
install: this.#
|
|
3059
|
+
install: this.#snapshotInstallFacts()
|
|
2663
3060
|
});
|
|
2664
3061
|
for (const session of this.#supervisor.list()) {
|
|
2665
3062
|
if (session.state === "stopped" || session.state === "failed") continue;
|
|
@@ -2708,7 +3105,11 @@ var Transport = class {
|
|
|
2708
3105
|
history: discovered.sessions,
|
|
2709
3106
|
historyTotal: discovered.total,
|
|
2710
3107
|
home: this.#config.home,
|
|
2711
|
-
installationRoot: this.#config.installationRoot
|
|
3108
|
+
installationRoot: this.#config.installationRoot,
|
|
3109
|
+
// `install` here as well as on the connect snapshot (issue #184): a page that never
|
|
3110
|
+
// reconnects still learns the orchestrator was replaced the next time it lists, rather
|
|
3111
|
+
// than only when the socket happens to drop.
|
|
3112
|
+
install: this.#snapshotInstallFacts()
|
|
2712
3113
|
});
|
|
2713
3114
|
return;
|
|
2714
3115
|
}
|
|
@@ -2751,13 +3152,15 @@ var Transport = class {
|
|
|
2751
3152
|
this.#broadcast({ kind: "session", session: result.session });
|
|
2752
3153
|
return;
|
|
2753
3154
|
}
|
|
2754
|
-
case "prompt":
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
control.
|
|
2758
|
-
|
|
2759
|
-
|
|
3155
|
+
case "prompt": {
|
|
3156
|
+
const accepted = acceptImages(control.images);
|
|
3157
|
+
if (!accepted.ok) {
|
|
3158
|
+
this.#broadcast({ kind: "error", message: `image refused: ${accepted.message}`, sessionId: control.sessionId });
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
await this.#supervisor.prompt(control.sessionId, control.message, control.behavior, accepted.images);
|
|
2760
3162
|
return;
|
|
3163
|
+
}
|
|
2761
3164
|
case "clearQueue": {
|
|
2762
3165
|
await this.#supervisor.clearQueue(control.sessionId, control.andStop);
|
|
2763
3166
|
return;
|
|
@@ -2912,14 +3315,14 @@ var Transport = class {
|
|
|
2912
3315
|
this.#wss = void 0;
|
|
2913
3316
|
this.#server = void 0;
|
|
2914
3317
|
this.#address = void 0;
|
|
2915
|
-
if (wss) await new Promise((
|
|
2916
|
-
if (server) await new Promise((
|
|
3318
|
+
if (wss) await new Promise((resolve10) => wss.close(() => resolve10()));
|
|
3319
|
+
if (server) await new Promise((resolve10) => server.close(() => resolve10()));
|
|
2917
3320
|
}
|
|
2918
3321
|
};
|
|
2919
3322
|
|
|
2920
3323
|
// packages/orchestrator/src/startup-error.ts
|
|
2921
|
-
import {
|
|
2922
|
-
import { join as
|
|
3324
|
+
import { statSync as statSync8 } from "node:fs";
|
|
3325
|
+
import { join as join13 } from "node:path";
|
|
2923
3326
|
function explainStartupError(error, host, port) {
|
|
2924
3327
|
const code = error?.code;
|
|
2925
3328
|
if (code === "EADDRINUSE") {
|
|
@@ -2954,15 +3357,15 @@ function explainStartupError(error, host, port) {
|
|
|
2954
3357
|
return void 0;
|
|
2955
3358
|
}
|
|
2956
3359
|
function describeBuiltUi(uiDir) {
|
|
3360
|
+
let readable = true;
|
|
2957
3361
|
try {
|
|
2958
|
-
|
|
2959
|
-
const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
|
|
2960
|
-
if (entry === void 0) return "(no entry bundle named in index.html)";
|
|
2961
|
-
const { mtime } = statSync8(join12(uiDir, "index.html"));
|
|
2962
|
-
return `${entry} (built ${mtime.toISOString().slice(0, 16).replace("T", " ")})`;
|
|
3362
|
+
statSync8(join13(uiDir, "index.html"));
|
|
2963
3363
|
} catch {
|
|
2964
|
-
|
|
3364
|
+
readable = false;
|
|
2965
3365
|
}
|
|
3366
|
+
const identity = servedUiIdentity(uiDir);
|
|
3367
|
+
if (identity === void 0) return readable ? "(no entry bundle named in index.html)" : "(unreadable)";
|
|
3368
|
+
return `${identity.entry} (built ${identity.builtAt.slice(0, 16).replace("T", " ")})`;
|
|
2966
3369
|
}
|
|
2967
3370
|
|
|
2968
3371
|
// packages/orchestrator/src/sandbox-guard.ts
|
|
@@ -3100,7 +3503,8 @@ async function main() {
|
|
|
3100
3503
|
}
|
|
3101
3504
|
}
|
|
3102
3505
|
const config = defaultConfig();
|
|
3103
|
-
const supervisor = new Supervisor(config);
|
|
3506
|
+
const supervisor = new Supervisor(config, { rememberOpenSessions: true });
|
|
3507
|
+
const restored = await supervisor.restoreOpenSessions();
|
|
3104
3508
|
const transport = new Transport(supervisor, config, { port: options.port, uiDir: options.uiDir });
|
|
3105
3509
|
transport.attach();
|
|
3106
3510
|
const address = await transport.listen();
|
|
@@ -3110,6 +3514,18 @@ async function main() {
|
|
|
3110
3514
|
console.log(` default workspace: ${config.workspaceRoot}`);
|
|
3111
3515
|
console.log(` agent root: ${config.agentRoot}${config.agentRootManaged ? " (managed)" : " (external)"}`);
|
|
3112
3516
|
console.log(` isolation: ${options.mode}`);
|
|
3517
|
+
if (restored.note !== void 0) console.log(` sessions to restore: ${restored.note}`);
|
|
3518
|
+
if (restored.restored.length > 0) {
|
|
3519
|
+
console.log(` reopened ${restored.restored.length} session${restored.restored.length === 1 ? "" : "s"} from the last run`);
|
|
3520
|
+
}
|
|
3521
|
+
for (const gone of restored.missingFile) {
|
|
3522
|
+
console.log(` forgot a session whose file no longer exists: ${gone}`);
|
|
3523
|
+
}
|
|
3524
|
+
for (const unavailable of restored.unavailable) {
|
|
3525
|
+
const [first = ""] = unavailable.message.split("\n");
|
|
3526
|
+
console.log(` could not reopen ${unavailable.sessionFile}: ${first}`);
|
|
3527
|
+
console.log(` kept in the list; it will be tried again on the next start`);
|
|
3528
|
+
}
|
|
3113
3529
|
console.log(` running from: ${ownPackageRoot()}`);
|
|
3114
3530
|
console.log(` verify this install: ${VERIFY_INSTALL_HINT} (see README, "Verify what you installed")`);
|
|
3115
3531
|
if (config.installationRoot === void 0) {
|
|
@@ -3155,7 +3571,7 @@ async function main() {
|
|
|
3155
3571
|
console.log(`
|
|
3156
3572
|
${signal} received; stopping sessions...`);
|
|
3157
3573
|
await transport.close().catch(() => void 0);
|
|
3158
|
-
await supervisor.stopAll().catch(() => void 0);
|
|
3574
|
+
await supervisor.stopAll({ forget: false }).catch(() => void 0);
|
|
3159
3575
|
console.log("stopped.");
|
|
3160
3576
|
process.exit(0);
|
|
3161
3577
|
};
|