pi-central 0.7.0 → 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/bin/pi-central.js CHANGED
@@ -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 mkdirSync2 } from "node:fs";
275
- import { join as join8 } from "node:path";
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) => this.emit("failed", 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
- for (const [, pending] of this.#pending) {
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((resolve9, reject) => {
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: resolve9, reject, timer });
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((resolve9) => {
615
- const timer = setTimeout(() => resolve9(this.#exited), ms);
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
- resolve9(true);
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 join6 } from "node:path";
1069
+ import { join as join8 } from "node:path";
722
1070
 
723
1071
  // packages/orchestrator/src/signing.ts
724
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
725
- import { join as join5, resolve as resolve5, sep as sep2 } from "node:path";
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 join5(root, "ssh", "github_login_ed25519");
1075
+ return join7(root, "ssh", "github_login_ed25519");
728
1076
  }
729
1077
  function allowedSignersPath(root) {
730
- return join5(root, "ssh", "allowed_signers");
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 = resolve5(root);
773
- const to = resolve5(path);
1120
+ const from = resolve7(root);
1121
+ const to = resolve7(path);
774
1122
  if (to === from) return true;
775
- return to.startsWith(from.endsWith(sep2) ? from : from + sep2);
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 ${join5(root, "gitconfig")} commit.gpgsign true`,
825
- `git config --file ${join5(root, "gitconfig")} tag.gpgsign true`,
826
- `git config --file ${join5(root, "gitconfig")} gpg.format ssh`,
827
- `git config --file ${join5(root, "gitconfig")} user.signingkey ${signingKeyPath(root)}`,
828
- `git config --file ${join5(root, "gitconfig")} gpg.ssh.allowedSignersFile ${allowedSignersPath(root)}`
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 readFileSync3 } from "node:fs";
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 = join6(root, "gitconfig");
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 = join6(root, "gh");
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 ${join6(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.`
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 = join6(root, "gitconfig");
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 readFileSync3(path, "utf8");
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 readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync5 } from "node:fs";
1166
- import { isAbsolute, join as join7, resolve as resolve6, sep as sep3 } from "node:path";
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(sep3) ? parent : parent + sep3;
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 = realpathSync(root);
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 = resolve6(realRoot, relPath);
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 = realpathSync(candidate);
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 = readdirSync2(path);
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(join7(path, name), name));
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()) return { name, kind: "file", size: stats.size, hidden: isHidden(name) };
1299
- return { name, kind: "other", hidden: isHidden(name) };
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 = statSync5(fullPath);
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 = readFileSync4(path);
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 = readFileSync4(path);
1761
+ buffer = readFileSync5(path);
1412
1762
  } catch (error) {
1413
1763
  return { ok: false, error: fromErrno(error, "cannot read", relPath) };
1414
1764
  }
@@ -1485,11 +1835,85 @@ var Supervisor = class extends EventEmitter2 {
1485
1835
  #sessions = /* @__PURE__ */ new Map();
1486
1836
  #processes = /* @__PURE__ */ new Map();
1487
1837
  #created = 0;
1488
- constructor(config) {
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 = {}) {
1489
1858
  super();
1490
1859
  this.#config = config;
1860
+ this.#open = new OpenSessionStore(config.stateRoot);
1861
+ this.#rememberOpenSessions = options.rememberOpenSessions === true;
1491
1862
  this.#installationRoot = resolveInstallationRoot();
1492
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
+ }
1493
1917
  list() {
1494
1918
  return [...this.#sessions.values()];
1495
1919
  }
@@ -1520,7 +1944,7 @@ var Supervisor = class extends EventEmitter2 {
1520
1944
  } else {
1521
1945
  try {
1522
1946
  cwd = scratchDirFor(this.#config, id);
1523
- mkdirSync2(cwd, { recursive: true });
1947
+ mkdirSync3(cwd, { recursive: true });
1524
1948
  } catch (error) {
1525
1949
  return { ok: false, message: error.message };
1526
1950
  }
@@ -1540,15 +1964,18 @@ var Supervisor = class extends EventEmitter2 {
1540
1964
  const live = this.#findLiveByPath(session.path);
1541
1965
  if (live) return { ok: true, session: live };
1542
1966
  const id = `s${++this.#created}`;
1543
- return this.#spawn({
1967
+ const mode = options.mode ?? "sandboxed";
1968
+ const spawned = this.#spawn({
1544
1969
  id,
1545
1970
  cwd: readiness.cwd,
1546
- mode: options.mode ?? "sandboxed",
1971
+ mode,
1547
1972
  // No name: the session already carries its own, and re-applying one would append a
1548
1973
  // redundant session_info entry to a file we did not create.
1549
1974
  ...options.env ? { env: options.env } : {},
1550
1975
  sessionPath: session.path
1551
1976
  });
1977
+ if (spawned.ok && this.#rememberOpenSessions) this.#open.remember(session.path, mode);
1978
+ return spawned;
1552
1979
  }
1553
1980
  #findLiveByPath(sessionPath) {
1554
1981
  for (const session of this.#sessions.values()) {
@@ -1611,7 +2038,7 @@ var Supervisor = class extends EventEmitter2 {
1611
2038
  #spawn(input) {
1612
2039
  const env = { ...agentRoutingEnv(this.#config.agentRoot), ...input.env ?? {} };
1613
2040
  const providerAuth = inspectProviderAuth(this.#config.providerAgentDir);
1614
- const keychainPath = join8(this.#config.agentRoot, KEYCHAIN_FILE);
2041
+ const keychainPath = join10(this.#config.agentRoot, KEYCHAIN_FILE);
1615
2042
  const keychainNeeded = providerAuth.needsKeychain;
1616
2043
  const keychainPresent = existsSync4(keychainPath);
1617
2044
  const fragment = input.mode === "sandboxed" ? renderPolicyFragment({
@@ -1697,6 +2124,22 @@ var Supervisor = class extends EventEmitter2 {
1697
2124
  void this.#refreshState(session.id);
1698
2125
  return { ok: true, session };
1699
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
+ }
1700
2143
  /**
1701
2144
  * A resumed session reopens its file (`--session <path>`); a new session is left to Pi's
1702
2145
  * default naming in its workspace.
@@ -1720,6 +2163,7 @@ var Supervisor = class extends EventEmitter2 {
1720
2163
  session.provider = model?.provider;
1721
2164
  session.thinkingLevel = state.thinkingLevel;
1722
2165
  if (typeof state.sessionFile === "string") session.sessionFile = state.sessionFile;
2166
+ this.#rememberSession(session);
1723
2167
  if (typeof state.sessionId === "string" && state.sessionId) session.sessionId = state.sessionId;
1724
2168
  if (typeof state.sessionName === "string" && state.sessionName) session.name = state.sessionName;
1725
2169
  session.messageCount = Number(state.messageCount ?? session.messageCount);
@@ -1996,12 +2440,36 @@ ${rpc.stderrTail}` : ""}`;
1996
2440
  if (!session || !rpc) return;
1997
2441
  session.state = "stopped";
1998
2442
  session.dialogs = [];
2443
+ if (this.#rememberOpenSessions) this.#open.forget(session.sessionFile);
1999
2444
  this.#patch(id);
2000
2445
  await rpc.stop();
2001
2446
  this.#processes.delete(id);
2002
2447
  }
2003
- async stopAll() {
2004
- await Promise.all([...this.#processes.keys()].map((id) => this.stop(id)));
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
+ );
2005
2473
  }
2006
2474
  /**
2007
2475
  * List a session's working directory (issue #8, ADR-0007).
@@ -2118,28 +2586,28 @@ import { createServer } from "node:http";
2118
2586
  import { WebSocketServer } from "ws";
2119
2587
 
2120
2588
  // packages/orchestrator/src/package-meta.ts
2121
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
2122
- import { dirname, join as join9, parse } from "node:path";
2589
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
2590
+ import { dirname as dirname3, join as join11, parse } from "node:path";
2123
2591
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2124
2592
  var PACKAGE_NAME = "pi-central";
2125
2593
  function findOwnPackage(from = import.meta.url) {
2126
- let directory = dirname(fileURLToPath2(from));
2594
+ let directory = dirname3(fileURLToPath2(from));
2127
2595
  for (; ; ) {
2128
- const candidate = join9(directory, "package.json");
2596
+ const candidate = join11(directory, "package.json");
2129
2597
  if (existsSync5(candidate)) {
2130
2598
  const parsed = readPackageJson(candidate);
2131
2599
  if (parsed?.name === PACKAGE_NAME && typeof parsed.version === "string") {
2132
2600
  return { path: candidate, root: directory, name: parsed.name, version: parsed.version };
2133
2601
  }
2134
2602
  }
2135
- const parent = dirname(directory);
2603
+ const parent = dirname3(directory);
2136
2604
  if (parent === directory || directory === parse(directory).root) return void 0;
2137
2605
  directory = parent;
2138
2606
  }
2139
2607
  }
2140
2608
  function readPackageJson(path) {
2141
2609
  try {
2142
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
2610
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
2143
2611
  if (typeof parsed !== "object" || parsed === null) return void 0;
2144
2612
  return parsed;
2145
2613
  } catch {
@@ -2232,216 +2700,9 @@ function acceptImages(raw) {
2232
2700
  return { ok: true, images };
2233
2701
  }
2234
2702
 
2235
- // packages/orchestrator/src/registry.ts
2236
- import { createReadStream, readdirSync as readdirSync3, realpathSync as realpathSync2, statSync as statSync6 } from "node:fs";
2237
- import { basename, dirname as dirname2, join as join10, resolve as resolve7, sep as sep4 } from "node:path";
2238
- var KNOWN_SESSION_VERSIONS = /* @__PURE__ */ new Set([3]);
2239
- var HEAD_BYTES = 256 * 1024;
2240
- var TAIL_BYTES = 8 * 1024;
2241
- function decodeSessionDir(dirName) {
2242
- const inner = dirName.replace(/^--/, "").replace(/--$/, "");
2243
- return `/${inner.replace(/-/g, "/")}`;
2244
- }
2245
- async function readLateSessionName(path) {
2246
- let stats;
2247
- try {
2248
- stats = statSync6(path);
2249
- } catch {
2250
- return void 0;
2251
- }
2252
- const start = Math.max(0, stats.size - TAIL_BYTES);
2253
- if (start === 0) return void 0;
2254
- const text = await new Promise((resolve9) => {
2255
- const chunks = [];
2256
- const stream = createReadStream(path, { start, end: stats.size - 1 });
2257
- stream.on("data", (chunk) => chunks.push(chunk));
2258
- stream.on("error", () => resolve9(""));
2259
- stream.on("close", () => resolve9(Buffer.concat(chunks).toString("utf8")));
2260
- });
2261
- if (text === "") return void 0;
2262
- const lines = text.split("\n");
2263
- lines.shift();
2264
- let name;
2265
- for (const line of lines) {
2266
- if (!line.includes('"session_info"')) continue;
2267
- try {
2268
- const record = JSON.parse(line);
2269
- if (record.type === "session_info" && typeof record.name === "string") {
2270
- const trimmed = record.name.trim();
2271
- if (trimmed.length > 0) name = trimmed;
2272
- }
2273
- } catch {
2274
- continue;
2275
- }
2276
- }
2277
- return name;
2278
- }
2279
- async function readHead(path) {
2280
- return new Promise((resolve9) => {
2281
- const chunks = [];
2282
- let size = 0;
2283
- const stream = createReadStream(path, { start: 0, end: HEAD_BYTES - 1 });
2284
- stream.on("data", (chunk) => {
2285
- chunks.push(chunk);
2286
- size += chunk.length;
2287
- });
2288
- stream.on("error", () => resolve9(""));
2289
- stream.on("close", () => resolve9(Buffer.concat(chunks, size).toString("utf8")));
2290
- });
2291
- }
2292
- function parseHead(text) {
2293
- const lines = text.split("\n");
2294
- let header = null;
2295
- let name;
2296
- let firstMessage = "";
2297
- let messageCount = 0;
2298
- for (const line of lines) {
2299
- if (!line) continue;
2300
- let record;
2301
- try {
2302
- record = JSON.parse(line);
2303
- } catch {
2304
- if (!header) continue;
2305
- break;
2306
- }
2307
- if (!header) {
2308
- if (record.type !== "session") return null;
2309
- if (typeof record.id !== "string" || typeof record.cwd !== "string") return null;
2310
- header = {
2311
- type: "session",
2312
- version: Number(record.version ?? 0),
2313
- id: record.id,
2314
- timestamp: String(record.timestamp ?? ""),
2315
- cwd: record.cwd,
2316
- ...typeof record.parentSession === "string" ? { parentSession: record.parentSession } : {}
2317
- };
2318
- continue;
2319
- }
2320
- if (record.type === "session_info" && typeof record.name === "string") {
2321
- name = record.name.trim() || void 0;
2322
- continue;
2323
- }
2324
- if (record.type === "message") {
2325
- messageCount += 1;
2326
- if (!firstMessage) {
2327
- const message = record.message;
2328
- if (message?.role === "user") firstMessage = flattenContent(message.content).slice(0, 200);
2329
- }
2330
- }
2331
- }
2332
- return header ? { header, name, firstMessage, messageCount } : null;
2333
- }
2334
- function flattenContent(content) {
2335
- if (typeof content === "string") return content;
2336
- if (Array.isArray(content)) {
2337
- return content.map((part) => {
2338
- if (typeof part === "string") return part;
2339
- if (part && typeof part === "object" && "text" in part) return String(part.text);
2340
- return "";
2341
- }).join(" ");
2342
- }
2343
- return "";
2344
- }
2345
- async function describeSessionFile(config, path, projectDir, stats) {
2346
- const head = await readHead(path);
2347
- const parsed = parseHead(head);
2348
- if (!parsed) return { ok: false, reason: "not a Pi session file (no valid header)" };
2349
- const lateName = stats.size > HEAD_BYTES ? await readLateSessionName(path) : void 0;
2350
- if (!KNOWN_SESSION_VERSIONS.has(parsed.header.version)) {
2351
- return { ok: false, reason: `unknown session version ${parsed.header.version}` };
2352
- }
2353
- const cwd = parsed.header.cwd || decodeSessionDir(projectDir);
2354
- return {
2355
- ok: true,
2356
- session: {
2357
- path,
2358
- id: parsed.header.id,
2359
- cwd,
2360
- // The late name wins: it is the most recent rename, and the head's is the earliest one.
2361
- ...lateName ?? parsed.name ? { name: lateName ?? parsed.name } : {},
2362
- created: parsed.header.timestamp,
2363
- modifiedMs: stats.mtimeMs,
2364
- bytes: stats.size,
2365
- messageCount: parsed.messageCount,
2366
- firstMessage: parsed.firstMessage,
2367
- cwdExists: directoryExists(cwd),
2368
- scratch: isScratch(config, cwd)
2369
- }
2370
- };
2371
- }
2372
- async function sessionAt(config, requested) {
2373
- if (typeof requested !== "string" || requested.length === 0 || requested.includes("\0")) return void 0;
2374
- let root;
2375
- let real;
2376
- try {
2377
- root = realpathSync2(config.sessionsRoot);
2378
- real = realpathSync2(resolve7(requested));
2379
- } catch {
2380
- return void 0;
2381
- }
2382
- const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(sep4) ? parent : parent + sep4);
2383
- if (!inside(root, real)) return void 0;
2384
- if (!real.endsWith(".jsonl")) return void 0;
2385
- let stats;
2386
- try {
2387
- stats = statSync6(real);
2388
- } catch {
2389
- return void 0;
2390
- }
2391
- if (!stats.isFile()) return void 0;
2392
- const projectDir = basename(dirname2(real));
2393
- const row = await describeSessionFile(config, real, projectDir, stats);
2394
- return row.ok ? row.session : void 0;
2395
- }
2396
- async function discover(config, limit = 500) {
2397
- const sessions = [];
2398
- const skipped = [];
2399
- let projectDirs;
2400
- try {
2401
- projectDirs = readdirSync3(config.sessionsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2402
- } catch {
2403
- return { sessions, total: 0, skipped };
2404
- }
2405
- for (const projectDir of projectDirs) {
2406
- const projectPath = join10(config.sessionsRoot, projectDir);
2407
- let entries;
2408
- try {
2409
- entries = readdirSync3(projectPath, { withFileTypes: true });
2410
- } catch {
2411
- continue;
2412
- }
2413
- for (const entry of entries) {
2414
- if (!entry.name.endsWith(".jsonl")) continue;
2415
- if (!entry.isFile()) continue;
2416
- const path = join10(projectPath, entry.name);
2417
- let stats;
2418
- try {
2419
- stats = statSync6(path);
2420
- } catch {
2421
- continue;
2422
- }
2423
- const row = await describeSessionFile(config, path, projectDir, stats);
2424
- if (!row.ok) {
2425
- skipped.push({ path, reason: row.reason });
2426
- continue;
2427
- }
2428
- sessions.push(row.session);
2429
- }
2430
- }
2431
- sessions.sort((a, b) => b.modifiedMs - a.modifiedMs);
2432
- return { sessions: sessions.slice(0, limit), total: sessions.length, skipped };
2433
- }
2434
- function directoryExists(dir) {
2435
- try {
2436
- return statSync6(dir).isDirectory();
2437
- } catch {
2438
- return false;
2439
- }
2440
- }
2441
-
2442
2703
  // packages/orchestrator/src/static-ui.ts
2443
- import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync7 } from "node:fs";
2444
- import { extname, join as join11, posix, resolve as resolve8, sep as sep5 } from "node:path";
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";
2445
2706
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2446
2707
  var UI_CSP = (
2447
2708
  // `blob:` is REQUIRED and is not covered by `'self'` (issue #111).
@@ -2478,13 +2739,13 @@ var CONTENT_TYPES = {
2478
2739
  };
2479
2740
  function resolveUiDistDir(env = process.env, from = import.meta.url) {
2480
2741
  const explicit = env["PI_CENTRAL_UI_DIST"];
2481
- if (explicit !== void 0 && explicit !== "") return resolve8(explicit);
2742
+ if (explicit !== void 0 && explicit !== "") return resolve9(explicit);
2482
2743
  const own = findOwnPackage(from);
2483
2744
  if (own === void 0) {
2484
2745
  return fileURLToPath3(new URL("../../ui/dist", from));
2485
2746
  }
2486
- const packaged = join11(own.root, "dist", "ui");
2487
- const workspace = join11(own.root, "packages", "ui", "dist");
2747
+ const packaged = join12(own.root, "dist", "ui");
2748
+ const workspace = join12(own.root, "packages", "ui", "dist");
2488
2749
  const fromSource = runningFromSource(from);
2489
2750
  const candidates = fromSource ? [workspace, packaged] : [packaged, workspace];
2490
2751
  for (const candidate of candidates) {
@@ -2496,13 +2757,13 @@ function runningFromSource(from) {
2496
2757
  return /[\\/]packages[\\/][^\\/]+[\\/]src[\\/]/.test(fileURLToPath3(from));
2497
2758
  }
2498
2759
  function isInside3(root, candidate) {
2499
- const rootPath = resolve8(root);
2500
- const target = resolve8(candidate);
2760
+ const rootPath = resolve9(root);
2761
+ const target = resolve9(candidate);
2501
2762
  if (target === rootPath) return true;
2502
2763
  return target.startsWith(rootPath.endsWith(sep5) ? rootPath : rootPath + sep5);
2503
2764
  }
2504
2765
  function resolveStaticPath(root, requestTarget) {
2505
- const rootPath = resolve8(root);
2766
+ const rootPath = resolve9(root);
2506
2767
  const target = requestTarget.split("?")[0].split("#")[0];
2507
2768
  let decoded;
2508
2769
  try {
@@ -2516,16 +2777,16 @@ function resolveStaticPath(root, requestTarget) {
2516
2777
  return { kind: "denied", reason: "the request target contains a '..' segment" };
2517
2778
  }
2518
2779
  const relative = posix.normalize(decoded).replace(/^\/+/, "");
2519
- const candidate = resolve8(rootPath, relative);
2780
+ const candidate = resolve9(rootPath, relative);
2520
2781
  if (!isInside3(rootPath, candidate)) {
2521
2782
  return { kind: "denied", reason: "the request target resolves outside the served UI root" };
2522
2783
  }
2523
2784
  return { kind: "file", path: candidate };
2524
2785
  }
2525
2786
  function createUiRequestHandler(options) {
2526
- const root = resolve8(options.root);
2787
+ const root = resolve9(options.root);
2527
2788
  const csp = options.csp ?? UI_CSP;
2528
- const indexPath = resolve8(root, "index.html");
2789
+ const indexPath = resolve9(root, "index.html");
2529
2790
  return (req, res) => {
2530
2791
  const method = req.method ?? "GET";
2531
2792
  if (method !== "GET" && method !== "HEAD") {
@@ -2612,7 +2873,21 @@ function sendFile(res, method, path, csp) {
2612
2873
  stream.pipe(res);
2613
2874
  }
2614
2875
  function hasBuiltUi(root) {
2615
- return existsSync6(resolve8(root, "index.html"));
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() };
2616
2891
  }
2617
2892
 
2618
2893
  // packages/orchestrator/src/transport.ts
@@ -2698,11 +2973,11 @@ var Transport = class {
2698
2973
  this.#server = server;
2699
2974
  this.#wss = wss;
2700
2975
  wss.on("connection", (socket) => this.#onConnection(socket));
2701
- await new Promise((resolve9, reject) => {
2976
+ await new Promise((resolve10, reject) => {
2702
2977
  server.once("error", reject);
2703
2978
  server.listen(this.#options.port, this.#options.host, () => {
2704
2979
  server.off("error", reject);
2705
- resolve9();
2980
+ resolve10();
2706
2981
  });
2707
2982
  });
2708
2983
  const addr = server.address();
@@ -2750,6 +3025,20 @@ var Transport = class {
2750
3025
  };
2751
3026
  return this.#install;
2752
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
+ }
2753
3042
  async #sendSnapshot(socket) {
2754
3043
  let history = [];
2755
3044
  let historyTotal = 0;
@@ -2767,7 +3056,7 @@ var Transport = class {
2767
3056
  historyTotal,
2768
3057
  home: this.#config.home,
2769
3058
  installationRoot: this.#config.installationRoot,
2770
- install: this.#installFacts()
3059
+ install: this.#snapshotInstallFacts()
2771
3060
  });
2772
3061
  for (const session of this.#supervisor.list()) {
2773
3062
  if (session.state === "stopped" || session.state === "failed") continue;
@@ -2816,7 +3105,11 @@ var Transport = class {
2816
3105
  history: discovered.sessions,
2817
3106
  historyTotal: discovered.total,
2818
3107
  home: this.#config.home,
2819
- 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()
2820
3113
  });
2821
3114
  return;
2822
3115
  }
@@ -3022,14 +3315,14 @@ var Transport = class {
3022
3315
  this.#wss = void 0;
3023
3316
  this.#server = void 0;
3024
3317
  this.#address = void 0;
3025
- if (wss) await new Promise((resolve9) => wss.close(() => resolve9()));
3026
- if (server) await new Promise((resolve9) => server.close(() => resolve9()));
3318
+ if (wss) await new Promise((resolve10) => wss.close(() => resolve10()));
3319
+ if (server) await new Promise((resolve10) => server.close(() => resolve10()));
3027
3320
  }
3028
3321
  };
3029
3322
 
3030
3323
  // packages/orchestrator/src/startup-error.ts
3031
- import { readFileSync as readFileSync6, statSync as statSync8 } from "node:fs";
3032
- import { join as join12 } from "node:path";
3324
+ import { statSync as statSync8 } from "node:fs";
3325
+ import { join as join13 } from "node:path";
3033
3326
  function explainStartupError(error, host, port) {
3034
3327
  const code = error?.code;
3035
3328
  if (code === "EADDRINUSE") {
@@ -3064,15 +3357,15 @@ function explainStartupError(error, host, port) {
3064
3357
  return void 0;
3065
3358
  }
3066
3359
  function describeBuiltUi(uiDir) {
3360
+ let readable = true;
3067
3361
  try {
3068
- const html = readFileSync6(join12(uiDir, "index.html"), "utf8");
3069
- const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
3070
- if (entry === void 0) return "(no entry bundle named in index.html)";
3071
- const { mtime } = statSync8(join12(uiDir, "index.html"));
3072
- return `${entry} (built ${mtime.toISOString().slice(0, 16).replace("T", " ")})`;
3362
+ statSync8(join13(uiDir, "index.html"));
3073
3363
  } catch {
3074
- return "(unreadable)";
3364
+ readable = false;
3075
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", " ")})`;
3076
3369
  }
3077
3370
 
3078
3371
  // packages/orchestrator/src/sandbox-guard.ts
@@ -3210,7 +3503,8 @@ async function main() {
3210
3503
  }
3211
3504
  }
3212
3505
  const config = defaultConfig();
3213
- const supervisor = new Supervisor(config);
3506
+ const supervisor = new Supervisor(config, { rememberOpenSessions: true });
3507
+ const restored = await supervisor.restoreOpenSessions();
3214
3508
  const transport = new Transport(supervisor, config, { port: options.port, uiDir: options.uiDir });
3215
3509
  transport.attach();
3216
3510
  const address = await transport.listen();
@@ -3220,6 +3514,18 @@ async function main() {
3220
3514
  console.log(` default workspace: ${config.workspaceRoot}`);
3221
3515
  console.log(` agent root: ${config.agentRoot}${config.agentRootManaged ? " (managed)" : " (external)"}`);
3222
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
+ }
3223
3529
  console.log(` running from: ${ownPackageRoot()}`);
3224
3530
  console.log(` verify this install: ${VERIFY_INSTALL_HINT} (see README, "Verify what you installed")`);
3225
3531
  if (config.installationRoot === void 0) {
@@ -3265,7 +3571,7 @@ async function main() {
3265
3571
  console.log(`
3266
3572
  ${signal} received; stopping sessions...`);
3267
3573
  await transport.close().catch(() => void 0);
3268
- await supervisor.stopAll().catch(() => void 0);
3574
+ await supervisor.stopAll({ forget: false }).catch(() => void 0);
3269
3575
  console.log("stopped.");
3270
3576
  process.exit(0);
3271
3577
  };