create-pmos 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/bin/create-pmos.js +69 -11
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/bin/pmos.js +9 -2
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/related-docs.js +107 -0
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/package.json +1 -1
- package/workspace/public/app.js +434 -61
- package/workspace/public/index.html +24 -3
- package/workspace/public/styles.css +909 -572
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +566 -39
package/workspace/server.js
CHANGED
|
@@ -71,7 +71,9 @@ const consoleState = require('./lib/console-state'); // read-only console surf
|
|
|
71
71
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
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
|
+
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
74
75
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
76
|
+
const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
|
|
75
77
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
76
78
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
77
79
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -301,6 +303,27 @@ function libraryTree(root) {
|
|
|
301
303
|
if (loose.length) roots.push({ name: '(root)', path: '', dirs: [], docs: loose });
|
|
302
304
|
return roots;
|
|
303
305
|
}
|
|
306
|
+
// Every markdown document under the root, as repo-relative paths. Used by alignment to decide which
|
|
307
|
+
// documents a change might affect (R6) — it needs a flat list, and libraryTree returns a nested shape
|
|
308
|
+
// for the library lens. Bounded: a repository is not an excuse to read forever.
|
|
309
|
+
function markdownDocs(root, limit = 800) {
|
|
310
|
+
const out = [];
|
|
311
|
+
const walk = (dir, rel, depth) => {
|
|
312
|
+
if (out.length >= limit || depth > 8) return;
|
|
313
|
+
let ents = [];
|
|
314
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
315
|
+
for (const e of ents) {
|
|
316
|
+
if (out.length >= limit) return;
|
|
317
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
318
|
+
const full = path.join(dir, e.name);
|
|
319
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
320
|
+
if (e.isDirectory()) walk(full, r, depth + 1);
|
|
321
|
+
else if (preview.previewKind(e.name) === 'markdown') out.push(r);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
walk(root, '', 0);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
304
327
|
// every top-level file (md + non-md), so the folder lens shows the real root, not a curated subset
|
|
305
328
|
function looseFiles(root) {
|
|
306
329
|
let ents = [];
|
|
@@ -738,7 +761,14 @@ function restorePersisted() {
|
|
|
738
761
|
// A session whose process is truly gone (tmux absent, or the daemon/process ended) loads as
|
|
739
762
|
// 'exited' with replayable scrollback and a one-click respawn — honest, never a fake-live pane.
|
|
740
763
|
let files = [];
|
|
741
|
-
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch {
|
|
764
|
+
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch { files = []; }
|
|
765
|
+
// Floor the counter on the FILENAMES first, before any parse. Doing it per-record meant a single
|
|
766
|
+
// unreadable file left `seq` low enough to re-issue an id another record already owned — and the
|
|
767
|
+
// next spawn would then overwrite that record's metadata and scrollback.
|
|
768
|
+
for (const f of files) {
|
|
769
|
+
const n = Number(f.replace(/\.json$/, '').split('-')[1]);
|
|
770
|
+
if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
771
|
+
}
|
|
742
772
|
for (const f of files) {
|
|
743
773
|
try {
|
|
744
774
|
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
@@ -765,6 +795,66 @@ function restorePersisted() {
|
|
|
765
795
|
const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
766
796
|
} catch {}
|
|
767
797
|
}
|
|
798
|
+
adoptOrphanTmuxSessions();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// The registered workspace a directory belongs to — the LONGEST matching root, so a repo opened
|
|
802
|
+
// inside another repo wins over its parent. Null when no registered workspace contains it.
|
|
803
|
+
function workspaceForPath(dir) {
|
|
804
|
+
if (!dir) return null;
|
|
805
|
+
let best = null;
|
|
806
|
+
for (const w of workspaces.values()) {
|
|
807
|
+
if (!insideRoot(w.root, dir)) continue;
|
|
808
|
+
if (!best || w.root.length > best.root.length) best = w;
|
|
809
|
+
}
|
|
810
|
+
return best;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// A live pane on our socket that NO restored record claims is still the PM's running agent — the
|
|
814
|
+
// server simply lost its note of it (state/sessions cleared or partly unreadable, a record written
|
|
815
|
+
// by an older build, a metadata write that never landed). Before this, those panes were invisible:
|
|
816
|
+
// the workspace came back apparently empty while the work was still running underneath, which is
|
|
817
|
+
// the "my sessions did not come back" defect. Adopting them puts every live pane back on screen,
|
|
818
|
+
// filed under the workspace its own directory belongs to — and it means an orphan is claimed
|
|
819
|
+
// DELIBERATELY, by the pane it actually is, rather than by accident through an id collision.
|
|
820
|
+
// What a recovered pane should call its agent. A shell (whatever the PM's login shell is) reads
|
|
821
|
+
// `shell`, exactly as a spawned one does; anything else keeps its own command name so the row does
|
|
822
|
+
// not claim to be a shell it is not. An unreadable start command falls back to `shell` — the
|
|
823
|
+
// label that promises the least.
|
|
824
|
+
function adoptedAgent(cmd) {
|
|
825
|
+
const base = path.basename(String(cmd || '').split(/\s+/)[0] || '');
|
|
826
|
+
if (!base) return 'shell';
|
|
827
|
+
if (base === path.basename(SHELL) || /^(sh|bash|zsh|fish|dash|powershell\.exe|cmd\.exe)$/i.test(base)) return 'shell';
|
|
828
|
+
return base;
|
|
829
|
+
}
|
|
830
|
+
function adoptOrphanTmuxSessions() {
|
|
831
|
+
if (!TMUX) return 0;
|
|
832
|
+
const claimed = new Set([...sessions.values()].map((s) => s.tmux).filter(Boolean));
|
|
833
|
+
let adopted = 0;
|
|
834
|
+
for (const t of tmuxList().sort((a, b) => a.created - b.created)) {
|
|
835
|
+
if (!t.name.startsWith('pmos-') || claimed.has(t.name)) continue;
|
|
836
|
+
const dir = t.dir && isDir(t.dir) ? path.resolve(t.dir) : null;
|
|
837
|
+
// Where it belongs: the workspace containing its pane directory; else — because a pane the PM
|
|
838
|
+
// can see must be a pane the PM can reach — that directory is registered as one, exactly as
|
|
839
|
+
// `pmos <path>` would. Only the home workspace is assumed when there is no readable directory.
|
|
840
|
+
const w = workspaceForPath(dir) || (dir && registerWorkspace(dir)) || homeWorkspace();
|
|
841
|
+
if (!w) continue;
|
|
842
|
+
const id = nextSessionId();
|
|
843
|
+
// The agent is the pane's own start command, not a guess: tmux spawned it directly (see
|
|
844
|
+
// createTmuxSession's `-- cmd`), so a `claude` pane comes back labelled `claude`.
|
|
845
|
+
const agent = adoptedAgent(t.cmd);
|
|
846
|
+
const s = {
|
|
847
|
+
id, ws: w.id, root: w.root, name: `recovered ${agent}`, agent,
|
|
848
|
+
origin: null, cwd: dir && insideRoot(w.root, dir) ? dir : w.root,
|
|
849
|
+
state: 'working', pty: null, tmux: t.name, note: '', buf: '',
|
|
850
|
+
cols: 80, rows: 24, clients: new Set(), created: nowISO(), recovered: true,
|
|
851
|
+
};
|
|
852
|
+
sessions.set(id, s);
|
|
853
|
+
attachPty(s, { seed: true }); // its history comes from tmux — ours was never written
|
|
854
|
+
persist(s);
|
|
855
|
+
adopted++;
|
|
856
|
+
}
|
|
857
|
+
return adopted;
|
|
768
858
|
}
|
|
769
859
|
|
|
770
860
|
// Broadcast to EVERY connected client exactly once (not per-session — a fresh client that
|
|
@@ -853,15 +943,22 @@ function sessionList() {
|
|
|
853
943
|
return [...sessions.values()].map((s) => {
|
|
854
944
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
855
945
|
const h = harnessOf(s);
|
|
946
|
+
const hx = transcriptHarness(s);
|
|
856
947
|
return {
|
|
857
948
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
858
949
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
|
|
950
|
+
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
951
|
+
// transcript's answer to whose turn it is (lib/session-state). working | quiet | exited |
|
|
952
|
+
// needs | blocked. `shell`-ness stays on `agent`, because it is orthogonal to state;
|
|
953
|
+
// `failed` does not exist (no observable for it). The client re-derives this from the SAME
|
|
954
|
+
// module — see /lib/session-state.js below — so the two can never hold different opinions.
|
|
955
|
+
shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
|
|
956
|
+
lastOutputAt: s.lastOutputAt || null,
|
|
862
957
|
lastWords: h.lastWords,
|
|
863
|
-
// the harness proper — structured record, not rendered bytes
|
|
864
|
-
|
|
958
|
+
// the harness proper — structured record, not rendered bytes. Resolved ONCE above and
|
|
959
|
+
// reused: it also feeds the join, and calling it twice per session per broadcast would
|
|
960
|
+
// double the transcript work the TX_REREAD_MS floor exists to bound.
|
|
961
|
+
hx,
|
|
865
962
|
missing: h.missing,
|
|
866
963
|
};
|
|
867
964
|
});
|
|
@@ -971,7 +1068,24 @@ const TMUX = commandExists('tmux') ? 'tmux' : null;
|
|
|
971
1068
|
const TMUX_SOCK = process.env.PMOS_TMUX_SOCK || 'pmos'; // private socket; never the user's own
|
|
972
1069
|
const TMUX_CONF = path.join(STATE, 'tmux.conf');
|
|
973
1070
|
let shuttingDown = false; // set on SIGTERM/SIGINT — suppress reattach
|
|
1071
|
+
// The name a session written BEFORE unique names carries. Kept only so those sessions still
|
|
1072
|
+
// re-attach after an upgrade; nothing new is ever named this way (see freshTmuxName).
|
|
974
1073
|
function tmuxName(id) { return 'pmos-' + id; }
|
|
1074
|
+
// A NEW session's tmux name must be unique on the socket for as long as any pane can outlive the
|
|
1075
|
+
// server that made it — and `pmos-sess-<n>` was not. The counter restarts with the process, so
|
|
1076
|
+
// once a generation's metadata went missing (state/sessions cleared, a partial restore) the next
|
|
1077
|
+
// server handed a fresh session the name of a pane still running from the last one, and
|
|
1078
|
+
// ensureTmuxSession's "already alive → leave it" ADOPTED that stranger's pane. The PM asked for a
|
|
1079
|
+
// session in one repo and got a live shell sitting in another — the wrong-directory defect. The
|
|
1080
|
+
// random suffix makes the name unforgeable by a later generation, so an orphan can only ever be
|
|
1081
|
+
// adopted deliberately (adoptOrphanTmuxSessions), never by an id collision.
|
|
1082
|
+
function freshTmuxName(id) {
|
|
1083
|
+
for (let i = 0; i < 50; i++) {
|
|
1084
|
+
const n = `pmos-${id}-${crypto.randomBytes(4).toString('hex')}`;
|
|
1085
|
+
if (!tmuxHasSession(n)) return n;
|
|
1086
|
+
}
|
|
1087
|
+
return `pmos-${id}-${process.pid.toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
1088
|
+
}
|
|
975
1089
|
function writeTmuxConf() {
|
|
976
1090
|
if (!TMUX) return;
|
|
977
1091
|
const conf = [
|
|
@@ -992,12 +1106,38 @@ function writeTmuxConf() {
|
|
|
992
1106
|
function tmuxCtl(args) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: sessionEnv(), encoding: 'utf8' }); }
|
|
993
1107
|
function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
|
|
994
1108
|
function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
|
|
995
|
-
|
|
996
|
-
|
|
1109
|
+
// Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
|
|
1110
|
+
// than adopting it: this path only ever runs for a brand-new session, so an existing pane of that
|
|
1111
|
+
// name is someone else's process, in someone else's directory. Silently reusing it is how a new
|
|
1112
|
+
// session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
|
|
1113
|
+
// this is the floor that keeps it impossible even if it were not.
|
|
1114
|
+
function createTmuxSession(name, cmd, workdir, cols, rows) {
|
|
1115
|
+
if (tmuxHasSession(name)) return false;
|
|
997
1116
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
998
1117
|
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
999
1118
|
return r.status === 0;
|
|
1000
1119
|
}
|
|
1120
|
+
// Every session alive on our private socket, with the directory its pane is actually sitting in.
|
|
1121
|
+
// Deliberately one lookup PER FIELD rather than one packed -F line: tmux rewrites whitespace
|
|
1122
|
+
// inside a format string (a tab separator came back as `_`), which silently glued the fields into
|
|
1123
|
+
// a single unusable name. One field per call has no separator to get wrong, and this runs only at
|
|
1124
|
+
// startup over the handful of sessions on our own socket. [] when tmux is absent or not running.
|
|
1125
|
+
function tmuxField(name, fmt) {
|
|
1126
|
+
const r = tmuxCtl(['display-message', '-p', '-t', name, fmt]);
|
|
1127
|
+
return r && r.status === 0 ? String(r.stdout || '').trim() : '';
|
|
1128
|
+
}
|
|
1129
|
+
function tmuxList() {
|
|
1130
|
+
if (!TMUX) return [];
|
|
1131
|
+
const r = tmuxCtl(['list-sessions', '-F', '#{session_name}']);
|
|
1132
|
+
if (!r || r.status !== 0) return [];
|
|
1133
|
+
return String(r.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)
|
|
1134
|
+
.map((name) => ({
|
|
1135
|
+
name,
|
|
1136
|
+
dir: tmuxField(name, '#{pane_current_path}'),
|
|
1137
|
+
created: Number(tmuxField(name, '#{session_created}')) || 0,
|
|
1138
|
+
cmd: tmuxField(name, '#{pane_start_command}'),
|
|
1139
|
+
}));
|
|
1140
|
+
}
|
|
1001
1141
|
// The pane id (%0, %1, …) the control client must address for input. One cheap lookup per attach;
|
|
1002
1142
|
// cached on the session because a pane id is stable for the life of the tmux session.
|
|
1003
1143
|
function tmuxPaneId(s, name) {
|
|
@@ -1030,7 +1170,11 @@ function seedFromPaneHistory(s, name) {
|
|
|
1030
1170
|
// pane's raw bytes as %output, which we forward verbatim — the same stream shape the direct-PTY
|
|
1031
1171
|
// fallback produces, so xterm.js keeps its own 5000-line scrollback. See lib/tmux-control.js.
|
|
1032
1172
|
function attachPty(s, { seed = false } = {}) {
|
|
1033
|
-
|
|
1173
|
+
// The session's OWN recorded name, never one recomputed from its id — since freshTmuxName the
|
|
1174
|
+
// two no longer coincide, and re-deriving would attach to whatever else happened to hold the
|
|
1175
|
+
// id-shaped name. tmuxName() is the fallback for a session persisted before unique names.
|
|
1176
|
+
const name = s.tmux || tmuxName(s.id);
|
|
1177
|
+
s.tmux = name;
|
|
1034
1178
|
const startedMs = Date.now();
|
|
1035
1179
|
let p;
|
|
1036
1180
|
try {
|
|
@@ -1132,8 +1276,26 @@ function exitedSession(base, workdir, why) {
|
|
|
1132
1276
|
sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
1133
1277
|
}
|
|
1134
1278
|
|
|
1279
|
+
// Live (non-exited) sessions belonging to a workspace. Used to decide whether isolating a branch
|
|
1280
|
+
// would actually protect anyone, and to refuse removing a worktree somebody is working in.
|
|
1281
|
+
function liveSessionCount(wsIdent) {
|
|
1282
|
+
let n = 0;
|
|
1283
|
+
for (const s of sessions.values()) if (s.ws === wsIdent && s.state !== 'exited') n++;
|
|
1284
|
+
return n;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// The next unused session id. `seq` is a per-process counter, so it can only be trusted once it
|
|
1288
|
+
// has been floored by what is already on disk and in memory (see restorePersisted) — this loop is
|
|
1289
|
+
// the last guard: a fresh session must never take an id another session's record already owns,
|
|
1290
|
+
// because persist() would then overwrite that record's scrollback.
|
|
1291
|
+
function nextSessionId() {
|
|
1292
|
+
let id = 'sess-' + (seq++);
|
|
1293
|
+
while (sessions.has(id)) id = 'sess-' + (seq++);
|
|
1294
|
+
return id;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1135
1297
|
function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
1136
|
-
const id =
|
|
1298
|
+
const id = nextSessionId();
|
|
1137
1299
|
const w = getWs(ws);
|
|
1138
1300
|
const root = w ? w.root : SELF_ROOT;
|
|
1139
1301
|
const isShell = agent === 'shell';
|
|
@@ -1156,11 +1318,11 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1156
1318
|
|
|
1157
1319
|
// ── the tmux path: the process outlives this server, and we re-attach to it live (R7) ──
|
|
1158
1320
|
if (TMUX) {
|
|
1159
|
-
const tname =
|
|
1321
|
+
const tname = freshTmuxName(id);
|
|
1160
1322
|
const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
|
|
1161
1323
|
note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
1162
1324
|
sessions.set(id, s);
|
|
1163
|
-
if (!
|
|
1325
|
+
if (!createTmuxSession(tname, cmd, workdir, 80, 24)) {
|
|
1164
1326
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
1165
1327
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
1166
1328
|
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 +1364,143 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1202
1364
|
return s;
|
|
1203
1365
|
}
|
|
1204
1366
|
|
|
1205
|
-
// New session, optionally on a chosen/created branch.
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1367
|
+
// New session, optionally on a chosen/created branch. Three paths, and which one runs is the whole
|
|
1368
|
+
// point of this function:
|
|
1369
|
+
//
|
|
1370
|
+
// isolate — put the branch in its OWN worktree, register that directory as a workspace, and
|
|
1371
|
+
// start the session there. Every already-running session stays exactly where it
|
|
1372
|
+
// was. This is the default the UI offers for "+ new branch", because the shared
|
|
1373
|
+
// checkout moving every session was the single worst thing about this surface.
|
|
1374
|
+
// checkout — the shared-tree path: move THIS repo (and so every session in it) to the branch.
|
|
1375
|
+
// Still offered, still honest about its blast radius, no longer the only option.
|
|
1376
|
+
// no git here — a workspace that is not a repo has no branch to be on. A stale picker or an old
|
|
1377
|
+
// client must not be able to BLOCK a session over it: we spawn, and return a
|
|
1378
|
+
// notice saying the branch was ignored. Never silently, never fatally.
|
|
1379
|
+
//
|
|
1380
|
+
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
1381
|
+
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
1382
|
+
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1383
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
|
|
1384
|
+
const w = getWs(ws);
|
|
1385
|
+
if (!w) return { ok: false, error: 'no workspace' };
|
|
1386
|
+
let targetWs = w.id;
|
|
1387
|
+
let notice = null;
|
|
1388
|
+
// Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
|
|
1389
|
+
// a plain checkout costs nothing and leaves no folder behind — which is the difference between
|
|
1390
|
+
// "a worktree when it earns its keep" and a directory per branch forever. The client sends what
|
|
1391
|
+
// the PM actually saw; this default only decides for a caller that expressed no preference (an
|
|
1392
|
+
// older client, a scheduled run).
|
|
1393
|
+
if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
|
|
1394
|
+
|
|
1210
1395
|
if (branch) {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
if (
|
|
1214
|
-
|
|
1396
|
+
if (!gitInfo.isRepo(w.root)) {
|
|
1397
|
+
notice = `“${w.name}” is not a git repository — the session started here, with no branch applied`;
|
|
1398
|
+
} else if (isolate) {
|
|
1399
|
+
const r = gitInfo.addWorktree(w.root, branch, { create: !!createBranch });
|
|
1400
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1401
|
+
// A worktree is created at the REPO's top. If this workspace was a subdirectory of the repo
|
|
1402
|
+
// (repo/packages/app — the ordinary shape for a tenant product repo added by path), the PM
|
|
1403
|
+
// should land in the matching subdirectory of the new tree, not at a root they never chose.
|
|
1404
|
+
const info = gitInfo.branchInfo(w.root);
|
|
1405
|
+
const mirrored = info.ok && info.subdir ? path.join(r.dir, info.subdir) : r.dir;
|
|
1406
|
+
const dir = isDir(mirrored) ? mirrored : r.dir;
|
|
1407
|
+
const nw = registerWorkspace(dir, `${w.name} ⑂ ${branch}`);
|
|
1408
|
+
if (!nw) return { ok: false, error: `worktree created at ${r.dir} but could not be registered as a workspace` };
|
|
1409
|
+
targetWs = nw.id;
|
|
1410
|
+
notice = r.reused
|
|
1411
|
+
? `reusing the existing worktree for “${branch}” at ${r.dir}`
|
|
1412
|
+
: `“${branch}” checked out in its own folder — ${r.dir}. Sessions already running are untouched.`;
|
|
1413
|
+
broadcast({ t: 'branch', ws: nw.id, ...gitInfo.branchInfo(nw.root) });
|
|
1414
|
+
} else {
|
|
1415
|
+
const r = gitInfo.checkout(w.root, branch, { create: !!createBranch });
|
|
1416
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
1417
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1418
|
+
}
|
|
1215
1419
|
}
|
|
1216
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws });
|
|
1217
|
-
return { ok: true, session: s };
|
|
1420
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
|
|
1421
|
+
return { ok: true, session: s, notice };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
1425
|
+
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
1426
|
+
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
1427
|
+
// — and without a delete ever being a guess. Three hazards block a removal, and every one is
|
|
1428
|
+
// reported by name: live sessions (ours to know), uncommitted changes and commits held nowhere
|
|
1429
|
+
// else (git's to know, see gitInfo.worktreeSafety).
|
|
1430
|
+
function worktreeReport(w) {
|
|
1431
|
+
const info = gitInfo.branchInfo(w.root);
|
|
1432
|
+
if (!info.ok) return { ok: false, code: info.code, error: info.error };
|
|
1433
|
+
const trees = info.worktrees || [];
|
|
1434
|
+
const rows = trees.map((t, i) => {
|
|
1435
|
+
const main = i === 0;
|
|
1436
|
+
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
|
|
1437
|
+
const live = owner ? liveSessionCount(owner.id) : 0;
|
|
1438
|
+
// the main worktree is the repo itself — never removable from here, and saying so beats an
|
|
1439
|
+
// absent button the PM has to reason about
|
|
1440
|
+
const gone = !main && !isDir(t.dir);
|
|
1441
|
+
const safety = main ? { removable: false, reasons: ['the repository’s own working tree'] }
|
|
1442
|
+
// deleted by hand already: nothing left to protect, only git bookkeeping to clear. Asking
|
|
1443
|
+
// `git status` about a folder that isn't there would fail and read as "unsafe".
|
|
1444
|
+
: gone ? { removable: true, reasons: ['the folder is already gone — this clears git’s record of it'] }
|
|
1445
|
+
: gitInfo.worktreeSafety(t.dir, t.branch);
|
|
1446
|
+
const reasons = safety.reasons.slice();
|
|
1447
|
+
if (!main && live > 0) reasons.unshift(`${live} session${live === 1 ? '' : 's'} still running here`);
|
|
1448
|
+
return { dir: t.dir, branch: t.branch, main, missing: gone,
|
|
1449
|
+
ws: owner ? owner.id : null, sessions: live,
|
|
1450
|
+
removable: !main && live === 0 && safety.removable, reasons };
|
|
1451
|
+
});
|
|
1452
|
+
return { ok: true, repoRoot: info.repoRoot, worktrees: rows };
|
|
1218
1453
|
}
|
|
1219
1454
|
|
|
1220
|
-
// ── Scratchpad:
|
|
1221
|
-
//
|
|
1455
|
+
// ── Scratchpad: PER-SESSION staging text (R4). One file per terminal tab, because that is the
|
|
1456
|
+
// unit the PM stages against: the text is the next thing to type into THAT agent, and a single
|
|
1457
|
+
// per-workspace pad meant every tab showed — and overwrote — the same buffer. Local-only, keyed by
|
|
1458
|
+
// the workspace's hashed id and the server-issued session id (never a caller-supplied path),
|
|
1459
|
+
// capped so it can't be used as a blob store.
|
|
1222
1460
|
const SCRATCH_MAX = 200_000;
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1461
|
+
// The pre-session file the workspace used to share. Still read, once: the first tab to open its
|
|
1462
|
+
// pad in that workspace CLAIMS it, so text staged before this change is not silently dropped.
|
|
1463
|
+
function legacyScratchpadPath(w) { return w ? path.join(SCRATCH_DIR, w.id + '.txt') : null; }
|
|
1464
|
+
// A session id is server-issued (`sess-<n>`), never echoed from the client — but this is the one
|
|
1465
|
+
// place a name reaches the filesystem, so it is checked rather than trusted.
|
|
1466
|
+
function scratchpadPath(w, sessionId) {
|
|
1467
|
+
if (!w) return null;
|
|
1468
|
+
if (!sessionId) return legacyScratchpadPath(w);
|
|
1469
|
+
const sid = String(sessionId);
|
|
1470
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(sid) || sid === '.' || sid === '..') return null;
|
|
1471
|
+
return path.join(SCRATCH_DIR, w.id, sid + '.txt');
|
|
1472
|
+
}
|
|
1473
|
+
function readScratchpad(w, sessionId) {
|
|
1474
|
+
const f = scratchpadPath(w, sessionId);
|
|
1226
1475
|
if (!f) return '';
|
|
1227
|
-
try { return fs.readFileSync(f, 'utf8'); } catch {
|
|
1476
|
+
try { return fs.readFileSync(f, 'utf8'); } catch {}
|
|
1477
|
+
// Nothing staged for this tab yet — inherit the workspace's pre-session pad if one survives,
|
|
1478
|
+
// by MOVING it, so exactly one tab gets it and it is never handed out twice.
|
|
1479
|
+
if (sessionId) {
|
|
1480
|
+
const legacy = legacyScratchpadPath(w);
|
|
1481
|
+
try {
|
|
1482
|
+
const text = fs.readFileSync(legacy, 'utf8');
|
|
1483
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
1484
|
+
fs.renameSync(legacy, f);
|
|
1485
|
+
return text;
|
|
1486
|
+
} catch {}
|
|
1487
|
+
}
|
|
1488
|
+
return '';
|
|
1228
1489
|
}
|
|
1229
|
-
function writeScratchpad(w, text) {
|
|
1230
|
-
const f = scratchpadPath(w);
|
|
1231
|
-
if (!f) throw new Error('no
|
|
1232
|
-
fs.mkdirSync(
|
|
1490
|
+
function writeScratchpad(w, sessionId, text) {
|
|
1491
|
+
const f = scratchpadPath(w, sessionId);
|
|
1492
|
+
if (!f) throw new Error('no scratchpad for that session');
|
|
1493
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
1233
1494
|
fs.writeFileSync(f, String(text == null ? '' : text).slice(0, SCRATCH_MAX));
|
|
1234
1495
|
}
|
|
1496
|
+
// A tab that is gone takes its staging text with it — the pad belongs to the session, not to a
|
|
1497
|
+
// slot a later session could inherit by reusing its id.
|
|
1498
|
+
function forgetScratchpad(s) {
|
|
1499
|
+
if (!s) return;
|
|
1500
|
+
const w = workspaces.get(s.ws);
|
|
1501
|
+
const f = scratchpadPath(w, s.id);
|
|
1502
|
+
if (f) { try { fs.unlinkSync(f); } catch {} }
|
|
1503
|
+
}
|
|
1235
1504
|
|
|
1236
1505
|
// ── Layouts: per-workspace arrangements (R7). Same shape as the scratchpad above — one file per
|
|
1237
1506
|
// workspace keyed by the workspace's hashed id, never a caller-supplied path, capped so it can't
|
|
@@ -1262,6 +1531,7 @@ function removeSession(id) {
|
|
|
1262
1531
|
const s = sessions.get(id);
|
|
1263
1532
|
if (!s || s.pty) return false;
|
|
1264
1533
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
1534
|
+
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
1265
1535
|
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
1266
1536
|
return true;
|
|
1267
1537
|
}
|
|
@@ -1430,6 +1700,9 @@ function buildConsole(name, w, root) {
|
|
|
1430
1700
|
if (name === 'needs') return needsResult(w);
|
|
1431
1701
|
if (name === 'metrics') return consoleState.metrics({ stateDir, snapshotDir });
|
|
1432
1702
|
if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
|
|
1703
|
+
// The workflow ladder reads the OPENED workspace's repo (records + git), not the state dir — an
|
|
1704
|
+
// initiative's position is a property of the repo it lives in, snapshot or no snapshot.
|
|
1705
|
+
if (name === 'workflow') return consoleState.workflow({ root: w ? w.root : root });
|
|
1433
1706
|
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1434
1707
|
}
|
|
1435
1708
|
|
|
@@ -1593,17 +1866,25 @@ async function handleRequest(req, res) {
|
|
|
1593
1866
|
if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
|
|
1594
1867
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
1595
1868
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
1869
|
+
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
1596
1870
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
1597
1871
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
1598
1872
|
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
1599
1873
|
// client feature that depends on it fails in a way no server-side test can see.
|
|
1600
1874
|
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
1875
|
+
if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
|
|
1601
1876
|
if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
|
|
1602
1877
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
1603
1878
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
1604
1879
|
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
1605
1880
|
if (p === '/vendor/addon-fit.js') return serveFile(res, path.join(XROOT, 'addon-fit', 'lib', 'addon-fit.js'));
|
|
1606
1881
|
if (p === '/vendor/addon-web-links.js') return serveFile(res, path.join(XROOT, 'addon-web-links', 'lib', 'addon-web-links.js'));
|
|
1882
|
+
// The design system's faces (D38) are VENDORED, not linked from a CDN: the workspace is a local
|
|
1883
|
+
// companion app that has to render correctly with no network at all. Plex Mono ships 400 + 700
|
|
1884
|
+
// because the terminal renders bold — with only the regular face the browser synthesises it.
|
|
1885
|
+
if (p === '/vendor/space-grotesk-var.woff2') return serveFile(res, path.join(PUB, 'vendor', 'space-grotesk-var.woff2'));
|
|
1886
|
+
if (p === '/vendor/ibm-plex-mono-400.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-400.woff2'));
|
|
1887
|
+
if (p === '/vendor/ibm-plex-mono-700.woff2') return serveFile(res, path.join(PUB, 'vendor', 'ibm-plex-mono-700.woff2'));
|
|
1607
1888
|
|
|
1608
1889
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
1609
1890
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
@@ -1626,6 +1907,28 @@ async function handleRequest(req, res) {
|
|
|
1626
1907
|
const ok = forgetWorkspace(body.id);
|
|
1627
1908
|
return json(res, { ok, error: ok ? undefined : 'unknown workspace, the home workspace, or it still owns sessions' }, ok ? 200 : 409);
|
|
1628
1909
|
}
|
|
1910
|
+
if (p === '/api/git/worktrees/remove' && req.method === 'POST') {
|
|
1911
|
+
// Reclaim a worktree's folder. `dir` comes from a client, so it is checked against git's own
|
|
1912
|
+
// list of THIS repo's linked worktrees before anything is removed — the main tree is
|
|
1913
|
+
// unreachable by construction, and a tree with running sessions, uncommitted changes, or
|
|
1914
|
+
// commits held nowhere else is refused with the reason, not silently kept.
|
|
1915
|
+
let body = {};
|
|
1916
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
1917
|
+
const w = getWs(body.ws);
|
|
1918
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
1919
|
+
const row = (worktreeReport(w).worktrees || [])
|
|
1920
|
+
.find((t) => path.resolve(t.dir) === path.resolve(String(body.dir || '')));
|
|
1921
|
+
if (!row) return json(res, { ok: false, error: 'not a linked worktree of this repository' }, 400);
|
|
1922
|
+
if (row.sessions > 0) return json(res, { ok: false, error: `kept — ${row.reasons[0]}` }, 409);
|
|
1923
|
+
const r = gitInfo.removeWorktree(w.root, row.dir);
|
|
1924
|
+
if (!r.ok) return json(res, r, 409);
|
|
1925
|
+
// the workspace entry goes with the folder; forgetWorkspace never touches a repo, and it
|
|
1926
|
+
// refuses while sessions remain, so a race here fails closed rather than orphaning scrollback
|
|
1927
|
+
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(row.dir));
|
|
1928
|
+
if (owner) forgetWorkspace(owner.id);
|
|
1929
|
+
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) });
|
|
1930
|
+
return json(res, { ok: true, dir: r.dir, pruned: !!r.pruned });
|
|
1931
|
+
}
|
|
1629
1932
|
if (p === '/api/pick-folder' && req.method === 'POST') {
|
|
1630
1933
|
let body = {};
|
|
1631
1934
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
@@ -1771,20 +2074,36 @@ async function handleRequest(req, res) {
|
|
|
1771
2074
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
1772
2075
|
return json(res, gitInfo.branchInfo(w.root));
|
|
1773
2076
|
}
|
|
2077
|
+
if (p === '/api/git/worktrees') {
|
|
2078
|
+
// Every working tree attached to this workspace's repo, each with the reasons it can or cannot
|
|
2079
|
+
// be removed. A worktree the workspace made is a folder the PM has to live with, so the app
|
|
2080
|
+
// that creates them is the app that has to show them.
|
|
2081
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
2082
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
2083
|
+
return json(res, worktreeReport(w));
|
|
2084
|
+
}
|
|
2085
|
+
// The pad belongs to a SESSION when one is named — and the workspace then comes from that
|
|
2086
|
+
// session, not from the caller, so a stale `ws` can never file one tab's text under another
|
|
2087
|
+
// repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
|
|
1774
2088
|
if (p === '/api/scratchpad' && req.method === 'POST') {
|
|
1775
2089
|
let body = {};
|
|
1776
2090
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1777
2091
|
catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1778
|
-
const
|
|
2092
|
+
const s = body.session ? sessions.get(String(body.session)) : null;
|
|
2093
|
+
if (body.session && !s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
2094
|
+
const w = s ? workspaces.get(s.ws) : getWs(body.ws);
|
|
1779
2095
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1780
|
-
try { writeScratchpad(w, body.text); }
|
|
2096
|
+
try { writeScratchpad(w, s ? s.id : null, body.text); }
|
|
1781
2097
|
catch (e) { warnWriteFail('scratchpad', e); return json(res, { ok: false, error: 'could not save scratchpad' }, 500); }
|
|
1782
|
-
return json(res, { ok: true });
|
|
2098
|
+
return json(res, { ok: true, session: s ? s.id : null });
|
|
1783
2099
|
}
|
|
1784
2100
|
if (p === '/api/scratchpad') {
|
|
1785
|
-
const
|
|
2101
|
+
const sid = url.searchParams.get('session');
|
|
2102
|
+
const s = sid ? sessions.get(sid) : null;
|
|
2103
|
+
if (sid && !s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
2104
|
+
const w = s ? workspaces.get(s.ws) : getWs(url.searchParams.get('ws'));
|
|
1786
2105
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
1787
|
-
return json(res, { ok: true, text: readScratchpad(w) });
|
|
2106
|
+
return json(res, { ok: true, session: s ? s.id : null, text: readScratchpad(w, s ? s.id : null) });
|
|
1788
2107
|
}
|
|
1789
2108
|
if (p === '/api/layouts' && req.method === 'POST') {
|
|
1790
2109
|
let body = {};
|
|
@@ -1951,12 +2270,156 @@ async function handleRequest(req, res) {
|
|
|
1951
2270
|
const sink = path.join(dir, 'doc-changes.jsonl');
|
|
1952
2271
|
try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(sink, JSON.stringify(rec) + '\n'); logged = sink; }
|
|
1953
2272
|
catch (e) { warnWriteFail('doc-changes.jsonl (the change log)', e); }
|
|
2273
|
+
writeChangeContext(dir); // R8 — keep the agent-readable digest current
|
|
1954
2274
|
}
|
|
1955
2275
|
// The document IS saved at this point. A failed log is reported honestly rather than as success,
|
|
1956
2276
|
// but it must not read as "your edit was lost".
|
|
1957
2277
|
return json(res, { ok: true, saved: body.path, logged, change: { added: rec.added, removed: rec.removed, at_line: rec.at_line },
|
|
1958
2278
|
warning: logged ? undefined : 'the document was saved but the change could not be logged' });
|
|
1959
2279
|
}
|
|
2280
|
+
if (p === '/api/align/candidates') {
|
|
2281
|
+
// Which documents does the last change to THIS document put out of step? Scoped by the change
|
|
2282
|
+
// (R6): a document qualifies only by a concrete tie — it links here, its frontmatter points
|
|
2283
|
+
// here, it shares the initiative, or it quotes text the change altered. "Feels related" is not a
|
|
2284
|
+
// rule, because that is how this becomes a review queue again.
|
|
2285
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
2286
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2287
|
+
const rel = String(url.searchParams.get('path') || '');
|
|
2288
|
+
const full = path.resolve(w.root, rel);
|
|
2289
|
+
if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
|
|
2290
|
+
|
|
2291
|
+
const dir = stateDirFor(w);
|
|
2292
|
+
let last = null;
|
|
2293
|
+
try {
|
|
2294
|
+
const sink = dir ? path.join(dir, 'doc-changes.jsonl') : null;
|
|
2295
|
+
if (sink && fs.existsSync(sink)) {
|
|
2296
|
+
const rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
|
|
2297
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
2298
|
+
.filter((r) => r && r.file === rel);
|
|
2299
|
+
last = rows[rows.length - 1] || null;
|
|
2300
|
+
}
|
|
2301
|
+
} catch {}
|
|
2302
|
+
if (!last) return json(res, { ok: true, empty: true, reason: 'no recorded change to this document yet' });
|
|
2303
|
+
|
|
2304
|
+
let changedText = '';
|
|
2305
|
+
try { changedText = fs.readFileSync(full, 'utf8'); } catch {}
|
|
2306
|
+
const docs = [];
|
|
2307
|
+
for (const f of markdownDocs(w.root)) {
|
|
2308
|
+
if (f === rel) continue;
|
|
2309
|
+
const fp = path.resolve(w.root, f);
|
|
2310
|
+
if (!insideRoot(w.root, fp)) continue;
|
|
2311
|
+
try { docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') }); } catch {}
|
|
2312
|
+
}
|
|
2313
|
+
const cands = relatedDocs.candidates({ changedPath: rel, changedText, removed: last.before, docs });
|
|
2314
|
+
return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, total: cands.length });
|
|
2315
|
+
}
|
|
2316
|
+
if (p === '/api/align/start' && req.method === 'POST') {
|
|
2317
|
+
// Hand the change and its candidates to an agent, which DRAFTS the realigning edits (R5). The
|
|
2318
|
+
// prompt is assembled here so the absolute paths are facts, not guesses — a tenant product repo
|
|
2319
|
+
// has no workspace/ folder, and the agent must never invent one.
|
|
2320
|
+
let body = {};
|
|
2321
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2322
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2323
|
+
const w = getWs(body.ws);
|
|
2324
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2325
|
+
const rel = String(body.path || '');
|
|
2326
|
+
if (!rel || !insideRoot(w.root, path.resolve(w.root, rel))) return json(res, { ok: false, error: 'outside workspace' }, 404);
|
|
2327
|
+
const targets = (Array.isArray(body.targets) ? body.targets : []).filter((t) => typeof t === 'string').slice(0, 12);
|
|
2328
|
+
if (!targets.length) return json(res, { ok: false, error: 'no documents named to align' }, 400);
|
|
2329
|
+
|
|
2330
|
+
const dir = stateDirFor(w, { create: true });
|
|
2331
|
+
if (!dir) return json(res, { ok: false, error: 'this workspace has no state directory to file drafts into' }, 500);
|
|
2332
|
+
const adir = path.join(dir, 'alignments');
|
|
2333
|
+
try { fs.mkdirSync(adir, { recursive: true }); }
|
|
2334
|
+
catch (e) { warnWriteFail('alignments directory', e); return json(res, { ok: false, error: 'could not create the alignments directory' }, 500); }
|
|
2335
|
+
|
|
2336
|
+
const c = body.change || {};
|
|
2337
|
+
const prompt = [
|
|
2338
|
+
'A document in this repository was just changed by the PM. Your job is to bring the documents',
|
|
2339
|
+
'listed below back into line with that change — nothing else.',
|
|
2340
|
+
'',
|
|
2341
|
+
`CHANGED: ${rel} (from line ${c.at_line || '?'})`,
|
|
2342
|
+
'',
|
|
2343
|
+
'It used to say:', '```', String(c.before || '').slice(0, 4000), '```',
|
|
2344
|
+
'It now says:', '```', String(c.after || '').slice(0, 4000), '```',
|
|
2345
|
+
c.note ? `The PM's reason: ${c.note}` : '',
|
|
2346
|
+
'',
|
|
2347
|
+
'BRING THESE INTO LINE — read each, and only these:',
|
|
2348
|
+
...targets.map((t) => ` - ${t}`),
|
|
2349
|
+
'',
|
|
2350
|
+
'For each document that genuinely contradicts the change, produce the edit that fixes it.',
|
|
2351
|
+
'A document that does not actually contradict it gets NO draft — say so and move on. Do not',
|
|
2352
|
+
'improve, tidy, or critique anything: the only warrant you have is the change above.',
|
|
2353
|
+
'',
|
|
2354
|
+
`Write your drafts as JSON to: ${path.join(adir, 'draft-' + Date.now() + '.json')}`,
|
|
2355
|
+
'Shape:',
|
|
2356
|
+
'{"source":"' + rel + '","at":"<ISO 8601>","drafts":[',
|
|
2357
|
+
' {"file":"<path>","why":"<what in this document contradicts the change>",',
|
|
2358
|
+
' "before":"<verbatim text from that file, appearing EXACTLY once>","after":"<replacement>"}',
|
|
2359
|
+
']}',
|
|
2360
|
+
'',
|
|
2361
|
+
'`before` must be copied verbatim and must occur exactly once in its file — the apply path',
|
|
2362
|
+
'refuses a stale or ambiguous anchor rather than guessing. An empty drafts array is a correct',
|
|
2363
|
+
'and useful answer.',
|
|
2364
|
+
].filter((l) => l !== '').join('\n');
|
|
2365
|
+
|
|
2366
|
+
const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
|
|
2367
|
+
origin: 'align:' + rel, initialInput: prompt + '\r' });
|
|
2368
|
+
const started = !!(s && s.pty);
|
|
2369
|
+
return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
|
|
2370
|
+
error: started ? undefined : ((s && s.note) || 'could not start the session') });
|
|
2371
|
+
}
|
|
2372
|
+
if (p === '/api/align/drafts') {
|
|
2373
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
2374
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2375
|
+
const dir = stateDirFor(w);
|
|
2376
|
+
const adir = dir ? path.join(dir, 'alignments') : null;
|
|
2377
|
+
let files = [];
|
|
2378
|
+
try { files = adir ? fs.readdirSync(adir).filter((f) => f.endsWith('.json')).sort() : []; } catch {}
|
|
2379
|
+
if (!files.length) return json(res, { ok: true, empty: true });
|
|
2380
|
+
const pick = files[files.length - 1];
|
|
2381
|
+
let doc;
|
|
2382
|
+
try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
|
|
2383
|
+
catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
|
|
2384
|
+
const drafts = Array.isArray(doc.drafts) ? doc.drafts.filter((d) => d && typeof d.file === 'string') : [];
|
|
2385
|
+
return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts });
|
|
2386
|
+
}
|
|
2387
|
+
if (p === '/api/align/apply' && req.method === 'POST') {
|
|
2388
|
+
// Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
|
|
2389
|
+
// anchor must appear exactly once, or the write is refused rather than guessed at.
|
|
2390
|
+
let body = {};
|
|
2391
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2392
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2393
|
+
const w = getWs(body.ws);
|
|
2394
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
|
|
2395
|
+
const rel = String(body.file || '');
|
|
2396
|
+
const full = path.resolve(w.root, rel);
|
|
2397
|
+
if (!rel || !insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
|
|
2398
|
+
if (preview.previewKind(path.basename(full)) !== 'markdown') return json(res, { ok: false, error: 'only markdown documents are editable here' }, 400);
|
|
2399
|
+
let current;
|
|
2400
|
+
try { current = fs.readFileSync(full, 'utf8'); }
|
|
2401
|
+
catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
|
|
2402
|
+
|
|
2403
|
+
const r = applyChange.applyChange(current, { before: body.before, after: body.after });
|
|
2404
|
+
if (!r.ok) return json(res, { ok: false, error: r.reason, detail: r.detail || null }, 409);
|
|
2405
|
+
try { fs.writeFileSync(full, r.text); }
|
|
2406
|
+
catch (e) { warnWriteFail('alignment apply (' + rel + ')', e); return json(res, { ok: false, error: 'could not write the document' }, 500); }
|
|
2407
|
+
|
|
2408
|
+
// An aligning edit is a change to that document too, and belongs in its log — attributed to the
|
|
2409
|
+
// change that caused it, so the log reads as a chain rather than as unexplained edits.
|
|
2410
|
+
const region = applyChange.changedRegion(body.before, body.after);
|
|
2411
|
+
const dir = stateDirFor(w, { create: true });
|
|
2412
|
+
if (dir) {
|
|
2413
|
+
const rec = { file: rel, at: nowISO(), pm: body.pm || 'wawan',
|
|
2414
|
+
note: 'aligned with ' + (body.source || 'an earlier change') + (body.why ? ' — ' + String(body.why).slice(0, 200) : ''),
|
|
2415
|
+
at_line: region.at_line, added: region.added, removed: region.removed,
|
|
2416
|
+
before: region.before, after: region.after, aligned_from: body.source || null };
|
|
2417
|
+
try { fs.appendFileSync(path.join(dir, 'doc-changes.jsonl'), JSON.stringify(rec) + '\n'); }
|
|
2418
|
+
catch (e) { warnWriteFail('doc-changes.jsonl (alignment)', e); }
|
|
2419
|
+
writeChangeContext(dir);
|
|
2420
|
+
}
|
|
2421
|
+
return json(res, { ok: true, wrote: rel });
|
|
2422
|
+
}
|
|
1960
2423
|
if (p === '/api/doc/changes') {
|
|
1961
2424
|
// The change log, read back (R8 — this is what an agent reads before working here).
|
|
1962
2425
|
const w = getWs(url.searchParams.get('ws'));
|
|
@@ -1977,6 +2440,67 @@ async function handleRequest(req, res) {
|
|
|
1977
2440
|
res.writeHead(404); res.end('not found');
|
|
1978
2441
|
}
|
|
1979
2442
|
|
|
2443
|
+
// ── R8: the change log, written for an agent to read before it works here ──────────────────────
|
|
2444
|
+
// The jsonl is the record; this is the same material shaped so a coding agent picks it up without
|
|
2445
|
+
// being handed a parser. Regenerated on every save — cheap, and it means the file is never stale.
|
|
2446
|
+
// What makes it worth reading is the PM's own reasons: an agent that knows WHY a line kept getting
|
|
2447
|
+
// rewritten will stop writing the thing that provoked it.
|
|
2448
|
+
function writeChangeContext(dir) {
|
|
2449
|
+
if (!dir) return null;
|
|
2450
|
+
const sink = path.join(dir, 'doc-changes.jsonl');
|
|
2451
|
+
let rows = [];
|
|
2452
|
+
try {
|
|
2453
|
+
if (!fs.existsSync(sink)) return null;
|
|
2454
|
+
rows = fs.readFileSync(sink, 'utf8').split('\n').filter(Boolean)
|
|
2455
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2456
|
+
} catch { return null; }
|
|
2457
|
+
if (!rows.length) return null;
|
|
2458
|
+
|
|
2459
|
+
const byFile = new Map();
|
|
2460
|
+
for (const r of rows) byFile.set(r.file, (byFile.get(r.file) || 0) + 1);
|
|
2461
|
+
const hot = [...byFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
2462
|
+
const withReasons = rows.filter((r) => r.note).slice(-20).reverse();
|
|
2463
|
+
const recent = rows.slice(-12).reverse();
|
|
2464
|
+
|
|
2465
|
+
const out = [
|
|
2466
|
+
'---', 'type: Reference',
|
|
2467
|
+
'title: "Change context — what the PM has been editing here, and why"',
|
|
2468
|
+
'timestamp: ' + nowISO().slice(0, 10), '---', '',
|
|
2469
|
+
'# Change context',
|
|
2470
|
+
'',
|
|
2471
|
+
'**Read this before writing documents in this repository.** It is generated from the PM\'s own',
|
|
2472
|
+
'edits in the workspace — not a summary of the repo, and not a style guide someone wrote once.',
|
|
2473
|
+
'If a line below shows the PM rewriting the same kind of thing repeatedly, that is a correction',
|
|
2474
|
+
'you can avoid provoking again.',
|
|
2475
|
+
'',
|
|
2476
|
+
`Generated ${nowISO()} from ${rows.length} recorded change${rows.length === 1 ? '' : 's'}.`,
|
|
2477
|
+
'',
|
|
2478
|
+
'## Documents changed most often', '',
|
|
2479
|
+
...hot.map(([f, n]) => `- \`${f}\` — ${n} change${n === 1 ? '' : 's'}`),
|
|
2480
|
+
'',
|
|
2481
|
+
];
|
|
2482
|
+
if (withReasons.length) {
|
|
2483
|
+
out.push('## Why the PM changed things, in their words', '');
|
|
2484
|
+
for (const r of withReasons) {
|
|
2485
|
+
out.push(`- **${r.file}** — ${r.note}`);
|
|
2486
|
+
if (r.before && r.after) {
|
|
2487
|
+
out.push(' ```', ' - ' + String(r.before).split('\n')[0].slice(0, 160),
|
|
2488
|
+
' + ' + String(r.after).split('\n')[0].slice(0, 160), ' ```');
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
out.push('');
|
|
2492
|
+
}
|
|
2493
|
+
out.push('## Most recent changes', '');
|
|
2494
|
+
for (const r of recent) {
|
|
2495
|
+
out.push(`- \`${r.file}\` · +${r.added} −${r.removed} at line ${r.at_line} · ${String(r.at).slice(0, 16).replace('T', ' ')}` +
|
|
2496
|
+
(r.aligned_from ? ` · aligned with a change to \`${r.aligned_from}\`` : ''));
|
|
2497
|
+
}
|
|
2498
|
+
out.push('');
|
|
2499
|
+
const target = path.join(dir, 'change-context.md');
|
|
2500
|
+
try { fs.writeFileSync(target, out.join('\n')); return target; }
|
|
2501
|
+
catch { return null; }
|
|
2502
|
+
}
|
|
2503
|
+
|
|
1980
2504
|
// ── Access guard (D62): loopback is token-free; every non-loopback peer needs the bearer token. ──
|
|
1981
2505
|
// Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
|
|
1982
2506
|
// remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers
|
|
@@ -2073,8 +2597,11 @@ wss.on('connection', (ws) => {
|
|
|
2073
2597
|
} else if (m.t === 'resize') {
|
|
2074
2598
|
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
2599
|
} 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 });
|
|
2600
|
+
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
2601
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: 'branch: ' + r.error })); return; }
|
|
2602
|
+
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
2603
|
+
// non-repo) — said out loud, never swallowed
|
|
2604
|
+
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
2078
2605
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
2079
2606
|
} else if (m.t === 'respawn') {
|
|
2080
2607
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|