create-pmos 0.17.0 → 0.18.1
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 +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/metrics.py +9 -2
- package/workspace/README.md +25 -0
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +129 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +87 -3
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +147 -6
- package/workspace/public/pm/state.js +65 -6
- package/workspace/public/pm/tab-term.js +134 -18
- package/workspace/server.js +267 -8
package/workspace/server.js
CHANGED
|
@@ -71,6 +71,8 @@ const consoleState = require('./lib/console-state'); // read-only console surf
|
|
|
71
71
|
const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
|
|
72
72
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
73
73
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
74
|
+
const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
|
|
75
|
+
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
74
76
|
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
75
77
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
76
78
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
@@ -312,11 +314,18 @@ function repoIdentity(root) {
|
|
|
312
314
|
const now = Date.now();
|
|
313
315
|
if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
|
|
314
316
|
let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
|
|
315
|
-
let remote = null;
|
|
317
|
+
let remote = null, tree = null;
|
|
316
318
|
try {
|
|
317
319
|
const trees = gitInfo.worktrees(root);
|
|
318
320
|
if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
|
|
319
321
|
} catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
|
|
322
|
+
// The THIRD key, and the only one that answers "would these two sessions collide": the working
|
|
323
|
+
// tree this workspace sits in — one HEAD, one index, one set of files. `key` above is the
|
|
324
|
+
// REPOSITORY (its main worktree), which deliberately groups a repo's linked worktrees; those are
|
|
325
|
+
// exactly the sessions that are safely isolated, so it is the wrong key for collisions. Null for
|
|
326
|
+
// a folder with no git, and null is meaningful: no index, nothing to collide over. Memoised in
|
|
327
|
+
// this same entry because `wsList()` runs on every workspace push and this is a third shell-out.
|
|
328
|
+
try { tree = gitInfo.repoRoot(root); } catch { tree = null; }
|
|
320
329
|
// Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
|
|
321
330
|
// another `git` shell-out on a call that runs for every workspace on every push. A failure is not
|
|
322
331
|
// a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
|
|
@@ -333,10 +342,62 @@ function repoIdentity(root) {
|
|
|
333
342
|
// one drifts.
|
|
334
343
|
else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
|
|
335
344
|
} catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
|
|
336
|
-
const val = { at: now, key, name, branch, branchError, branchCode, remote };
|
|
345
|
+
const val = { at: now, key, name, branch, branchError, branchCode, remote, tree };
|
|
337
346
|
repoKeyCache.set(root, val);
|
|
338
347
|
return val;
|
|
339
348
|
}
|
|
349
|
+
|
|
350
|
+
// ── Shared-tree co-tenancy (workspace-session-concurrency) ────────────────────────────────────
|
|
351
|
+
// The rules live in lib/tree-share.js, pure and unit-tested; what lives here is the two things the
|
|
352
|
+
// server owns — the git answer (via the memoised identity above) and the session registry.
|
|
353
|
+
//
|
|
354
|
+
// Held claims, tree top → session id. Only ever BELIEVED while its holder is live (treeShare.
|
|
355
|
+
// writerOf re-checks), so an exited writer leaves the tree vacant rather than leaving a stale name
|
|
356
|
+
// on it. Nothing is promoted automatically: which pane drives is the PM's intention, not ours.
|
|
357
|
+
const treeClaims = new Map();
|
|
358
|
+
/** workspace id → its working-tree top (null when the folder is not in a repo). */
|
|
359
|
+
function treeOfWsMap() {
|
|
360
|
+
const m = new Map();
|
|
361
|
+
for (const w of workspaces.values()) m.set(w.id, repoIdentity(w.root).tree || null);
|
|
362
|
+
return m;
|
|
363
|
+
}
|
|
364
|
+
function treeForWs(wsIdent) {
|
|
365
|
+
const w = wsIdent && workspaces.get(wsIdent);
|
|
366
|
+
return w ? (repoIdentity(w.root).tree || null) : null;
|
|
367
|
+
}
|
|
368
|
+
/** Live sessions sharing a tree, excluding one id. Rows are the registry's own objects. */
|
|
369
|
+
function treeTenants(tree, excludeId) {
|
|
370
|
+
return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
|
|
371
|
+
}
|
|
372
|
+
function treeWriter(tree) {
|
|
373
|
+
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
|
|
377
|
+
* found into its own scrollback. The pane line is deliberately part of the session's record rather
|
|
378
|
+
* than only a transient notice frame — re-open the tab tomorrow and it still says what it was told.
|
|
379
|
+
* Returns the co-tenants so the caller can put the same fact on the wire.
|
|
380
|
+
*/
|
|
381
|
+
function announceTree(s) {
|
|
382
|
+
const tree = treeForWs(s.ws);
|
|
383
|
+
s.tree = tree || null;
|
|
384
|
+
if (!tree) return [];
|
|
385
|
+
const mates = treeTenants(tree, s.id);
|
|
386
|
+
const holder = treeWriter(tree);
|
|
387
|
+
if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
|
|
388
|
+
if (!mates.length) return []; // alone in the tree: nothing to say
|
|
389
|
+
const held = holder ? (sessions.get(holder) || {}).name : null;
|
|
390
|
+
const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
|
|
391
|
+
if (line) appendBuf(s, line);
|
|
392
|
+
return mates;
|
|
393
|
+
}
|
|
394
|
+
/** The sentence for the spawn's notice frame — same facts as the pane line, same source. */
|
|
395
|
+
function treeNoticeFor(s, mates) {
|
|
396
|
+
if (!mates || !mates.length) return null;
|
|
397
|
+
const holder = treeWriter(s.tree);
|
|
398
|
+
const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
|
|
399
|
+
return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
|
|
400
|
+
}
|
|
340
401
|
function wsList() {
|
|
341
402
|
return [...workspaces.values()].map((w) => {
|
|
342
403
|
const rid = repoIdentity(w.root);
|
|
@@ -1246,6 +1307,10 @@ function persist(s) {
|
|
|
1246
1307
|
try {
|
|
1247
1308
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
1248
1309
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
1310
|
+
// What is on disk now matches `s.buf`. The periodic writer reads this to skip a session whose
|
|
1311
|
+
// buffer has not moved — see the interval below. Cleared only on a write that SUCCEEDED, so a
|
|
1312
|
+
// disk blip leaves the session dirty and it is retried on the next tick.
|
|
1313
|
+
s.bufDirty = false;
|
|
1249
1314
|
} catch { /* best-effort; never crash a live session on a disk blip */ }
|
|
1250
1315
|
}
|
|
1251
1316
|
function unpersist(id) {
|
|
@@ -1472,7 +1537,16 @@ function sessionList() {
|
|
|
1472
1537
|
// failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
|
|
1473
1538
|
const pread = readProfilesNow();
|
|
1474
1539
|
const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
|
|
1475
|
-
|
|
1540
|
+
// ONE tree map and ONE writer lookup per broadcast, for the same reason profiles are read once:
|
|
1541
|
+
// the heartbeat runs every 2s and a per-row `git rev-parse` would be a shell-out per pane per
|
|
1542
|
+
// tick. `repoIdentity` is memoised, but the map is still built once and reused.
|
|
1543
|
+
const rowsAll = [...sessions.values()];
|
|
1544
|
+
const treeOfWs = treeOfWsMap();
|
|
1545
|
+
const writerByTree = new Map();
|
|
1546
|
+
for (const t of new Set([...treeOfWs.values()].filter(Boolean))) {
|
|
1547
|
+
writerByTree.set(t, treeShare.writerOf(treeClaims, treeOfWs, rowsAll, t));
|
|
1548
|
+
}
|
|
1549
|
+
return rowsAll.map((s) => {
|
|
1476
1550
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
1477
1551
|
const h = harnessOf(s);
|
|
1478
1552
|
const hx = transcriptHarness(s);
|
|
@@ -1504,6 +1578,18 @@ function sessionList() {
|
|
|
1504
1578
|
// BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
|
|
1505
1579
|
// profile descriptor"), which is the honest answer. Resolving it to `null` instead would
|
|
1506
1580
|
// print "none was chosen" over a session that did choose one (R9).
|
|
1581
|
+
// ── the shared working tree, and the DECLARED writer on it ──
|
|
1582
|
+
// `tree` is the collision unit (one HEAD, one index) — null for a folder with no git, and
|
|
1583
|
+
// null is the honest answer there rather than a group of one. `tree_role` is 'writer' for the
|
|
1584
|
+
// one session that holds the claim, 'reader' for every other session in that tree, and null
|
|
1585
|
+
// where there is no tree. `tree_peers` counts the OTHER live sessions sharing it. All three
|
|
1586
|
+
// are advisory: PMOS hosts the terminal, it does not sit between the agent and the files.
|
|
1587
|
+
tree: treeOfWs.get(s.ws) || null,
|
|
1588
|
+
// an exited pane holds no role: a corpse is neither writing nor waiting to.
|
|
1589
|
+
tree_role: treeShare.isLive(s)
|
|
1590
|
+
? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
|
|
1591
|
+
: null,
|
|
1592
|
+
tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
|
|
1507
1593
|
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1508
1594
|
profile_line: profilesLib.profileLine(
|
|
1509
1595
|
!('profile' in s) ? undefined
|
|
@@ -1560,6 +1646,7 @@ function trimBuf(buf, limit) {
|
|
|
1560
1646
|
function appendBuf(s, data) {
|
|
1561
1647
|
s.buf += data;
|
|
1562
1648
|
if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
1649
|
+
s.bufDirty = true; // the periodic writer skips a session that is not dirty
|
|
1563
1650
|
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
1564
1651
|
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
1565
1652
|
s.lastOutputAt = Date.now();
|
|
@@ -1727,6 +1814,7 @@ function seedFromPaneHistory(s, name) {
|
|
|
1727
1814
|
if (!text) return false;
|
|
1728
1815
|
s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
|
|
1729
1816
|
s.parsed = null;
|
|
1817
|
+
s.bufDirty = true; // the ring moved; the file on disk is stale
|
|
1730
1818
|
return true;
|
|
1731
1819
|
}
|
|
1732
1820
|
|
|
@@ -1922,6 +2010,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1922
2010
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
1923
2011
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
1924
2012
|
}
|
|
2013
|
+
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
1925
2014
|
attachPty(s);
|
|
1926
2015
|
feed(s);
|
|
1927
2016
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -1940,6 +2029,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1940
2029
|
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
1941
2030
|
lastOutputAt: Date.now() };
|
|
1942
2031
|
sessions.set(id, s);
|
|
2032
|
+
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
1943
2033
|
p.onData((d) => {
|
|
1944
2034
|
appendBuf(s, d);
|
|
1945
2035
|
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
@@ -2017,7 +2107,96 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2017
2107
|
}
|
|
2018
2108
|
}
|
|
2019
2109
|
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
2020
|
-
|
|
2110
|
+
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2111
|
+
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2112
|
+
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
2113
|
+
// one the PM has been living in. It said nothing. Now it says what it already knew.
|
|
2114
|
+
//
|
|
2115
|
+
// Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
|
|
2116
|
+
// notice can both be true of one spawn. joinNotices keeps them both rather than letting the
|
|
2117
|
+
// second evict the first exactly when the PM most needs both.
|
|
2118
|
+
const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
|
|
2119
|
+
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
|
|
2123
|
+
* The composer asked six questions to open a terminal, and the one question the PM actually has —
|
|
2124
|
+
* "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
|
|
2125
|
+
* isolation checkbox whose default came from liveSessionCount(). These three functions replace that
|
|
2126
|
+
* with: pick a repository, optionally pick an initiative, press one control.
|
|
2127
|
+
*
|
|
2128
|
+
* The policy itself is lib/start-policy.js — pure, so the git-touching paths are testable without
|
|
2129
|
+
* a repository. This layer only supplies it with facts and carries out what it decides.
|
|
2130
|
+
*
|
|
2131
|
+
* R3 is why `agent` appears NOWHERE below except as a value passed through to the spawn: the
|
|
2132
|
+
* destination is derived from the repository and the initiative, so `shell` and `claude` cannot
|
|
2133
|
+
* land in different directories for the same selection. Previously both resolved through
|
|
2134
|
+
* `state.wsId` — whichever folder the client happened to have selected — which is exactly how they
|
|
2135
|
+
* came apart.
|
|
2136
|
+
*/
|
|
2137
|
+
function treesOfRepo(repoKey) {
|
|
2138
|
+
const out = [];
|
|
2139
|
+
for (const w of workspaces.values()) {
|
|
2140
|
+
const ident = repoIdentity(w.root);
|
|
2141
|
+
if (String(ident.key) !== String(repoKey)) continue;
|
|
2142
|
+
out.push({ id: w.id, root: w.root, name: w.name, repo_key: ident.key, branch: ident.branch });
|
|
2143
|
+
}
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
2146
|
+
// The repo key for a request that named a workspace rather than a repository. The client sends
|
|
2147
|
+
// whichever it has; a stale ws id must not silently retarget another repo, so an unknown id is an
|
|
2148
|
+
// error rather than a fallback to "some repository".
|
|
2149
|
+
function repoKeyFor({ repoKey, ws }) {
|
|
2150
|
+
if (repoKey) return String(repoKey);
|
|
2151
|
+
const w = ws && workspaces.get(ws);
|
|
2152
|
+
return w ? String(repoIdentity(w.root).key) : null;
|
|
2153
|
+
}
|
|
2154
|
+
// What the PM is about to get, BEFORE they press anything (R1: the destination is named first).
|
|
2155
|
+
// Read-only: it reads git and resolves the policy, and starts nothing.
|
|
2156
|
+
function startPreview({ initiative, repoKey, ws }) {
|
|
2157
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2158
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2159
|
+
const trees = treesOfRepo(key);
|
|
2160
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2161
|
+
// branchInfo is shelled per call and only when an initiative is named — the no-initiative plan
|
|
2162
|
+
// needs no branch list, and this runs on every selection change.
|
|
2163
|
+
let branches = null;
|
|
2164
|
+
if (initiative && main) {
|
|
2165
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2166
|
+
}
|
|
2167
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2168
|
+
if (!plan.ok) return plan;
|
|
2169
|
+
// The literal directory, so the sentence in the UI is checkable rather than a promise.
|
|
2170
|
+
let dir = null;
|
|
2171
|
+
if (plan.plan === 'new-worktree' && main) { try { dir = gitInfo.worktreePath(main.root, plan.branch); } catch {} }
|
|
2172
|
+
else { const t = trees.find((x) => x.id === plan.wsId); dir = t ? t.root : null; }
|
|
2173
|
+
return { ok: true, plan: plan.plan, why: plan.why, dir, branch: plan.branch, wsId: plan.wsId };
|
|
2174
|
+
}
|
|
2175
|
+
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
2176
|
+
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
2177
|
+
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
2178
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
2179
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2180
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2181
|
+
const trees = treesOfRepo(key);
|
|
2182
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2183
|
+
let branches = null;
|
|
2184
|
+
if (initiative && main) {
|
|
2185
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2186
|
+
}
|
|
2187
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2188
|
+
if (!plan.ok) return plan;
|
|
2189
|
+
const r = spawnWithBranch({
|
|
2190
|
+
name: name || initiative || agent || 'shell',
|
|
2191
|
+
agent: agent || 'shell',
|
|
2192
|
+
ws: plan.wsId,
|
|
2193
|
+
branch: plan.branch,
|
|
2194
|
+
createBranch: plan.createBranch,
|
|
2195
|
+
isolate: plan.isolate,
|
|
2196
|
+
profile,
|
|
2197
|
+
});
|
|
2198
|
+
if (!r.ok) return r;
|
|
2199
|
+
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
2021
2200
|
}
|
|
2022
2201
|
|
|
2023
2202
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
@@ -2033,7 +2212,11 @@ function worktreeReport(w) {
|
|
|
2033
2212
|
const rows = trees.map((t, i) => {
|
|
2034
2213
|
const main = i === 0;
|
|
2035
2214
|
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
|
|
2036
|
-
|
|
2215
|
+
// Counted by TREE, not by workspace id. A session running in a workspace registered at a
|
|
2216
|
+
// SUBDIRECTORY of this tree (`repo/packages/app` — the ordinary tenant-repo shape) is a session
|
|
2217
|
+
// whose files this removal would pull out from under it, and the old workspace-id count could
|
|
2218
|
+
// not see it: two ids, one checkout. `owner` is still the row's workspace for the client.
|
|
2219
|
+
const live = treeTenants(path.resolve(t.dir), null).length;
|
|
2037
2220
|
// the main worktree is the repo itself — never removable from here, and saying so beats an
|
|
2038
2221
|
// absent button the PM has to reason about
|
|
2039
2222
|
const gone = !main && !isDir(t.dir);
|
|
@@ -2137,6 +2320,10 @@ function removeSession(id) {
|
|
|
2137
2320
|
if (!s || s.pty) return false;
|
|
2138
2321
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
2139
2322
|
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
2323
|
+
// Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
|
|
2324
|
+
// believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
|
|
2325
|
+
// map that only grows is a leak waiting to be found by someone else.
|
|
2326
|
+
if (s.tree && treeClaims.get(s.tree) === id) treeClaims.delete(s.tree);
|
|
2140
2327
|
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
2141
2328
|
return true;
|
|
2142
2329
|
}
|
|
@@ -2155,8 +2342,17 @@ function renameSession(id, name) {
|
|
|
2155
2342
|
return true;
|
|
2156
2343
|
}
|
|
2157
2344
|
|
|
2158
|
-
//
|
|
2159
|
-
|
|
2345
|
+
// Periodic scrollback persistence so a crash loses at most a few seconds of history — but ONLY for
|
|
2346
|
+
// sessions whose buffer actually moved. `persist()` is two SYNCHRONOUS writes, the second of them
|
|
2347
|
+
// the whole ring buffer (up to 200 KB), and this ran unconditionally for every live session every
|
|
2348
|
+
// five seconds. A workspace with six idle panes open was therefore doing over a megabyte of blocking
|
|
2349
|
+
// disk I/O, on a timer, forever — rewriting bytes identical to the ones already on disk, on the same
|
|
2350
|
+
// thread that carries every keystroke to a PTY and every byte back to the browser. That is a stall
|
|
2351
|
+
// the PM feels as the terminal going unresponsive for no reason they can see.
|
|
2352
|
+
//
|
|
2353
|
+
// `bufDirty` is set by appendBuf (the only writer of `s.buf`) and cleared by persist(), so an idle
|
|
2354
|
+
// session now costs one map lookup per tick and a busy one is written exactly as often as before.
|
|
2355
|
+
setInterval(() => { for (const s of sessions.values()) if (s.pty && s.bufDirty) persist(s); }, 5000).unref?.();
|
|
2160
2356
|
|
|
2161
2357
|
// ── File-mode state, per workspace. ───────────────────────────────────────────────────
|
|
2162
2358
|
// A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
|
|
@@ -2947,6 +3143,25 @@ async function handleRequest(req, res) {
|
|
|
2947
3143
|
return serveFile(res, file, null, true);
|
|
2948
3144
|
}
|
|
2949
3145
|
|
|
3146
|
+
if (p === '/api/tree/claim' && req.method === 'POST') {
|
|
3147
|
+
// Hand the writer claim to a named session. The ONLY way a claim moves: never on a timer, never
|
|
3148
|
+
// on an exit, never on a heuristic. Which pane should be driving is the PM's intention, and a
|
|
3149
|
+
// derived answer must not impersonate a decision — so an exited holder leaves the tree vacant
|
|
3150
|
+
// and this endpoint is how it stops being vacant.
|
|
3151
|
+
let body = {};
|
|
3152
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3153
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3154
|
+
const s = body.session && sessions.get(String(body.session));
|
|
3155
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 400);
|
|
3156
|
+
if (!treeShare.isLive(s)) return json(res, { ok: false, error: 'that session has exited — an exited pane cannot hold a claim' }, 400);
|
|
3157
|
+
const tree = treeForWs(s.ws);
|
|
3158
|
+
if (!tree) return json(res, { ok: false, error: 'that session is not in a git working tree — there is nothing to claim' }, 400);
|
|
3159
|
+
const prev = treeWriter(tree);
|
|
3160
|
+
treeClaims.set(tree, s.id);
|
|
3161
|
+
pushSessions();
|
|
3162
|
+
return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
|
|
3163
|
+
note: 'a declaration, not a guarantee — nothing about either process changed' });
|
|
3164
|
+
}
|
|
2950
3165
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
2951
3166
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
2952
3167
|
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
@@ -3385,6 +3600,16 @@ async function handleRequest(req, res) {
|
|
|
3385
3600
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3386
3601
|
return json(res, gitInfo.branchInfo(w.root));
|
|
3387
3602
|
}
|
|
3603
|
+
if (p === '/api/start/preview') {
|
|
3604
|
+
// repo-first R1 — what one click will do, said BEFORE the click. Read-only: it resolves the
|
|
3605
|
+
// policy and names the literal destination directory, and starts nothing. This exists so the
|
|
3606
|
+
// rule is checkable by the PM rather than inferred after a folder appears.
|
|
3607
|
+
return json(res, startPreview({
|
|
3608
|
+
initiative: url.searchParams.get('initiative') || '',
|
|
3609
|
+
repoKey: url.searchParams.get('repo') || '',
|
|
3610
|
+
ws: url.searchParams.get('ws') || '',
|
|
3611
|
+
}));
|
|
3612
|
+
}
|
|
3388
3613
|
if (p === '/api/git/worktrees') {
|
|
3389
3614
|
// Every working tree attached to this workspace's repo, each with the reasons it can or cannot
|
|
3390
3615
|
// be removed. A worktree the workspace made is a folder the PM has to live with, so the app
|
|
@@ -3393,6 +3618,27 @@ async function handleRequest(req, res) {
|
|
|
3393
3618
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3394
3619
|
return json(res, worktreeReport(w));
|
|
3395
3620
|
}
|
|
3621
|
+
if (p === '/api/tree/peers') {
|
|
3622
|
+
// Who this workspace's sessions actually share mutable git state with, and who is declared to
|
|
3623
|
+
// be driving. `advisory` is a FIELD, not a footnote: a caller that renders this must not be
|
|
3624
|
+
// able to read it as enforcement without having read the word.
|
|
3625
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
3626
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3627
|
+
const tree = repoIdentity(w.root).tree || null;
|
|
3628
|
+
if (!tree) {
|
|
3629
|
+
return json(res, { ok: true, tree: null, writer: null, sessions: [], advisory: true,
|
|
3630
|
+
note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
|
|
3631
|
+
}
|
|
3632
|
+
const writer = treeWriter(tree);
|
|
3633
|
+
const rows = treeTenants(tree, null).map((s) => ({
|
|
3634
|
+
id: s.id, name: s.name, ws: s.ws, agent: s.agent, state: s.state,
|
|
3635
|
+
role: treeShare.roleOf(s.id, tree, writer),
|
|
3636
|
+
}));
|
|
3637
|
+
return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
|
|
3638
|
+
note: writer
|
|
3639
|
+
? 'the writer claim is a declaration, not a guarantee — PMOS hosts the terminal, it does not sit between the agent and the files'
|
|
3640
|
+
: 'no session holds the writer claim on this tree' });
|
|
3641
|
+
}
|
|
3396
3642
|
// The pad belongs to a SESSION when one is named — and the workspace then comes from that
|
|
3397
3643
|
// session, not from the caller, so a stale `ws` can never file one tab's text under another
|
|
3398
3644
|
// repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
|
|
@@ -4019,7 +4265,7 @@ wss.on('connection', (ws) => {
|
|
|
4019
4265
|
ws.on('message', (raw) => {
|
|
4020
4266
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
4021
4267
|
// Gate the shell-spawning / PTY-feeding messages through the buckets before they do any work.
|
|
4022
|
-
if ((m.t === 'new' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
4268
|
+
if ((m.t === 'new' || m.t === 'start' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
4023
4269
|
ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
|
|
4024
4270
|
return;
|
|
4025
4271
|
}
|
|
@@ -4043,6 +4289,15 @@ wss.on('connection', (ws) => {
|
|
|
4043
4289
|
// non-repo) — said out loud, never swallowed
|
|
4044
4290
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4045
4291
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4292
|
+
} else if (m.t === 'start') {
|
|
4293
|
+
// repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
|
|
4294
|
+
// never a branch and never an isolate flag: those are the policy's to decide, and a client
|
|
4295
|
+
// that could still send them would be a second place the rule lives.
|
|
4296
|
+
const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
|
|
4297
|
+
agent: m.agent, name: m.name, profile: m.profile });
|
|
4298
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
4299
|
+
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4300
|
+
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4046
4301
|
} else if (m.t === 'respawn') {
|
|
4047
4302
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
4048
4303
|
// `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
|
|
@@ -4147,6 +4402,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
4147
4402
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
4148
4403
|
// being dropped one layer above it.
|
|
4149
4404
|
repoIdentity, wsList,
|
|
4405
|
+
// the shared-tree co-tenancy layer — exported so the suite can assert the KEY in both directions
|
|
4406
|
+
// (one repo registered twice is one tree; two worktrees of one repo are two) without driving
|
|
4407
|
+
// every case through a spawn.
|
|
4408
|
+
treeForWs, treeTenants, treeWriter, treeClaims,
|
|
4150
4409
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
4151
4410
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
4152
4411
|
scrub, harnessOf,
|