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.
@@ -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
- return [...sessions.values()].map((s) => {
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
- return { ok: true, session: s, notice };
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
- const live = owner ? liveSessionCount(owner.id) : 0;
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
- // periodic scrollback persistence so a crash loses at most a few seconds of history
2159
- setInterval(() => { for (const s of sessions.values()) if (s.pty) persist(s); }, 5000).unref?.();
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,