create-pmos 0.17.0 → 0.18.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 CHANGED
@@ -333,6 +333,36 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.18.0
337
+
338
+ **Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
339
+ bump means an existing install auto-upgrades.
340
+
341
+ - **Starting a session where others are already running tells you what you are sharing.** Sessions
342
+ in one checkout share one branch, one index and one set of files — that was true before and
343
+ nothing said it. The new session's pane now names the sessions already there and points at the
344
+ alternative (a new branch in its own folder). It appears on the ordinary "+ session" path too,
345
+ where no git operation runs at all: the server had already worked out that other sessions would
346
+ be disturbed, and was throwing the answer away unless you happened to pick a branch.
347
+ - **It counts the right thing.** A repository registered at two paths — the project root and a
348
+ subdirectory of it, the ordinary shape when you add a product repo by path — is **one** checkout,
349
+ and its sessions are now told about each other; before, each was told it was alone. Two
350
+ *worktrees* of one repository are **two** checkouts and are deliberately not warned about, because
351
+ those are the sessions that isolation already made safe. A folder with no git says nothing, since
352
+ there is no index to collide over.
353
+ - **One session is the declared writer; the rest read.** The first live session in a checkout holds
354
+ the claim, and you can hand it to another (`POST /api/tree/claim`); `GET /api/tree/peers` reports
355
+ who holds it and who else is there. It moves only when you move it — never on a timer or an exit —
356
+ and when its holder exits the checkout reads *vacant* rather than promoting somebody at random.
357
+ - **Be clear about what that is: a declaration, not a lock.** The workspace hosts your agent's
358
+ terminal; it does not sit between the agent and the files, so nothing here prevents a write, a
359
+ checkout or a commit, and no session is ever refused because another one is there. It makes a
360
+ shared checkout visible. The discipline stays yours.
361
+ - **A folder you are working in can no longer be reclaimed out from under you.** Removing a
362
+ worktree already refused when sessions were running in it — but it counted only sessions in the
363
+ workspace registered at that exact folder, so a session running in a subdirectory of it was
364
+ invisible. It is counted now, and named in the refusal.
365
+
336
366
  ## What's new in 0.17.0
337
367
 
338
368
  **Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@4cb5198
1
+ kernel-source: pmos@83f52ff
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -155,6 +155,31 @@ the folder and **keeps the branch**; nothing is ever removed automatically. Git
155
155
  between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
156
156
  repo.
157
157
 
158
+ ## Several sessions in one tree: you are told, never stopped
159
+
160
+ Sessions in one working tree share **one branch, one index and one set of files**. Starting a
161
+ session in a tree that already has live ones now says so in the pane — naming them, and naming the
162
+ way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
163
+ git operation runs at all: the server already knew, and used to say nothing.
164
+
165
+ **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
166
+ `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
167
+ its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
168
+ warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
169
+ and says nothing, because there is no index to collide over.
170
+
171
+ **The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
172
+ others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
173
+ `POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
174
+ timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
175
+ promoted, because which pane should be driving is your intention and not something to derive.
176
+
177
+ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
178
+ the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
179
+ because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
180
+ (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
181
+ not wired yet.)
182
+
158
183
  A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
159
184
  counts, which matters for a product repo added by path. A folder with **no git** simply says so and
160
185
  starts the session; nothing about git can block work in a repo that doesn't use it.
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ // Who shares a working tree with whom, and who is DECLARED to be driving it.
3
+ //
4
+ // The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
5
+ // inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
6
+ // module cannot prevent a write, a checkout or a commit — and never claims to. What it can do is
7
+ // compute the right SET of sessions that would collide, and carry one declaration about which of
8
+ // them the PM says is driving. Advisory, and every sentence it produces says so.
9
+ //
10
+ // The one idea worth holding: **three keys look like "which repo is this" and only one of them is
11
+ // the collision unit.**
12
+ // • the workspace id — a registered DIRECTORY. Too narrow: one repo can be registered twice
13
+ // (`~/repo` and `~/repo/packages/app`, the ordinary shape for a tenant repo added by path),
14
+ // and those two ids share one HEAD, one index, one set of files.
15
+ // • `repo_key` — the repository, i.e. its MAIN worktree. Too wide: it deliberately groups
16
+ // a repo's linked worktrees, which are exactly the sessions that are SAFELY isolated. Warning
17
+ // on those is how a warning gets trained away.
18
+ // • the working-tree top (`git rev-parse --show-toplevel`) — one HEAD, one index, one
19
+ // `index.lock`. This is the one. A folder with no git has no tree key, and therefore no
20
+ // co-tenants: there is no index to corrupt, so there is nothing to say.
21
+ //
22
+ // Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
23
+ // registry, and the rules are the part worth testing without either.
24
+
25
+ /** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
26
+ function isLive(s) { return !!s && s.state !== 'exited'; }
27
+
28
+ /**
29
+ * The live sessions sharing `tree`, excluding one id (normally the session asking).
30
+ * `treeOfWs` maps a workspace id → its working-tree top (null for a folder with no git).
31
+ * A null/empty `tree` matches nothing — "not in a repo" is never a group.
32
+ */
33
+ function coTenants(sessions, treeOfWs, tree, exclude) {
34
+ if (!tree) return [];
35
+ const out = [];
36
+ for (const s of sessions || []) {
37
+ if (!isLive(s) || s.id === exclude) continue;
38
+ if (treeOfWs.get(s.ws) === tree) out.push(s);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Who holds the writer claim on `tree` — or null when nobody does.
45
+ *
46
+ * The claim is stored, but it is only BELIEVED while its holder is live: a claim is a statement
47
+ * about a running session, and a dead session cannot be driving anything. Deriving the answer this
48
+ * way is what makes "the holder exited" read as **vacant** rather than as a stale name, without a
49
+ * cleanup pass having to be correct for the UI to be honest.
50
+ *
51
+ * Note what this deliberately does NOT do: promote the oldest survivor. Which pane should drive is
52
+ * the PM's intention, not an observable, and a derived answer must never impersonate a decision.
53
+ */
54
+ function writerOf(claims, treeOfWs, sessions, tree) {
55
+ if (!tree) return null;
56
+ const held = claims.get(tree);
57
+ if (!held) return null;
58
+ const s = (sessions || []).find((x) => x && x.id === held);
59
+ if (!isLive(s) || treeOfWs.get(s.ws) !== tree) return null;
60
+ return held;
61
+ }
62
+
63
+ /** 'writer' | 'reader' for a session in a tree; null when it is not in one (no git → no role). */
64
+ function roleOf(sessionId, tree, writerId) {
65
+ if (!tree) return null;
66
+ return sessionId === writerId ? 'writer' : 'reader';
67
+ }
68
+
69
+ /** Name a handful of panes without letting a 12-session tree produce a paragraph. */
70
+ function nameList(rows, cap = 3) {
71
+ const names = (rows || []).map((s) => `“${s.name}”`);
72
+ if (names.length <= cap) return names.join(', ');
73
+ return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
74
+ }
75
+
76
+ /**
77
+ * The sentence a session gets when it starts in a tree somebody is already working in.
78
+ *
79
+ * It names the panes (a bare count tells the PM nothing they can act on), states the ONE fact that
80
+ * makes this matter — a shared branch, index and file set — and names the way out that already
81
+ * exists, because a warning with no alternative is just a nag. Returns null when there is nobody
82
+ * there, so the caller never has to test for emptiness.
83
+ */
84
+ function coTenancyNotice(mates, opts) {
85
+ const rows = mates || [];
86
+ if (!rows.length) return null;
87
+ const n = rows.length;
88
+ const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
89
+ const held = opts && opts.writerName;
90
+ const who = role === 'writer'
91
+ // Vacant tree: this session took the claim. Say so — the PM should know a declaration was made
92
+ // on their behalf, not discover it later in a list.
93
+ ? 'nobody had claimed it, so this session now holds the writer claim'
94
+ : held
95
+ ? `“${held}” holds the writer claim`
96
+ : 'no session holds the writer claim';
97
+ return `${n} session${n === 1 ? '' : 's'} already running in this working tree (${nameList(rows)}) — `
98
+ + `you share one branch, one index and one set of files with ${n === 1 ? 'it' : 'them'}. ${who}. `
99
+ + 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the '
100
+ + 'files. To work in parallel for real, start the session on a new branch with its own folder (⑂).';
101
+ }
102
+
103
+ /**
104
+ * The same fact, written into the pane's own scrollback at spawn.
105
+ *
106
+ * The notice frame is a transient line in whichever client was listening; this one is part of the
107
+ * session's record, so re-opening the tab tomorrow still shows what it was told. Dim, one line,
108
+ * and it never claims anything was prevented.
109
+ */
110
+ function paneAdvisory(mates, opts) {
111
+ const line = coTenancyNotice(mates, opts);
112
+ if (!line) return null;
113
+ return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
114
+ }
115
+
116
+ /**
117
+ * Carry two non-fatal truths on a channel that has only ever carried one.
118
+ *
119
+ * `spawnWithBranch` returns a single `notice` string and, until now, at most one of its facts could
120
+ * be set at a time. A branch notice and a co-tenancy notice can both be true of the same spawn, and
121
+ * the second must not evict the first — dropping one would make the wire format quietly lossy in
122
+ * exactly the situation the PM most needs both.
123
+ */
124
+ function joinNotices(...parts) {
125
+ const kept = parts.filter((p) => p != null && String(p).trim() !== '');
126
+ return kept.length ? kept.join(' · ') : null;
127
+ }
128
+
129
+ module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.17.0",
9
+ "version": "0.18.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -71,6 +71,7 @@ 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 treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
74
75
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
75
76
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
76
77
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
@@ -312,11 +313,18 @@ function repoIdentity(root) {
312
313
  const now = Date.now();
313
314
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
314
315
  let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
315
- let remote = null;
316
+ let remote = null, tree = null;
316
317
  try {
317
318
  const trees = gitInfo.worktrees(root);
318
319
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
319
320
  } catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
321
+ // The THIRD key, and the only one that answers "would these two sessions collide": the working
322
+ // tree this workspace sits in — one HEAD, one index, one set of files. `key` above is the
323
+ // REPOSITORY (its main worktree), which deliberately groups a repo's linked worktrees; those are
324
+ // exactly the sessions that are safely isolated, so it is the wrong key for collisions. Null for
325
+ // a folder with no git, and null is meaningful: no index, nothing to collide over. Memoised in
326
+ // this same entry because `wsList()` runs on every workspace push and this is a third shell-out.
327
+ try { tree = gitInfo.repoRoot(root); } catch { tree = null; }
320
328
  // Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
321
329
  // another `git` shell-out on a call that runs for every workspace on every push. A failure is not
322
330
  // a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
@@ -333,10 +341,62 @@ function repoIdentity(root) {
333
341
  // one drifts.
334
342
  else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
335
343
  } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
336
- const val = { at: now, key, name, branch, branchError, branchCode, remote };
344
+ const val = { at: now, key, name, branch, branchError, branchCode, remote, tree };
337
345
  repoKeyCache.set(root, val);
338
346
  return val;
339
347
  }
348
+
349
+ // ── Shared-tree co-tenancy (workspace-session-concurrency) ────────────────────────────────────
350
+ // The rules live in lib/tree-share.js, pure and unit-tested; what lives here is the two things the
351
+ // server owns — the git answer (via the memoised identity above) and the session registry.
352
+ //
353
+ // Held claims, tree top → session id. Only ever BELIEVED while its holder is live (treeShare.
354
+ // writerOf re-checks), so an exited writer leaves the tree vacant rather than leaving a stale name
355
+ // on it. Nothing is promoted automatically: which pane drives is the PM's intention, not ours.
356
+ const treeClaims = new Map();
357
+ /** workspace id → its working-tree top (null when the folder is not in a repo). */
358
+ function treeOfWsMap() {
359
+ const m = new Map();
360
+ for (const w of workspaces.values()) m.set(w.id, repoIdentity(w.root).tree || null);
361
+ return m;
362
+ }
363
+ function treeForWs(wsIdent) {
364
+ const w = wsIdent && workspaces.get(wsIdent);
365
+ return w ? (repoIdentity(w.root).tree || null) : null;
366
+ }
367
+ /** Live sessions sharing a tree, excluding one id. Rows are the registry's own objects. */
368
+ function treeTenants(tree, excludeId) {
369
+ return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
370
+ }
371
+ function treeWriter(tree) {
372
+ return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
373
+ }
374
+ /**
375
+ * A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
376
+ * found into its own scrollback. The pane line is deliberately part of the session's record rather
377
+ * than only a transient notice frame — re-open the tab tomorrow and it still says what it was told.
378
+ * Returns the co-tenants so the caller can put the same fact on the wire.
379
+ */
380
+ function announceTree(s) {
381
+ const tree = treeForWs(s.ws);
382
+ s.tree = tree || null;
383
+ if (!tree) return [];
384
+ const mates = treeTenants(tree, s.id);
385
+ const holder = treeWriter(tree);
386
+ if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
387
+ if (!mates.length) return []; // alone in the tree: nothing to say
388
+ const held = holder ? (sessions.get(holder) || {}).name : null;
389
+ const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
390
+ if (line) appendBuf(s, line);
391
+ return mates;
392
+ }
393
+ /** The sentence for the spawn's notice frame — same facts as the pane line, same source. */
394
+ function treeNoticeFor(s, mates) {
395
+ if (!mates || !mates.length) return null;
396
+ const holder = treeWriter(s.tree);
397
+ const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
398
+ return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
399
+ }
340
400
  function wsList() {
341
401
  return [...workspaces.values()].map((w) => {
342
402
  const rid = repoIdentity(w.root);
@@ -1472,7 +1532,16 @@ function sessionList() {
1472
1532
  // failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
1473
1533
  const pread = readProfilesNow();
1474
1534
  const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
1475
- return [...sessions.values()].map((s) => {
1535
+ // ONE tree map and ONE writer lookup per broadcast, for the same reason profiles are read once:
1536
+ // the heartbeat runs every 2s and a per-row `git rev-parse` would be a shell-out per pane per
1537
+ // tick. `repoIdentity` is memoised, but the map is still built once and reused.
1538
+ const rowsAll = [...sessions.values()];
1539
+ const treeOfWs = treeOfWsMap();
1540
+ const writerByTree = new Map();
1541
+ for (const t of new Set([...treeOfWs.values()].filter(Boolean))) {
1542
+ writerByTree.set(t, treeShare.writerOf(treeClaims, treeOfWs, rowsAll, t));
1543
+ }
1544
+ return rowsAll.map((s) => {
1476
1545
  const d = harness.deriveState(s.state, s.lastOutputAt, now);
1477
1546
  const h = harnessOf(s);
1478
1547
  const hx = transcriptHarness(s);
@@ -1504,6 +1573,18 @@ function sessionList() {
1504
1573
  // BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
1505
1574
  // profile descriptor"), which is the honest answer. Resolving it to `null` instead would
1506
1575
  // print "none was chosen" over a session that did choose one (R9).
1576
+ // ── the shared working tree, and the DECLARED writer on it ──
1577
+ // `tree` is the collision unit (one HEAD, one index) — null for a folder with no git, and
1578
+ // null is the honest answer there rather than a group of one. `tree_role` is 'writer' for the
1579
+ // one session that holds the claim, 'reader' for every other session in that tree, and null
1580
+ // where there is no tree. `tree_peers` counts the OTHER live sessions sharing it. All three
1581
+ // are advisory: PMOS hosts the terminal, it does not sit between the agent and the files.
1582
+ tree: treeOfWs.get(s.ws) || null,
1583
+ // an exited pane holds no role: a corpse is neither writing nor waiting to.
1584
+ tree_role: treeShare.isLive(s)
1585
+ ? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
1586
+ : null,
1587
+ tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
1507
1588
  profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1508
1589
  profile_line: profilesLib.profileLine(
1509
1590
  !('profile' in s) ? undefined
@@ -1922,6 +2003,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
1922
2003
  s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
1923
2004
  persist(s); pushSessions(); pushWorkspaces(); return s;
1924
2005
  }
2006
+ announceTree(s); // before attach, so the advisory is the first line of the pane's record
1925
2007
  attachPty(s);
1926
2008
  feed(s);
1927
2009
  persist(s); pushSessions(); pushWorkspaces(); return s;
@@ -1940,6 +2022,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
1940
2022
  pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
1941
2023
  lastOutputAt: Date.now() };
1942
2024
  sessions.set(id, s);
2025
+ announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
1943
2026
  p.onData((d) => {
1944
2027
  appendBuf(s, d);
1945
2028
  for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
@@ -2017,7 +2100,16 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2017
2100
  }
2018
2101
  }
2019
2102
  const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
2020
- return { ok: true, session: s, notice };
2103
+ // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2104
+ // block above. The isolate default already computes that other sessions would be disturbed — and
2105
+ // then the whole git block is skipped when no branch was picked, which is the common path and the
2106
+ // one the PM has been living in. It said nothing. Now it says what it already knew.
2107
+ //
2108
+ // Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
2109
+ // notice can both be true of one spawn. joinNotices keeps them both rather than letting the
2110
+ // second evict the first exactly when the PM most needs both.
2111
+ const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
2112
+ return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
2021
2113
  }
2022
2114
 
2023
2115
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
@@ -2033,7 +2125,11 @@ function worktreeReport(w) {
2033
2125
  const rows = trees.map((t, i) => {
2034
2126
  const main = i === 0;
2035
2127
  const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
2036
- const live = owner ? liveSessionCount(owner.id) : 0;
2128
+ // Counted by TREE, not by workspace id. A session running in a workspace registered at a
2129
+ // SUBDIRECTORY of this tree (`repo/packages/app` — the ordinary tenant-repo shape) is a session
2130
+ // whose files this removal would pull out from under it, and the old workspace-id count could
2131
+ // not see it: two ids, one checkout. `owner` is still the row's workspace for the client.
2132
+ const live = treeTenants(path.resolve(t.dir), null).length;
2037
2133
  // the main worktree is the repo itself — never removable from here, and saying so beats an
2038
2134
  // absent button the PM has to reason about
2039
2135
  const gone = !main && !isDir(t.dir);
@@ -2137,6 +2233,10 @@ function removeSession(id) {
2137
2233
  if (!s || s.pty) return false;
2138
2234
  if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
2139
2235
  forgetScratchpad(s); // the tab's staging text goes with the tab
2236
+ // Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
2237
+ // believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
2238
+ // map that only grows is a leak waiting to be found by someone else.
2239
+ if (s.tree && treeClaims.get(s.tree) === id) treeClaims.delete(s.tree);
2140
2240
  sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
2141
2241
  return true;
2142
2242
  }
@@ -2947,6 +3047,25 @@ async function handleRequest(req, res) {
2947
3047
  return serveFile(res, file, null, true);
2948
3048
  }
2949
3049
 
3050
+ if (p === '/api/tree/claim' && req.method === 'POST') {
3051
+ // Hand the writer claim to a named session. The ONLY way a claim moves: never on a timer, never
3052
+ // on an exit, never on a heuristic. Which pane should be driving is the PM's intention, and a
3053
+ // derived answer must not impersonate a decision — so an exited holder leaves the tree vacant
3054
+ // and this endpoint is how it stops being vacant.
3055
+ let body = {};
3056
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3057
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3058
+ const s = body.session && sessions.get(String(body.session));
3059
+ if (!s) return json(res, { ok: false, error: 'no such session' }, 400);
3060
+ if (!treeShare.isLive(s)) return json(res, { ok: false, error: 'that session has exited — an exited pane cannot hold a claim' }, 400);
3061
+ const tree = treeForWs(s.ws);
3062
+ if (!tree) return json(res, { ok: false, error: 'that session is not in a git working tree — there is nothing to claim' }, 400);
3063
+ const prev = treeWriter(tree);
3064
+ treeClaims.set(tree, s.id);
3065
+ pushSessions();
3066
+ return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
3067
+ note: 'a declaration, not a guarantee — nothing about either process changed' });
3068
+ }
2950
3069
  if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
2951
3070
  if (p === '/api/workspaces' && req.method === 'POST') {
2952
3071
  // How `pmos` in another repo becomes another workspace (R1). Local-only, like every
@@ -3393,6 +3512,27 @@ async function handleRequest(req, res) {
3393
3512
  if (!w) return json(res, { ok: false, error: 'no workspace' });
3394
3513
  return json(res, worktreeReport(w));
3395
3514
  }
3515
+ if (p === '/api/tree/peers') {
3516
+ // Who this workspace's sessions actually share mutable git state with, and who is declared to
3517
+ // be driving. `advisory` is a FIELD, not a footnote: a caller that renders this must not be
3518
+ // able to read it as enforcement without having read the word.
3519
+ const w = getWs(url.searchParams.get('ws'));
3520
+ if (!w) return json(res, { ok: false, error: 'no workspace' });
3521
+ const tree = repoIdentity(w.root).tree || null;
3522
+ if (!tree) {
3523
+ return json(res, { ok: true, tree: null, writer: null, sessions: [], advisory: true,
3524
+ note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
3525
+ }
3526
+ const writer = treeWriter(tree);
3527
+ const rows = treeTenants(tree, null).map((s) => ({
3528
+ id: s.id, name: s.name, ws: s.ws, agent: s.agent, state: s.state,
3529
+ role: treeShare.roleOf(s.id, tree, writer),
3530
+ }));
3531
+ return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
3532
+ note: writer
3533
+ ? 'the writer claim is a declaration, not a guarantee — PMOS hosts the terminal, it does not sit between the agent and the files'
3534
+ : 'no session holds the writer claim on this tree' });
3535
+ }
3396
3536
  // The pad belongs to a SESSION when one is named — and the workspace then comes from that
3397
3537
  // session, not from the caller, so a stale `ws` can never file one tab's text under another
3398
3538
  // repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
@@ -4147,6 +4287,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
4147
4287
  // non-repo folder from a real git fault, and the whole legibility defect was that distinction
4148
4288
  // being dropped one layer above it.
4149
4289
  repoIdentity, wsList,
4290
+ // the shared-tree co-tenancy layer — exported so the suite can assert the KEY in both directions
4291
+ // (one repo registered twice is one tree; two worktrees of one repo are two) without driving
4292
+ // every case through a spawn.
4293
+ treeForWs, treeTenants, treeWriter, treeClaims,
4150
4294
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
4151
4295
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
4152
4296
  scrub, harnessOf,