create-pmos 0.8.0 → 0.9.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.
@@ -72,6 +72,7 @@ const fmParse = require('./lib/file-mode-parse'); // canonical file-mode pa
72
72
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
73
73
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
74
74
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
75
+ const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
75
76
  // The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
76
77
  // (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
77
78
  // — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
@@ -738,7 +739,14 @@ function restorePersisted() {
738
739
  // A session whose process is truly gone (tmux absent, or the daemon/process ended) loads as
739
740
  // 'exited' with replayable scrollback and a one-click respawn — honest, never a fake-live pane.
740
741
  let files = [];
741
- try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch { return; }
742
+ try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch { files = []; }
743
+ // Floor the counter on the FILENAMES first, before any parse. Doing it per-record meant a single
744
+ // unreadable file left `seq` low enough to re-issue an id another record already owned — and the
745
+ // next spawn would then overwrite that record's metadata and scrollback.
746
+ for (const f of files) {
747
+ const n = Number(f.replace(/\.json$/, '').split('-')[1]);
748
+ if (Number.isFinite(n) && n >= seq) seq = n + 1;
749
+ }
742
750
  for (const f of files) {
743
751
  try {
744
752
  const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
@@ -765,6 +773,66 @@ function restorePersisted() {
765
773
  const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
766
774
  } catch {}
767
775
  }
776
+ adoptOrphanTmuxSessions();
777
+ }
778
+
779
+ // The registered workspace a directory belongs to — the LONGEST matching root, so a repo opened
780
+ // inside another repo wins over its parent. Null when no registered workspace contains it.
781
+ function workspaceForPath(dir) {
782
+ if (!dir) return null;
783
+ let best = null;
784
+ for (const w of workspaces.values()) {
785
+ if (!insideRoot(w.root, dir)) continue;
786
+ if (!best || w.root.length > best.root.length) best = w;
787
+ }
788
+ return best;
789
+ }
790
+
791
+ // A live pane on our socket that NO restored record claims is still the PM's running agent — the
792
+ // server simply lost its note of it (state/sessions cleared or partly unreadable, a record written
793
+ // by an older build, a metadata write that never landed). Before this, those panes were invisible:
794
+ // the workspace came back apparently empty while the work was still running underneath, which is
795
+ // the "my sessions did not come back" defect. Adopting them puts every live pane back on screen,
796
+ // filed under the workspace its own directory belongs to — and it means an orphan is claimed
797
+ // DELIBERATELY, by the pane it actually is, rather than by accident through an id collision.
798
+ // What a recovered pane should call its agent. A shell (whatever the PM's login shell is) reads
799
+ // `shell`, exactly as a spawned one does; anything else keeps its own command name so the row does
800
+ // not claim to be a shell it is not. An unreadable start command falls back to `shell` — the
801
+ // label that promises the least.
802
+ function adoptedAgent(cmd) {
803
+ const base = path.basename(String(cmd || '').split(/\s+/)[0] || '');
804
+ if (!base) return 'shell';
805
+ if (base === path.basename(SHELL) || /^(sh|bash|zsh|fish|dash|powershell\.exe|cmd\.exe)$/i.test(base)) return 'shell';
806
+ return base;
807
+ }
808
+ function adoptOrphanTmuxSessions() {
809
+ if (!TMUX) return 0;
810
+ const claimed = new Set([...sessions.values()].map((s) => s.tmux).filter(Boolean));
811
+ let adopted = 0;
812
+ for (const t of tmuxList().sort((a, b) => a.created - b.created)) {
813
+ if (!t.name.startsWith('pmos-') || claimed.has(t.name)) continue;
814
+ const dir = t.dir && isDir(t.dir) ? path.resolve(t.dir) : null;
815
+ // Where it belongs: the workspace containing its pane directory; else — because a pane the PM
816
+ // can see must be a pane the PM can reach — that directory is registered as one, exactly as
817
+ // `pmos <path>` would. Only the home workspace is assumed when there is no readable directory.
818
+ const w = workspaceForPath(dir) || (dir && registerWorkspace(dir)) || homeWorkspace();
819
+ if (!w) continue;
820
+ const id = nextSessionId();
821
+ // The agent is the pane's own start command, not a guess: tmux spawned it directly (see
822
+ // createTmuxSession's `-- cmd`), so a `claude` pane comes back labelled `claude`.
823
+ const agent = adoptedAgent(t.cmd);
824
+ const s = {
825
+ id, ws: w.id, root: w.root, name: `recovered ${agent}`, agent,
826
+ origin: null, cwd: dir && insideRoot(w.root, dir) ? dir : w.root,
827
+ state: 'working', pty: null, tmux: t.name, note: '', buf: '',
828
+ cols: 80, rows: 24, clients: new Set(), created: nowISO(), recovered: true,
829
+ };
830
+ sessions.set(id, s);
831
+ attachPty(s, { seed: true }); // its history comes from tmux — ours was never written
832
+ persist(s);
833
+ adopted++;
834
+ }
835
+ return adopted;
768
836
  }
769
837
 
770
838
  // Broadcast to EVERY connected client exactly once (not per-session — a fresh client that
@@ -853,15 +921,22 @@ function sessionList() {
853
921
  return [...sessions.values()].map((s) => {
854
922
  const d = harness.deriveState(s.state, s.lastOutputAt, now);
855
923
  const h = harnessOf(s);
924
+ const hx = transcriptHarness(s);
856
925
  return {
857
926
  id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
858
927
  cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
859
- // derived presentation state: working | quiet | exited. `shell`-ness stays on `agent`,
860
- // because it is orthogonal to state; `failed` does not exist (no observable for it).
861
- shown: d.state, quietMs: d.quietMs, lastOutputAt: s.lastOutputAt || null,
928
+ // Derived presentation state, JOINED from both sources: the cadence clock above and the
929
+ // transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
930
+ // needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
931
+ // `failed` does not exist (no observable for it). The client re-derives this from the SAME
932
+ // module — see /lib/session-state.js below — so the two can never hold different opinions.
933
+ shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
934
+ lastOutputAt: s.lastOutputAt || null,
862
935
  lastWords: h.lastWords,
863
- // the harness proper — structured record, not rendered bytes
864
- hx: transcriptHarness(s),
936
+ // the harness proper — structured record, not rendered bytes. Resolved ONCE above and
937
+ // reused: it also feeds the join, and calling it twice per session per broadcast would
938
+ // double the transcript work the TX_REREAD_MS floor exists to bound.
939
+ hx,
865
940
  missing: h.missing,
866
941
  };
867
942
  });
@@ -971,7 +1046,24 @@ const TMUX = commandExists('tmux') ? 'tmux' : null;
971
1046
  const TMUX_SOCK = process.env.PMOS_TMUX_SOCK || 'pmos'; // private socket; never the user's own
972
1047
  const TMUX_CONF = path.join(STATE, 'tmux.conf');
973
1048
  let shuttingDown = false; // set on SIGTERM/SIGINT — suppress reattach
1049
+ // The name a session written BEFORE unique names carries. Kept only so those sessions still
1050
+ // re-attach after an upgrade; nothing new is ever named this way (see freshTmuxName).
974
1051
  function tmuxName(id) { return 'pmos-' + id; }
1052
+ // A NEW session's tmux name must be unique on the socket for as long as any pane can outlive the
1053
+ // server that made it — and `pmos-sess-<n>` was not. The counter restarts with the process, so
1054
+ // once a generation's metadata went missing (state/sessions cleared, a partial restore) the next
1055
+ // server handed a fresh session the name of a pane still running from the last one, and
1056
+ // ensureTmuxSession's "already alive → leave it" ADOPTED that stranger's pane. The PM asked for a
1057
+ // session in one repo and got a live shell sitting in another — the wrong-directory defect. The
1058
+ // random suffix makes the name unforgeable by a later generation, so an orphan can only ever be
1059
+ // adopted deliberately (adoptOrphanTmuxSessions), never by an id collision.
1060
+ function freshTmuxName(id) {
1061
+ for (let i = 0; i < 50; i++) {
1062
+ const n = `pmos-${id}-${crypto.randomBytes(4).toString('hex')}`;
1063
+ if (!tmuxHasSession(n)) return n;
1064
+ }
1065
+ return `pmos-${id}-${process.pid.toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
1066
+ }
975
1067
  function writeTmuxConf() {
976
1068
  if (!TMUX) return;
977
1069
  const conf = [
@@ -992,12 +1084,38 @@ function writeTmuxConf() {
992
1084
  function tmuxCtl(args) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: sessionEnv(), encoding: 'utf8' }); }
993
1085
  function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
994
1086
  function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
995
- function ensureTmuxSession(name, cmd, workdir, cols, rows) {
996
- if (tmuxHasSession(name)) return true; // already alive (a reattach) — leave it
1087
+ // Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
1088
+ // than adopting it: this path only ever runs for a brand-new session, so an existing pane of that
1089
+ // name is someone else's process, in someone else's directory. Silently reusing it is how a new
1090
+ // session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
1091
+ // this is the floor that keeps it impossible even if it were not.
1092
+ function createTmuxSession(name, cmd, workdir, cols, rows) {
1093
+ if (tmuxHasSession(name)) return false;
997
1094
  const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
998
1095
  '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
999
1096
  return r.status === 0;
1000
1097
  }
1098
+ // Every session alive on our private socket, with the directory its pane is actually sitting in.
1099
+ // Deliberately one lookup PER FIELD rather than one packed -F line: tmux rewrites whitespace
1100
+ // inside a format string (a tab separator came back as `_`), which silently glued the fields into
1101
+ // a single unusable name. One field per call has no separator to get wrong, and this runs only at
1102
+ // startup over the handful of sessions on our own socket. [] when tmux is absent or not running.
1103
+ function tmuxField(name, fmt) {
1104
+ const r = tmuxCtl(['display-message', '-p', '-t', name, fmt]);
1105
+ return r && r.status === 0 ? String(r.stdout || '').trim() : '';
1106
+ }
1107
+ function tmuxList() {
1108
+ if (!TMUX) return [];
1109
+ const r = tmuxCtl(['list-sessions', '-F', '#{session_name}']);
1110
+ if (!r || r.status !== 0) return [];
1111
+ return String(r.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)
1112
+ .map((name) => ({
1113
+ name,
1114
+ dir: tmuxField(name, '#{pane_current_path}'),
1115
+ created: Number(tmuxField(name, '#{session_created}')) || 0,
1116
+ cmd: tmuxField(name, '#{pane_start_command}'),
1117
+ }));
1118
+ }
1001
1119
  // The pane id (%0, %1, …) the control client must address for input. One cheap lookup per attach;
1002
1120
  // cached on the session because a pane id is stable for the life of the tmux session.
1003
1121
  function tmuxPaneId(s, name) {
@@ -1030,7 +1148,11 @@ function seedFromPaneHistory(s, name) {
1030
1148
  // pane's raw bytes as %output, which we forward verbatim — the same stream shape the direct-PTY
1031
1149
  // fallback produces, so xterm.js keeps its own 5000-line scrollback. See lib/tmux-control.js.
1032
1150
  function attachPty(s, { seed = false } = {}) {
1033
- const name = tmuxName(s.id);
1151
+ // The session's OWN recorded name, never one recomputed from its id — since freshTmuxName the
1152
+ // two no longer coincide, and re-deriving would attach to whatever else happened to hold the
1153
+ // id-shaped name. tmuxName() is the fallback for a session persisted before unique names.
1154
+ const name = s.tmux || tmuxName(s.id);
1155
+ s.tmux = name;
1034
1156
  const startedMs = Date.now();
1035
1157
  let p;
1036
1158
  try {
@@ -1132,8 +1254,26 @@ function exitedSession(base, workdir, why) {
1132
1254
  sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
1133
1255
  }
1134
1256
 
1257
+ // Live (non-exited) sessions belonging to a workspace. Used to decide whether isolating a branch
1258
+ // would actually protect anyone, and to refuse removing a worktree somebody is working in.
1259
+ function liveSessionCount(wsIdent) {
1260
+ let n = 0;
1261
+ for (const s of sessions.values()) if (s.ws === wsIdent && s.state !== 'exited') n++;
1262
+ return n;
1263
+ }
1264
+
1265
+ // The next unused session id. `seq` is a per-process counter, so it can only be trusted once it
1266
+ // has been floored by what is already on disk and in memory (see restorePersisted) — this loop is
1267
+ // the last guard: a fresh session must never take an id another session's record already owns,
1268
+ // because persist() would then overwrite that record's scrollback.
1269
+ function nextSessionId() {
1270
+ let id = 'sess-' + (seq++);
1271
+ while (sessions.has(id)) id = 'sess-' + (seq++);
1272
+ return id;
1273
+ }
1274
+
1135
1275
  function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1136
- const id = 'sess-' + (seq++);
1276
+ const id = nextSessionId();
1137
1277
  const w = getWs(ws);
1138
1278
  const root = w ? w.root : SELF_ROOT;
1139
1279
  const isShell = agent === 'shell';
@@ -1156,11 +1296,11 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1156
1296
 
1157
1297
  // ── the tmux path: the process outlives this server, and we re-attach to it live (R7) ──
1158
1298
  if (TMUX) {
1159
- const tname = tmuxName(id);
1299
+ const tname = freshTmuxName(id);
1160
1300
  const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
1161
1301
  note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
1162
1302
  sessions.set(id, s);
1163
- if (!ensureTmuxSession(tname, cmd, workdir, 80, 24)) {
1303
+ if (!createTmuxSession(tname, cmd, workdir, 80, 24)) {
1164
1304
  const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
1165
1305
  s.state = 'exited'; s.tmux = null; s.note = why;
1166
1306
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
@@ -1202,36 +1342,143 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
1202
1342
  return s;
1203
1343
  }
1204
1344
 
1205
- // New session, optionally on a chosen/created branch. The working tree is SHARED across a repo's
1206
- // sessions, so the checkout is repo-wide — the UI says so. A FAILED checkout must never spawn a
1207
- // session on the wrong branch (rubric hard floor): we return {ok:false} and spawn nothing. Kept as
1208
- // its own function so the checkout-then-maybe-spawn contract is unit-testable without a WS client.
1209
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch }) {
1345
+ // New session, optionally on a chosen/created branch. Three paths, and which one runs is the whole
1346
+ // point of this function:
1347
+ //
1348
+ // isolate — put the branch in its OWN worktree, register that directory as a workspace, and
1349
+ // start the session there. Every already-running session stays exactly where it
1350
+ // was. This is the default the UI offers for "+ new branch", because the shared
1351
+ // checkout moving every session was the single worst thing about this surface.
1352
+ // checkout — the shared-tree path: move THIS repo (and so every session in it) to the branch.
1353
+ // Still offered, still honest about its blast radius, no longer the only option.
1354
+ // no git here — a workspace that is not a repo has no branch to be on. A stale picker or an old
1355
+ // client must not be able to BLOCK a session over it: we spawn, and return a
1356
+ // notice saying the branch was ignored. Never silently, never fatally.
1357
+ //
1358
+ // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
1359
+ // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
1360
+ // git-then-maybe-spawn contract is unit-testable without a WS client.
1361
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
1362
+ const w = getWs(ws);
1363
+ if (!w) return { ok: false, error: 'no workspace' };
1364
+ let targetWs = w.id;
1365
+ let notice = null;
1366
+ // Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
1367
+ // a plain checkout costs nothing and leaves no folder behind — which is the difference between
1368
+ // "a worktree when it earns its keep" and a directory per branch forever. The client sends what
1369
+ // the PM actually saw; this default only decides for a caller that expressed no preference (an
1370
+ // older client, a scheduled run).
1371
+ if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
1372
+
1210
1373
  if (branch) {
1211
- const w = getWs(ws);
1212
- const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
1213
- if (!r.ok) return { ok: false, error: r.error };
1214
- broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
1374
+ if (!gitInfo.isRepo(w.root)) {
1375
+ notice = `“${w.name}” is not a git repository — the session started here, with no branch applied`;
1376
+ } else if (isolate) {
1377
+ const r = gitInfo.addWorktree(w.root, branch, { create: !!createBranch });
1378
+ if (!r.ok) return { ok: false, error: r.error };
1379
+ // A worktree is created at the REPO's top. If this workspace was a subdirectory of the repo
1380
+ // (repo/packages/app — the ordinary shape for a tenant product repo added by path), the PM
1381
+ // should land in the matching subdirectory of the new tree, not at a root they never chose.
1382
+ const info = gitInfo.branchInfo(w.root);
1383
+ const mirrored = info.ok && info.subdir ? path.join(r.dir, info.subdir) : r.dir;
1384
+ const dir = isDir(mirrored) ? mirrored : r.dir;
1385
+ const nw = registerWorkspace(dir, `${w.name} ⑂ ${branch}`);
1386
+ if (!nw) return { ok: false, error: `worktree created at ${r.dir} but could not be registered as a workspace` };
1387
+ targetWs = nw.id;
1388
+ notice = r.reused
1389
+ ? `reusing the existing worktree for “${branch}” at ${r.dir}`
1390
+ : `“${branch}” checked out in its own folder — ${r.dir}. Sessions already running are untouched.`;
1391
+ broadcast({ t: 'branch', ws: nw.id, ...gitInfo.branchInfo(nw.root) });
1392
+ } else {
1393
+ const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
1394
+ if (!r.ok) return { ok: false, error: r.error };
1395
+ broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
1396
+ }
1215
1397
  }
1216
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws });
1217
- return { ok: true, session: s };
1398
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
1399
+ return { ok: true, session: s, notice };
1400
+ }
1401
+
1402
+ // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
1403
+ // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
1404
+ // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
1405
+ // — and without a delete ever being a guess. Three hazards block a removal, and every one is
1406
+ // reported by name: live sessions (ours to know), uncommitted changes and commits held nowhere
1407
+ // else (git's to know, see gitInfo.worktreeSafety).
1408
+ function worktreeReport(w) {
1409
+ const info = gitInfo.branchInfo(w.root);
1410
+ if (!info.ok) return { ok: false, code: info.code, error: info.error };
1411
+ const trees = info.worktrees || [];
1412
+ const rows = trees.map((t, i) => {
1413
+ const main = i === 0;
1414
+ const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
1415
+ const live = owner ? liveSessionCount(owner.id) : 0;
1416
+ // the main worktree is the repo itself — never removable from here, and saying so beats an
1417
+ // absent button the PM has to reason about
1418
+ const gone = !main && !isDir(t.dir);
1419
+ const safety = main ? { removable: false, reasons: ['the repository’s own working tree'] }
1420
+ // deleted by hand already: nothing left to protect, only git bookkeeping to clear. Asking
1421
+ // `git status` about a folder that isn't there would fail and read as "unsafe".
1422
+ : gone ? { removable: true, reasons: ['the folder is already gone — this clears git’s record of it'] }
1423
+ : gitInfo.worktreeSafety(t.dir, t.branch);
1424
+ const reasons = safety.reasons.slice();
1425
+ if (!main && live > 0) reasons.unshift(`${live} session${live === 1 ? '' : 's'} still running here`);
1426
+ return { dir: t.dir, branch: t.branch, main, missing: gone,
1427
+ ws: owner ? owner.id : null, sessions: live,
1428
+ removable: !main && live === 0 && safety.removable, reasons };
1429
+ });
1430
+ return { ok: true, repoRoot: info.repoRoot, worktrees: rows };
1218
1431
  }
1219
1432
 
1220
- // ── Scratchpad: per-workspace staging text (R4). Local-only, one file per workspace keyed by the
1221
- // workspace's hashed id (never a caller-supplied path), capped so it can't be used as a blob store.
1433
+ // ── Scratchpad: PER-SESSION staging text (R4). One file per terminal tab, because that is the
1434
+ // unit the PM stages against: the text is the next thing to type into THAT agent, and a single
1435
+ // per-workspace pad meant every tab showed — and overwrote — the same buffer. Local-only, keyed by
1436
+ // the workspace's hashed id and the server-issued session id (never a caller-supplied path),
1437
+ // capped so it can't be used as a blob store.
1222
1438
  const SCRATCH_MAX = 200_000;
1223
- function scratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
1224
- function readScratchpad(w) {
1225
- const f = scratchpadPath(w);
1439
+ // The pre-session file the workspace used to share. Still read, once: the first tab to open its
1440
+ // pad in that workspace CLAIMS it, so text staged before this change is not silently dropped.
1441
+ function legacyScratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
1442
+ // A session id is server-issued (`sess-<n>`), never echoed from the client — but this is the one
1443
+ // place a name reaches the filesystem, so it is checked rather than trusted.
1444
+ function scratchpadPath(w, sessionId) {
1445
+ if (!w) return null;
1446
+ if (!sessionId) return legacyScratchpadPath(w);
1447
+ const sid = String(sessionId);
1448
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(sid) || sid === '.' || sid === '..') return null;
1449
+ return path.join(SCRATCH_DIR, w.id, sid + '.txt');
1450
+ }
1451
+ function readScratchpad(w, sessionId) {
1452
+ const f = scratchpadPath(w, sessionId);
1226
1453
  if (!f) return '';
1227
- try { return fs.readFileSync(f, 'utf8'); } catch { return ''; }
1454
+ try { return fs.readFileSync(f, 'utf8'); } catch {}
1455
+ // Nothing staged for this tab yet — inherit the workspace's pre-session pad if one survives,
1456
+ // by MOVING it, so exactly one tab gets it and it is never handed out twice.
1457
+ if (sessionId) {
1458
+ const legacy = legacyScratchpadPath(w);
1459
+ try {
1460
+ const text = fs.readFileSync(legacy, 'utf8');
1461
+ fs.mkdirSync(path.dirname(f), { recursive: true });
1462
+ fs.renameSync(legacy, f);
1463
+ return text;
1464
+ } catch {}
1465
+ }
1466
+ return '';
1228
1467
  }
1229
- function writeScratchpad(w, text) {
1230
- const f = scratchpadPath(w);
1231
- if (!f) throw new Error('no workspace');
1232
- fs.mkdirSync(SCRATCH_DIR, { recursive: true });
1468
+ function writeScratchpad(w, sessionId, text) {
1469
+ const f = scratchpadPath(w, sessionId);
1470
+ if (!f) throw new Error('no scratchpad for that session');
1471
+ fs.mkdirSync(path.dirname(f), { recursive: true });
1233
1472
  fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
1234
1473
  }
1474
+ // A tab that is gone takes its staging text with it — the pad belongs to the session, not to a
1475
+ // slot a later session could inherit by reusing its id.
1476
+ function forgetScratchpad(s) {
1477
+ if (!s) return;
1478
+ const w = workspaces.get(s.ws);
1479
+ const f = scratchpadPath(w, s.id);
1480
+ if (f) { try { fs.unlinkSync(f); } catch {} }
1481
+ }
1235
1482
 
1236
1483
  // ── Layouts: per-workspace arrangements (R7). Same shape as the scratchpad above — one file per
1237
1484
  // workspace keyed by the workspace's hashed id, never a caller-supplied path, capped so it can't
@@ -1262,6 +1509,7 @@ function removeSession(id) {
1262
1509
  const s = sessions.get(id);
1263
1510
  if (!s || s.pty) return false;
1264
1511
  if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
1512
+ forgetScratchpad(s); // the tab's staging text goes with the tab
1265
1513
  sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
1266
1514
  return true;
1267
1515
  }
@@ -1430,6 +1678,9 @@ function buildConsole(name, w, root) {
1430
1678
  if (name === 'needs') return needsResult(w);
1431
1679
  if (name === 'metrics') return consoleState.metrics({ stateDir, snapshotDir });
1432
1680
  if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
1681
+ // The workflow ladder reads the OPENED workspace's repo (records + git), not the state dir — an
1682
+ // initiative's position is a property of the repo it lives in, snapshot or no snapshot.
1683
+ if (name === 'workflow') return consoleState.workflow({ root: w ? w.root : root });
1433
1684
  return { ok: false, error: 'unknown console surface: ' + name };
1434
1685
  }
1435
1686
 
@@ -1593,6 +1844,7 @@ async function handleRequest(req, res) {
1593
1844
  if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
1594
1845
  if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
1595
1846
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
1847
+ if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
1596
1848
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
1597
1849
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
1598
1850
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
@@ -1604,6 +1856,12 @@ async function handleRequest(req, res) {
1604
1856
  if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
1605
1857
  if (p === '/vendor/addon-fit.js') return serveFile(res, path.join(XROOT, 'addon-fit', 'lib', 'addon-fit.js'));
1606
1858
  if (p === '/vendor/addon-web-links.js') return serveFile(res, path.join(XROOT, 'addon-web-links', 'lib', 'addon-web-links.js'));
1859
+ // The design system's faces (D38) are VENDORED, not linked from a CDN: the workspace is a local
1860
+ // companion app that has to render correctly with no network at all. Plex Mono ships 400 + 700
1861
+ // because the terminal renders bold — with only the regular face the browser synthesises it.
1862
+ if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
1863
+ if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
1864
+ if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
1607
1865
 
1608
1866
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
1609
1867
  if (p === '/api/workspaces' && req.method === 'POST') {
@@ -1626,6 +1884,28 @@ async function handleRequest(req, res) {
1626
1884
  const ok = forgetWorkspace(body.id);
1627
1885
  return json(res, { ok, error: ok ? undefined : 'unknown workspace, the home workspace, or it still owns sessions' }, ok ? 200 : 409);
1628
1886
  }
1887
+ if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
1888
+ // Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
1889
+ // list of THIS repo's linked worktrees before anything is removed — the main tree is
1890
+ // unreachable by construction, and a tree with running sessions, uncommitted changes, or
1891
+ // commits held nowhere else is refused with the reason, not silently kept.
1892
+ let body = {};
1893
+ try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
1894
+ const w = getWs(body.ws);
1895
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
1896
+ const row = (worktreeReport(w).worktrees || [])
1897
+ .find((t) => path.resolve(t.dir) === path.resolve(String(body.dir || '')));
1898
+ if (!row) return json(res, { ok: false, error: 'not a linked worktree of this repository' }, 400);
1899
+ if (row.sessions > 0) return json(res, { ok: false, error: `kept — ${row.reasons[0]}` }, 409);
1900
+ const r = gitInfo.removeWorktree(w.root, row.dir);
1901
+ if (!r.ok) return json(res, r, 409);
1902
+ // the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
1903
+ // refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
1904
+ const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
1905
+ if (owner) forgetWorkspace(owner.id);
1906
+ broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
1907
+ return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned });
1908
+ }
1629
1909
  if (p === '/api/pick-folder' && req.method === 'POST') {
1630
1910
  let body = {};
1631
1911
  try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
@@ -1771,20 +2051,36 @@ async function handleRequest(req, res) {
1771
2051
  if (!w) return json(res, { ok: false, error: 'no workspace' });
1772
2052
  return json(res, gitInfo.branchInfo(w.root));
1773
2053
  }
2054
+ if (p === '/api/git/worktrees') {
2055
+ // Every working tree attached to this workspace's repo, each with the reasons it can or cannot
2056
+ // be removed. A worktree the workspace made is a folder the PM has to live with, so the app
2057
+ // that creates them is the app that has to show them.
2058
+ const w = getWs(url.searchParams.get('ws'));
2059
+ if (!w) return json(res, { ok: false, error: 'no workspace' });
2060
+ return json(res, worktreeReport(w));
2061
+ }
2062
+ // The pad belongs to a SESSION when one is named — and the workspace then comes from that
2063
+ // session, not from the caller, so a stale `ws` can never file one tab's text under another
2064
+ // repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
1774
2065
  if (p === '/api/scratchpad' && req.method === 'POST') {
1775
2066
  let body = {};
1776
2067
  try { body = JSON.parse((await readBody(req)) || '{}'); }
1777
2068
  catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
1778
- const w = getWs(body.ws);
2069
+ const s = body.session ? sessions.get(String(body.session)) : null;
2070
+ if (body.session && !s) return json(res, { ok: false, error: 'no such session' }, 404);
2071
+ const w = s ? workspaces.get(s.ws) : getWs(body.ws);
1779
2072
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1780
- try { writeScratchpad(w, body.text); }
2073
+ try { writeScratchpad(w, s ? s.id : null, body.text); }
1781
2074
  catch (e) { warnWriteFail('scratchpad', e); return json(res, { ok: false, error: 'could not save scratchpad' }, 500); }
1782
- return json(res, { ok: true });
2075
+ return json(res, { ok: true, session: s ? s.id : null });
1783
2076
  }
1784
2077
  if (p === '/api/scratchpad') {
1785
- const w = getWs(url.searchParams.get('ws'));
2078
+ const sid = url.searchParams.get('session');
2079
+ const s = sid ? sessions.get(sid) : null;
2080
+ if (sid && !s) return json(res, { ok: false, error: 'no such session' }, 404);
2081
+ const w = s ? workspaces.get(s.ws) : getWs(url.searchParams.get('ws'));
1786
2082
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
1787
- return json(res, { ok: true, text: readScratchpad(w) });
2083
+ return json(res, { ok: true, session: s ? s.id : null, text: readScratchpad(w, s ? s.id : null) });
1788
2084
  }
1789
2085
  if (p === '/api/layouts' && req.method === 'POST') {
1790
2086
  let body = {};
@@ -2073,8 +2369,11 @@ wss.on('connection', (ws) => {
2073
2369
  } else if (m.t === 'resize') {
2074
2370
  const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
2075
2371
  } else if (m.t === 'new') {
2076
- const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch });
2372
+ const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate });
2077
2373
  if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
2374
+ // a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
2375
+ // non-repo) — said out loud, never swallowed
2376
+ if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
2078
2377
  ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
2079
2378
  } else if (m.t === 'respawn') {
2080
2379
  const old = sessions.get(m.id); if (!old || old.pty) return;