create-pmos 0.18.1 → 0.19.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.19.0
337
+
338
+ **A long document scrolls its own panel, and starting a session asks what is already running.** Run
339
+ `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **Opening a long document no longer scrolls the whole window.** Scrolling a document in the right
342
+ panel moved the entire interface — the top bar slid away, the status bar rose from below — while
343
+ the panel's own scrollbar never engaged. Every panel now scrolls inside itself and the app frame
344
+ never moves. (If you are curious: the shell's middle row was written `1fr`, which is
345
+ `minmax(auto, 1fr)`, so a tall document enlarged the row rather than overflowing it. Every style
346
+ involved read as correct, which is why it survived review.)
347
+ - **`+ session` now asks before it starts one, when the checkout is already busy.** It used to
348
+ start the session and *then* tell you how many others were in there — a count arriving after the
349
+ decision, naming something you cannot act on. Now you get a chooser first, listing every session
350
+ in that checkout.
351
+ - **It says what each one is.** Its name, its agent, the same word the sidebar uses (`working` ·
352
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's own last words, and who holds the writer
353
+ claim.
354
+ - **…and what ending it would cost, without over-claiming.** Only `needs you` reads as settled —
355
+ its turn finished and it is waiting on you. `working` says output is arriving right now. `quiet`
356
+ and `maybe blocked` say, in as many words, that the workspace *cannot tell* a finished turn from a
357
+ permission prompt from a stuck tool. A confident "safe to end" that discarded a running turn would
358
+ be worse than no reading at all.
359
+ - **You can take the running session instead of making another.** `open it` attaches a tab to it and
360
+ starts nothing. `end it` is there too — the first click arms it and names what it will end, the
361
+ second does it. `+ start another session here anyway` is always one click: being told is never
362
+ being blocked, and no session is ever refused because another is there.
363
+ - **The same detail is written into the new session's scrollback**, so scrolling back tomorrow
364
+ answers the same question the chooser did.
365
+
336
366
  ## What's new in 0.18.1
337
367
 
338
368
  **Three fixes to the workspace terminal, all of them about what the window knew about a session.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.18.1",
3
+ "version": "0.19.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@9fee84a
1
+ kernel-source: pmos@181425a
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -162,6 +162,24 @@ session in a tree that already has live ones now says so in the pane — naming
162
162
  way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
163
163
  git operation runs at all: the server already knew, and used to say nothing.
164
164
 
165
+ **`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
166
+ act on, and the answer you usually want is not "start another" at all — it is "which of those is the
167
+ one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
168
+ first, listing every session in that tree with:
169
+
170
+ - **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
171
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
172
+ - **what ending it would cost** — one sentence per session, worded from the evidence. Only
173
+ `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
174
+ `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
175
+ discards a running turn is worse than no reading at all;
176
+ - **`open it`** — attach a tab to that session and start nothing.
177
+
178
+ `end it` is there too, and is the only destructive control on the surface: two clicks, the first
179
+ arming it. `+ start another session here anyway` is always one click away — informing you is never
180
+ obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
181
+ back tomorrow answers the same question the chooser did.
182
+
165
183
  **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
166
184
  `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
167
185
  its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
@@ -178,7 +196,9 @@ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not s
178
196
  the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
179
197
  because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
180
198
  (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
181
- not wired yet.)
199
+ not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
200
+ chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
201
+ each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
182
202
 
183
203
  A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
184
204
  counts, which matters for a product repo added by path. A folder with **no git** simply says so and
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
- // Who shares a working tree with whom, and who is DECLARED to be driving it.
2
+ // Who shares a working tree with whom, who is DECLARED to be driving it, and — since
3
+ // workspace-session-choice — what each of those sessions is actually DOING and what ending it
4
+ // would cost.
3
5
  //
4
6
  // The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
5
7
  // inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
@@ -21,6 +23,23 @@
21
23
  //
22
24
  // Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
23
25
  // registry, and the rules are the part worth testing without either.
26
+ //
27
+ // ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
28
+ // This file is now a UMD, for the same reason lib/session-state.js is: the SERVER computes the
29
+ // co-tenancy sentence at spawn, and the BROWSER computes the same descriptions again when the PM
30
+ // clicks `+ session` into an occupied tree. A second copy of "what does ending this cost?" would
31
+ // be free to drift, and the two surfaces would end up holding different opinions about one
32
+ // session — the exact failure the session-state table was extracted to stop.
33
+ //
34
+ // What this module deliberately does NOT do is name a session's STATE. The vocabulary (working ·
35
+ // quiet · needs you · maybe blocked · exited) belongs to lib/session-state.js and arrives here
36
+ // already worded, on the row, as `shown` + `label`. Re-deriving a word here would be that second
37
+ // copy in a new costume.
38
+ (function (root, factory) {
39
+ const api = factory();
40
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
41
+ else root.PMOSTreeShare = api;
42
+ })(typeof self !== 'undefined' ? self : this, function () {
24
43
 
25
44
  /** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
26
45
  function isLive(s) { return !!s && s.state !== 'exited'; }
@@ -73,18 +92,138 @@ function nameList(rows, cap = 3) {
73
92
  return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
74
93
  }
75
94
 
95
+ /** The same list, each pane carrying the word beside it — `label` when the caller supplied one. */
96
+ function statedList(rows, cap = 3) {
97
+ const parts = (rows || []).map((s) => (s && s.label ? `“${s.name}” (${s.label})` : `“${s.name}”`));
98
+ if (parts.length <= cap) return parts.join(', ');
99
+ return `${parts.slice(0, cap).join(', ')} and ${parts.length - cap} more`;
100
+ }
101
+
102
+ /** A terse age, in the voice the sidebar already uses. `null` in, `null` out — never "0s". */
103
+ function ago(ms) {
104
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
105
+ if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))}s`;
106
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
107
+ return `${Math.round(ms / 360000) / 10}h`;
108
+ }
109
+
110
+ /**
111
+ * WHAT ENDING THIS SESSION WOULD COST — read only from what the workspace can actually observe.
112
+ *
113
+ * The PM's question, asked in as many words, is "is it safe to end?". The honest answer has three
114
+ * shapes and only one of them is a yes, so the levels are named for the EVIDENCE rather than for a
115
+ * recommendation:
116
+ *
117
+ * 'in-flight' — bytes are arriving right now. Ending it cuts a turn that is running. Not safe,
118
+ * and this one PMOS is sure of.
119
+ * 'settled' — the turn finished and the session is waiting on the human (`needs`). This is the
120
+ * only state where "nothing is in flight" is CORROBORATED — session-state reaches
121
+ * `needs` only from a transcript with no unresolved tool call, over a quiet clock.
122
+ * 'unknown' — quiet, or a tool call open with nothing moving. The workspace cannot tell a
123
+ * finished turn from a permission prompt from a hung tool, and this is the state it
124
+ * must not launder into a yes. Saying "probably idle" is the whole point: an
125
+ * over-confident "safe" here is the watermelon this repo has a word for.
126
+ * 'gone' — already exited; there is nothing left to end.
127
+ *
128
+ * `shown` is the presented state from lib/session-state.js. An unrecognised value falls to
129
+ * 'unknown', because a state this module has never heard of is precisely not a state it may
130
+ * declare safe.
131
+ */
132
+ function endRisk(shown, quietMs) {
133
+ const age = ago(quietMs);
134
+ if (shown === 'exited') {
135
+ return { level: 'gone', why: 'already exited — there is nothing left to end' };
136
+ }
137
+ if (shown === 'working') {
138
+ return { level: 'in-flight', why: 'output is arriving right now — ending it cuts a turn that is still running' };
139
+ }
140
+ if (shown === 'needs') {
141
+ return {
142
+ level: 'settled',
143
+ why: 'its turn finished and it is waiting on you — ending it costs the scrollback, not work in flight',
144
+ };
145
+ }
146
+ if (shown === 'blocked') {
147
+ return {
148
+ level: 'unknown',
149
+ why: `a tool call has been open${age ? ` ${age}` : ''} with nothing moving — PMOS cannot tell a permission prompt from a stuck tool, so ending it may or may not discard work`,
150
+ };
151
+ }
152
+ return {
153
+ level: 'unknown',
154
+ why: `nothing has moved${age ? ` for ${age}` : ''}, and PMOS cannot see whether the turn finished — read this as “probably idle”, not “safe”`,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * One co-tenant, described for a human who is deciding between joining it and starting another.
160
+ *
161
+ * Everything here is either copied off the row or derived by `endRisk`; nothing is invented. In
162
+ * particular `doing` is the agent's own last words as the sidebar already shows them, and it stays
163
+ * null when the workspace could not read them rather than becoming a cheerful placeholder.
164
+ */
165
+ function describeTenant(row, writerId) {
166
+ const s = row || {};
167
+ const shown = s.shown || (s.state === 'exited' ? 'exited' : 'working');
168
+ return {
169
+ id: s.id || null,
170
+ ws: s.ws || null,
171
+ name: s.name || '(unnamed)',
172
+ agent: s.agent || null,
173
+ shown,
174
+ label: s.label || shown,
175
+ doing: s.doing || null,
176
+ branch: s.branch || null,
177
+ writer: !!(writerId && s.id === writerId),
178
+ risk: endRisk(shown, s.quietMs),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The whole answer to "you are about to start a session in a tree that is already busy".
184
+ *
185
+ * Returned as DATA rather than as a paragraph, because the two consumers need different shapes of
186
+ * it: the server writes a sentence into the pane's scrollback, and the browser draws a chooser with
187
+ * a button per peer. Both read the same `peers`, so a peer the chooser offers and a peer the pane
188
+ * line names can never be different sets.
189
+ */
190
+ function coTenancyReport(mates, opts) {
191
+ const o = opts || {};
192
+ const peers = (mates || []).map((m) => describeTenant(m, o.writerId));
193
+ const n = peers.length;
194
+ return {
195
+ tree: o.tree || null,
196
+ branch: o.branch || null,
197
+ peers,
198
+ headline: n
199
+ ? `${n} session${n === 1 ? '' : 's'} already running in this working tree`
200
+ : 'nothing else is running in this working tree',
201
+ // Stated once, here, so every surface that renders this report carries the same limit.
202
+ shared: 'you share one branch, one index and one set of files with '
203
+ + (n === 1 ? 'it' : 'them'),
204
+ advisory: 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the files.',
205
+ escape: 'To work in parallel for real, start the session on a new branch with its own folder (⑂).',
206
+ };
207
+ }
208
+
76
209
  /**
77
210
  * The sentence a session gets when it starts in a tree somebody is already working in.
78
211
  *
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.
212
+ * It names the panes AND what each of them is doing — a name alone tells the PM which pane, but not
213
+ * whether walking over to it is a good idea. `label` is the sidebar's own word for the session
214
+ * (working · quiet 12m · needs you · maybe blocked), passed in rather than re-derived, so this
215
+ * sentence and the sidebar cannot disagree. Rows with no label degrade to bare names rather than
216
+ * inventing a state.
217
+ *
218
+ * It states the ONE fact that makes this matter — a shared branch, index and file set — and names
219
+ * the way out that already exists, because a warning with no alternative is just a nag. Returns
220
+ * null when there is nobody there, so the caller never has to test for emptiness.
83
221
  */
84
222
  function coTenancyNotice(mates, opts) {
85
223
  const rows = mates || [];
86
224
  if (!rows.length) return null;
87
225
  const n = rows.length;
226
+ const rep = coTenancyReport(rows, opts);
88
227
  const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
89
228
  const held = opts && opts.writerName;
90
229
  const who = role === 'writer'
@@ -94,23 +233,40 @@ function coTenancyNotice(mates, opts) {
94
233
  : held
95
234
  ? `“${held}” holds the writer claim`
96
235
  : '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 (⑂).';
236
+ return `${rep.headline} (${statedList(rows)}) — ${rep.shared}. ${who}. `
237
+ + `${rep.advisory} ${rep.escape} `
238
+ + `To join ${n === 1 ? 'it' : 'one of them'} instead of starting another, open it from + session — `
239
+ + 'the chooser says what each one is doing and what ending it would cost.';
101
240
  }
102
241
 
103
242
  /**
104
243
  * The same fact, written into the pane's own scrollback at spawn.
105
244
  *
106
245
  * 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.
246
+ * session's record, so re-opening the tab tomorrow still shows what it was told. Dim, and it never
247
+ * claims anything was prevented.
248
+ *
249
+ * It is no longer ONE line. The sentence is followed by a row per peer carrying that peer's state
250
+ * and what ending it would cost — the same `peers` the chooser draws, in the same order, because
251
+ * the PM who scrolls back to this tomorrow is asking the identical question they asked at the
252
+ * chooser and deserves the identical answer. Bounded at four rows so a twelve-session tree cannot
253
+ * push the shell's first prompt off the screen; the sentence above already says the true count.
109
254
  */
255
+ const ADVISORY_ROWS = 4;
110
256
  function paneAdvisory(mates, opts) {
111
257
  const line = coTenancyNotice(mates, opts);
112
258
  if (!line) return null;
113
- return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
259
+ const rep = coTenancyReport(mates, opts);
260
+ const shown = rep.peers.slice(0, ADVISORY_ROWS);
261
+ const rows = shown.map((p) => {
262
+ const who = `${p.name}${p.writer ? ' (writer)' : ''}`;
263
+ const doing = p.doing ? ` · ${p.doing}` : '';
264
+ return `\x1b[38;5;244m · ${who} — ${p.label}${doing}; ${p.risk.why}\x1b[0m\r\n`;
265
+ });
266
+ const more = rep.peers.length > shown.length
267
+ ? [`\x1b[38;5;244m · …and ${rep.peers.length - shown.length} more, listed in + session\x1b[0m\r\n`]
268
+ : [];
269
+ return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n` + rows.join('') + more.join('');
114
270
  }
115
271
 
116
272
  /**
@@ -126,4 +282,6 @@ function joinNotices(...parts) {
126
282
  return kept.length ? kept.join(' · ') : null;
127
283
  }
128
284
 
129
- module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
285
+ return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
286
+ nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
287
+ });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.18.1",
9
+ "version": "0.19.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.18.1",
3
+ "version": "0.19.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",
@@ -29,6 +29,12 @@
29
29
  holding different opinions about one session. Loaded before the module graph so a panel can
30
30
  bind from window.PMOSSessionState rather than re-typing a word. -->
31
31
  <script src="/lib/session-state.js"></script>
32
+ <!-- ONE module, TWO consumers, again (workspace-session-choice). Who shares a working tree, and
33
+ what ending one of those sessions would cost, is computed by the SERVER when it writes the
34
+ advisory into a new pane's scrollback and by the BROWSER when it draws the + session chooser.
35
+ Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
36
+ drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
37
+ <script src="/lib/tree-share.js"></script>
32
38
  <!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
33
39
  window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
34
40
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
@@ -83,9 +83,35 @@ function runningCount() {
83
83
 
84
84
  /* ── the shell, built once ─────────────────────────────────────────────────────────────────── */
85
85
 
86
+ /* ── Why every track and every column here is `minmax(0, …)` / `min-height:0` ─────────────────
87
+ *
88
+ * The shell is 100vh and nothing in it is supposed to scroll the PAGE — a panel scrolls, the app
89
+ * never does. That held right up until a long document was opened in panel 4, and then the whole
90
+ * user interface scrolled: the top bar slid away, the status bar came up from below, and the doc
91
+ * pane's own `overflow-y:auto` never engaged.
92
+ *
93
+ * The cause is one CSS default, in two guises, and it is worth naming precisely because it is
94
+ * invisible in a style string that looks correct:
95
+ *
96
+ * • `grid-template-rows: 44px 1fr 26px` — `1fr` is shorthand for `minmax(auto, 1fr)`, and that
97
+ * `auto` MINIMUM is the item's min-content height. A tall document therefore does not overflow
98
+ * the middle row; it ENLARGES it, past the 100vh the row was supposed to be trapped inside.
99
+ * • a grid item / flex item with `min-height: auto` cannot shrink below its content either, so
100
+ * even a correctly-sized track hands the overflow straight through to the next box down.
101
+ *
102
+ * Overflow that is `visible` still contributes to the SCROLLABLE overflow region of every ancestor
103
+ * that does not clip it, all the way up to the viewport — which is exactly the reported symptom:
104
+ * the page scrolls, not the pane. So the fix is the whole chain, not one box. Each ancestor between
105
+ * the viewport and a scrolling pane declares a zero minimum, and the root clips as the backstop so
106
+ * a future panel that forgets cannot reach the page again.
107
+ *
108
+ * The design prototype had this right (`grid-template-rows:44px minmax(0,1fr) 28px`, see
109
+ * prototype/workspace-layout-variants/*.html); the port dropped the `minmax(0, …)` and read
110
+ * identically until something tall was rendered into it.
111
+ */
86
112
  const root = el('div', {
87
113
  'data-pm-theme': state.theme,
88
- style: 'display:grid;grid-template-rows:44px 1fr 26px;height:100vh;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
114
+ style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
89
115
  });
90
116
 
91
117
  const themeBtn = el('button', {
@@ -113,7 +139,14 @@ const statusBar = el('div', {
113
139
  el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
114
140
  );
115
141
 
116
- const bodyGrid = el('div', { style: 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0' });
142
+ /* The body grid's own geometry, in one place so the initial build and every repaint below cannot
143
+ * disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
144
+ * middle track is: an auto row is floored at its items' min-content height, and one tall panel
145
+ * would push the grid taller than the box it was given. */
146
+ const BODY_GRID_STYLE = () =>
147
+ 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
148
+
149
+ const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
117
150
 
118
151
  /** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
119
152
  function makeRail(flag) {
@@ -131,10 +164,14 @@ const rail1 = makeRail('col1');
131
164
  const rail2 = makeRail('col2');
132
165
  rail0.label.textContent = 'repositories ⟩';
133
166
 
134
- const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0' });
135
- const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0' });
136
- const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;display:flex;flex-direction:column' });
137
- const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0' });
167
+ /* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
168
+ * than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
169
+ * two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
170
+ * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
171
+ const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
172
+ const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
173
+ const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
174
+ const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
138
175
 
139
176
  setChildren(root, [topBar, bodyGrid, statusBar]);
140
177
 
@@ -172,7 +209,7 @@ function render() {
172
209
  rail1.label.textContent = modeTitle() + ' ⟩';
173
210
  rail2.label.textContent = detailTitle() + ' ⟩';
174
211
 
175
- bodyGrid.setAttribute('style', 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0');
212
+ bodyGrid.setAttribute('style', BODY_GRID_STYLE());
176
213
 
177
214
  // On a phone there are no rails to collapse into and no second column to fold away: the four
178
215
  // hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
@@ -277,9 +277,13 @@ function evalFor(ladderSteps) {
277
277
 
278
278
  /** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
279
279
  function sessionRow(s) {
280
- const label = s.shown === 'quiet' && Number.isFinite(s.quietMs)
280
+ // `s.label` is the SERVER's rendering of the presented state — one join (lib/session-state's
281
+ // `label`), one clock, one sentence. This used to be re-derived here from `shown` + `quietMs`,
282
+ // which is how the sidebar and the pane could describe one session with two different words. The
283
+ // local derivation survives only as a fallback for a server that predates the field.
284
+ const label = s.label || (s.shown === 'quiet' && Number.isFinite(s.quietMs)
281
285
  ? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
282
- : (s.shown === 'needs' ? 'needs you' : s.shown);
286
+ : (s.shown === 'needs' ? 'needs you' : s.shown));
283
287
  return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
284
288
  }
285
289
 
@@ -441,10 +445,34 @@ function groupsFromSpine(spine) {
441
445
  const products = new Map(); // product name → {name, ws, root, count, board, records}
442
446
  let productOrder = [];
443
447
 
448
+ /* workspace id → the working-tree top it sits in (null for a folder with no git).
449
+ *
450
+ * This exists so panel 4 can answer "who else is already in the tree I am about to start a session
451
+ * in?" BEFORE it starts one. The session rows carry `tree` too, but only for trees that already
452
+ * have a session — which is one session too late for a chooser that runs on the FIRST click. The
453
+ * server derives this (`git rev-parse --show-toplevel`, via `repoIdentity`) and publishes it on
454
+ * /api/workspaces; it is read here and never re-derived. */
455
+ const wsTree = new Map();
456
+
457
+ /** The working tree a workspace id sits in, or null — for a folder with no git, or an unknown id. */
458
+ export function treeOfWorkspace(id) {
459
+ return (id && wsTree.get(id)) || null;
460
+ }
461
+
462
+ /** The branch that tree is on, or null. Shown beside the tree so a chooser names what is shared. */
463
+ const wsBranch = new Map();
464
+ export function branchOfWorkspace(id) {
465
+ return (id && wsBranch.get(id)) || null;
466
+ }
467
+
444
468
  /** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
445
469
  function foldWorkspaces(rows) {
446
470
  const byRepo = new Map();
447
471
  for (const w of rows || []) {
472
+ // Recorded per REGISTERED id, before the fold — folding is by repository, and the whole point
473
+ // of the tree key is that two registered ids can share one tree while a repo's two worktrees do
474
+ // not. Reading it off the folded row would lose exactly that distinction.
475
+ if (w && w.id) { wsTree.set(w.id, w.tree || null); wsBranch.set(w.id, w.branch || null); }
448
476
  const key = w.repo_key || w.root;
449
477
  if (!byRepo.has(key)) {
450
478
  byRepo.set(key, {
@@ -173,14 +173,68 @@ function nextSessionName() {
173
173
  return { n: max + 1, name: 'agent ' + (max + 1) };
174
174
  }
175
175
 
176
+ /* ── who is already in the tree this session would land in ───────────────────────────────────
177
+ *
178
+ * The workspace used to answer this AFTER the fact: it started the session, then wrote "2 sessions
179
+ * already running in this working tree" into the new pane. That sentence is true and arrives one
180
+ * step too late — the PM has already made the thing they might not have wanted, and the sentence
181
+ * names a count they cannot act on. Worse, the answer they usually want is not "start another"
182
+ * at all; it is "which of those is the one I was already working in?".
183
+ *
184
+ * So the same facts are computed BEFORE the spawn, from data the page already holds:
185
+ * • the tree the new session would land in — data.js publishes it per registered workspace id
186
+ * (`/api/workspaces` → `tree`), which is available even when that tree has no session yet;
187
+ * • the live sessions in it — `state.sessions`, each row carrying the server's own `tree`,
188
+ * `tree_role`, presented state and rendered label;
189
+ * • what ending each one would cost — lib/tree-share's `endRisk`, the same module the server uses
190
+ * for the pane advisory, so the two readings are one reading.
191
+ *
192
+ * Nothing is derived twice and nothing is guessed. When any input is missing — fixture mode, no
193
+ * workspace selected, a folder with no git, the shared module not loaded — this answers "nobody",
194
+ * and `+ session` behaves exactly as it always did. A chooser that appears on a bad guess would be
195
+ * worse than no chooser.
196
+ */
197
+ function treePeersNow() {
198
+ if (FIXTURE) return null;
199
+ const TS = typeof window !== 'undefined' ? window.PMOSTreeShare : null;
200
+ if (!TS) return null;
201
+ const ws = state.wsId || null;
202
+ const tree = ws && dataMod && typeof dataMod.treeOfWorkspace === 'function'
203
+ ? dataMod.treeOfWorkspace(ws) : null;
204
+ if (!tree) return null; // no git, or nothing selected — no index to share
205
+ const rows = Array.isArray(state.sessions) ? state.sessions : [];
206
+ // The map coTenants() keys on. Built from each row's OWN `tree`, which the server derived — the
207
+ // client never runs git and must not pretend to. The target workspace is added explicitly so a
208
+ // tree whose only registration has no session yet still resolves.
209
+ const treeOfWs = new Map([[ws, tree]]);
210
+ for (const r of rows) if (r && r.ws && !treeOfWs.has(r.ws)) treeOfWs.set(r.ws, r.tree || null);
211
+ const mates = TS.coTenants(rows, treeOfWs, tree, null);
212
+ if (!mates.length) return null;
213
+ // The declared writer, read off the row the server already marked rather than recomputed.
214
+ const held = rows.find((r) => r && r.tree === tree && r.tree_role === 'writer');
215
+ return {
216
+ tree,
217
+ branch: (dataMod && typeof dataMod.branchOfWorkspace === 'function' && dataMod.branchOfWorkspace(ws)) || null,
218
+ peers: mates.map((m) => TS.describeTenant(m, held ? held.id : null)),
219
+ };
220
+ }
221
+
176
222
  /**
177
223
  * `+ session` (line 2087): file a new session against the selected initiative and open its tab.
178
224
  * The session lands in `extraSess` — the prototype's own list of sessions started in this window —
179
225
  * and the terminal tab is opened in the callback, i.e. after the render that added it.
226
+ *
227
+ * `opts.force` skips the chooser. It is what the chooser's own "start another session here anyway"
228
+ * calls, so the guard is asked exactly once per click and a PM who has already answered the
229
+ * question is never asked it twice.
180
230
  */
181
- export function newSession(slug) {
231
+ export function newSession(slug, opts) {
182
232
  const target = slug || selSlug();
183
233
  if (!target) return;
234
+ if (!(opts && opts.force)) {
235
+ const found = treePeersNow();
236
+ if (found) { setState({ startPrompt: { slug: target, ...found }, endConfirm: null }); return; }
237
+ }
184
238
  const { n, name } = nextSessionName();
185
239
  /* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
186
240
  * and it is the only thing that authorises one. Without it the terminal resolves by name, which
@@ -188,6 +242,8 @@ export function newSession(slug) {
188
242
  const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
189
243
  setState((s) => ({
190
244
  startN: n + 1,
245
+ startPrompt: null,
246
+ endConfirm: null,
191
247
  extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
192
248
  }), () => openTermTab(target, sess));
193
249
  if (!FIXTURE && dataMod && typeof dataMod.startSession === 'function') {
@@ -195,6 +251,25 @@ export function newSession(slug) {
195
251
  }
196
252
  }
197
253
 
254
+ /** Join a session that is already running instead of starting one: open its tab, start nothing. */
255
+ export function joinSession(slug, peer) {
256
+ if (!peer) return;
257
+ setState({ startPrompt: null, endConfirm: null }, () => openTermTab(slug, {
258
+ name: peer.name, st: peer.shown, profile: peer.profile || null,
259
+ /* The SERVER's id, and NO `spawn`. Both halves matter and they are the exact difference
260
+ * between joining and starting: `id` is the join, so the tab attaches to precisely the PTY the
261
+ * PM pointed at rather than matching by a name that repeats across repositories; the absent
262
+ * `spawn` is what withholds authorisation to create one. A peer described by
263
+ * treeShare.describeTenant always carries its id, so this is never a name-match in practice. */
264
+ id: peer.id || null,
265
+ }));
266
+ }
267
+
268
+ /** Dismiss the chooser without starting or joining anything. */
269
+ export function dismissStartPrompt() {
270
+ setState({ startPrompt: null, endConfirm: null });
271
+ }
272
+
198
273
  /** `⫽ split` / `⫽ unsplit` (lines 2088–2092). */
199
274
  export function toggleSplit() {
200
275
  setState((s) => {
@@ -226,7 +301,7 @@ const emptyState = el('div', {
226
301
  style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
227
302
  }, "nothing open — click a task's output or an artifact, or + session above");
228
303
 
229
- const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
304
+ const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
230
305
 
231
306
  /* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
232
307
  * One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
@@ -432,9 +507,117 @@ function copyActive() {
432
507
  setTimeout(() => setState({ copied: false }), 1200);
433
508
  }
434
509
 
510
+ /* ── the + session chooser ──────────────────────────────────────────────────────────────────
511
+ *
512
+ * Shown INSTEAD of a spawn when the working tree already has live sessions. It answers, in this
513
+ * order, the three questions the old one-line notice left the PM holding:
514
+ *
515
+ * what is running? — every peer named, with the sidebar's own word for its state, the
516
+ * agent's last words, and the writer claim where one is held.
517
+ * is it safe to end? — one sentence per peer from lib/tree-share's `endRisk`, worded from the
518
+ * EVIDENCE. Only `needs you` gets a plain "nothing is in flight"; quiet
519
+ * and maybe-blocked say, in as many words, that the workspace cannot
520
+ * tell — because a confident "safe" that discards a running turn is
521
+ * exactly the false confidence this repo calls a watermelon.
522
+ * can I just use it? — `open it`, which attaches a tab to that session and starts NOTHING.
523
+ *
524
+ * `end it` is the one destructive control on this surface and it is two clicks: the first arms it
525
+ * and the button says what will happen, the second sends it. Nothing else here can lose work.
526
+ *
527
+ * Rebuilt on each render, deliberately: it holds no PTY and no caret, so there is nothing for a
528
+ * persistent node to protect, and a chooser rebuilt from state cannot show a session that has
529
+ * since exited.
530
+ */
531
+ const RISK_COLOR = {
532
+ 'in-flight': 'var(--warning)',
533
+ settled: 'var(--success)',
534
+ unknown: 'var(--muted-foreground)',
535
+ gone: 'var(--muted-foreground)',
536
+ };
537
+ const CH_BTN = 'flex:none;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;'
538
+ + 'background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);color:oklch(0.85 0.01 214);white-space:nowrap';
539
+
540
+ function peerRow(prompt, p) {
541
+ const armed = state.endConfirm === p.id;
542
+ return el('div', {
543
+ style: 'display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border:1px solid oklch(1 0 0 / 10%);background:oklch(1 0 0 / 3%)',
544
+ },
545
+ el('span', { style: 'flex:none;font-size:9px;line-height:18px;color:' + RISK_COLOR[p.risk.level] }, '●'),
546
+ el('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:2px' },
547
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
548
+ el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:oklch(0.95 0.01 214)' }, iv(p.name)),
549
+ el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.72 0.014 214)' }, iv(p.label)),
550
+ p.agent ? el('span', { style: 'font-family:var(--mono);font-size:10px;color:oklch(0.55 0.02 213)' }, iv(p.agent)) : null,
551
+ p.writer ? el('span', {
552
+ style: 'font-family:var(--mono);font-size:9.5px;color:var(--brand);border:1px solid oklch(1 0 0 / 12%);padding:1px 5px',
553
+ }, 'writer') : null,
554
+ ),
555
+ // The agent's own last words, when the workspace could read them. Absent rather than
556
+ // substituted: a placeholder here would be a claim about a session nobody read.
557
+ p.doing ? el('div', {
558
+ style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
559
+ }, iv(p.doing)) : null,
560
+ el('div', { style: 'font-size:11px;color:' + RISK_COLOR[p.risk.level] + ';line-height:1.45' }, iv(p.risk.why)),
561
+ ),
562
+ el('div', { style: 'flex:none;display:flex;gap:6px;align-items:center' },
563
+ el('button', {
564
+ onClick: () => joinSession(prompt.slug, p),
565
+ style: CH_BTN + ';color:var(--brand)', hover: CHIP_HOVER,
566
+ }, 'open it'),
567
+ el('button', {
568
+ onClick: () => {
569
+ if (!armed) { setState({ endConfirm: p.id }); return; }
570
+ if (termMod && typeof termMod.killSession === 'function') termMod.killSession(p.id);
571
+ setState({ endConfirm: null });
572
+ },
573
+ style: CH_BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''),
574
+ hover: CHIP_HOVER,
575
+ }, iv(armed ? 'end “' + p.name + '” — click again' : 'end it')),
576
+ ),
577
+ );
578
+ }
579
+
580
+ function startPromptBlock() {
581
+ const prompt = state.startPrompt;
582
+ if (!prompt) return null;
583
+ const peers = prompt.peers || [];
584
+ const n = peers.length;
585
+ return el('div', {
586
+ style: 'flex:none;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px;'
587
+ + 'padding:12px 14px;border-bottom:1px solid oklch(1 0 0 / 10%);background:oklch(0.11 0.004 228.8)',
588
+ },
589
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
590
+ el('span', { style: 'font-size:12.5px;color:oklch(0.95 0.01 214)' },
591
+ iv('⑂ ' + n + ' session' + (n === 1 ? '' : 's') + ' already running in this working tree')),
592
+ el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.55 0.02 213);overflow-wrap:anywhere' },
593
+ iv(prompt.tree + (prompt.branch ? ' · ' + prompt.branch : ''))),
594
+ ),
595
+ el('div', { style: 'font-size:11.5px;color:oklch(0.72 0.014 214);line-height:1.5' },
596
+ iv('You would share one branch, one index and one set of files with '
597
+ + (n === 1 ? 'it' : 'them') + '. Open one instead, end one, or start another anyway.')),
598
+ peers.map((p) => peerRow(prompt, p)),
599
+ el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;padding-top:2px' },
600
+ el('button', {
601
+ onClick: () => newSession(prompt.slug, { force: true }),
602
+ style: CH_BTN, hover: CHIP_HOVER,
603
+ }, '+ start another session here anyway'),
604
+ el('button', { onClick: () => dismissStartPrompt(), style: CH_BTN, hover: CHIP_HOVER }, 'cancel'),
605
+ ),
606
+ // The limit, where the PM meets the feature — never only in a comment. PMOS hosts the terminal;
607
+ // it does not sit between an agent and the files, and no button above changes that.
608
+ el('div', { style: 'font-size:10.5px;color:oklch(0.52 0.02 213);line-height:1.5' },
609
+ 'Nothing here is enforced: PMOS hosts your agent’s terminal, it does not sit between it and '
610
+ + 'the files. To work in parallel for real, start on a new branch with its own folder (⑂).'),
611
+ );
612
+ }
613
+
435
614
  /* ── pane hosts ────────────────────────────────────────────────────────────────────────────── */
436
615
 
437
- const PANE_STYLE = 'flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
616
+ /* `min-height:0` is load-bearing, not tidiness. A pane is a flex COLUMN whose body declares
617
+ * `flex:1;overflow-y:auto` — and a flex item's automatic minimum is its content, so without this
618
+ * the pane grows to fit a long document instead of letting the document scroll inside it, and the
619
+ * growth escapes upward until the whole page scrolls. See the chain documented in pm/app.js. */
620
+ const PANE_STYLE = 'flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
438
621
  const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
439
622
 
440
623
  function paneFor(tab) {
@@ -618,7 +801,12 @@ export function renderTabs(host) {
618
801
 
619
802
  setChildren(host, [
620
803
  tabBar,
621
- visTabs.length === 0 ? emptyState : null,
804
+ // Above the panes and below the tab bar: the chooser is about the click that was just made in
805
+ // that bar, and putting it here leaves every open pane — and every live PTY — exactly where it
806
+ // was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
807
+ // the persistent pane hosts exist to prevent.
808
+ startPromptBlock(),
809
+ visTabs.length === 0 && !state.startPrompt ? emptyState : null,
622
810
  panesRow,
623
811
  state.scratchOpen ? scratchBlock : null,
624
812
  bottomBar,
@@ -38,6 +38,12 @@ function initial() {
38
38
  tabs: [], active: null, splitId: null,
39
39
  initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
40
40
  extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
41
+ /* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
42
+ * spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
43
+ * otherwise. `endConfirm` is the id of the one peer whose `end it` has been armed — a
44
+ * second click ends it, and anything else disarms. Neither is persisted: a chooser that
45
+ * reappeared on reload would be a question about a moment that has passed. */
46
+ startPrompt: null, endConfirm: null,
41
47
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
42
48
  flowsMap: null, schedList: null, agentList: null, memory: null,
43
49
  savedDocs: {}, scratchCopied: false,
@@ -148,7 +148,7 @@ function viewBody(text) {
148
148
  const fm = [];
149
149
  doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
150
150
  return el('div', {
151
- style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
151
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
152
152
  },
153
153
  el('div', { style: 'max-width:72ch' },
154
154
  el('div', {
@@ -171,7 +171,7 @@ function viewBody(text) {
171
171
 
172
172
  function rawBody(text) {
173
173
  return el('div', {
174
- style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
174
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
175
175
  }, text.split('\n').map((l) => {
176
176
  const ln = FX.rawLine(l);
177
177
  return el('div', {
@@ -183,7 +183,7 @@ function rawBody(text) {
183
183
  function diffBody(raw, text, dirty) {
184
184
  const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
185
185
  return el('div', {
186
- style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
186
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
187
187
  }, rows.map((dr) => el('div', {
188
188
  style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
189
189
  }, iv(dr.t))));
@@ -197,7 +197,7 @@ function editBox(tab) {
197
197
  let ta = boxes.get(tab.id);
198
198
  if (ta) return ta;
199
199
  ta = el('textarea', {
200
- style: 'flex:1;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
200
+ style: 'flex:1;min-height:0;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
201
201
  onInput: (e) => {
202
202
  const v = e.target.value;
203
203
  saveErr.delete(tab.path);
@@ -253,7 +253,7 @@ export function mountDoc(host, tab) {
253
253
  const dirtyColor = err ? 'var(--destructive)' : (dirty ? 'var(--warning)' : 'var(--muted-foreground)');
254
254
 
255
255
  const modeBar = el('div', {
256
- style: 'display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
256
+ style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
257
257
  },
258
258
  modeBtns, saveBtn, revertBtn,
259
259
  el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
@@ -52,7 +52,7 @@ export function mountTask(host, tab) {
52
52
  'no artifact yet — produced when the agent runs this task');
53
53
 
54
54
  const body = el('div', {
55
- style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
55
+ style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
56
56
  },
57
57
  el('div', { style: 'max-width:76ch' },
58
58
  el('div', { style: 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:5px' },
@@ -446,7 +446,7 @@ if (typeof window !== 'undefined') {
446
446
 
447
447
  /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
448
448
 
449
- const SCROLL_STYLE = 'flex:1;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
449
+ const SCROLL_STYLE = 'flex:1;min-height:0;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
450
450
  const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
451
451
  const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
452
452
  const PROMPT_STYLE = 'color:#47cf79;flex:none';
@@ -639,6 +639,22 @@ export function sendToTerm(tab, data) {
639
639
  return send({ t: 'input', id: rec.sessionId, data });
640
640
  }
641
641
 
642
+ /**
643
+ * End a running session, by id — the `+ session` chooser's one destructive action.
644
+ *
645
+ * The wire message already existed (`t:'kill'`); what did not exist anywhere in the four-panel
646
+ * surface was a way to reach it, which is why "is it safe to end this?" had no answer the PM could
647
+ * act on. This is a bare send: the CONFIRMATION lives in the chooser, next to the reading of what
648
+ * ending it would cost, because that is the only place the PM is looking at both at once.
649
+ *
650
+ * Returns false when the socket is not up, so a caller can say "not sent" rather than imply a kill
651
+ * that never left the page.
652
+ */
653
+ export function killSession(id) {
654
+ if (FIXTURE || !id) return false;
655
+ return send({ t: 'kill', id });
656
+ }
657
+
642
658
  /**
643
659
  * The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
644
660
  * the session, and the session's identity is this id — not the tab id (which survives a session
@@ -369,6 +369,36 @@ function treeForWs(wsIdent) {
369
369
  function treeTenants(tree, excludeId) {
370
370
  return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
371
371
  }
372
+ /**
373
+ * A registry row, described for a human deciding whether to JOIN it rather than start another
374
+ * (workspace-session-choice). "2 sessions already running" names a fact the PM cannot act on; what
375
+ * they need is which one, what it is doing, and what ending it would cost.
376
+ *
377
+ * Every field is one the sidebar already publishes — the joined presented state, the quiet clock,
378
+ * the sidebar's own word for it, and the agent's last words. Nothing new is derived here and no
379
+ * word is re-invented: `sessionState` owns the vocabulary and `treeShare.endRisk` owns the
380
+ * consequence, and this function only joins them onto one row.
381
+ */
382
+ function describePeer(s, now) {
383
+ const d = harness.deriveState(s.state, s.lastOutputAt, now || Date.now());
384
+ const hx = transcriptHarness(s);
385
+ const shown = sessionState.presented(d.state, hx && hx.turn);
386
+ const oc = hx && hx.openCall;
387
+ const call = oc && oc.verb
388
+ ? { verb: oc.verb, ageMs: oc.sinceTs ? Math.max(0, (now || Date.now()) - Date.parse(oc.sinceTs)) : null }
389
+ : null;
390
+ return {
391
+ id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, shown,
392
+ quietMs: d.quietMs,
393
+ label: sessionState.label(shown, { quietMs: d.quietMs, call, fmtDur: treeShare.ago }),
394
+ doing: (harnessOf(s) || {}).lastWords || null,
395
+ };
396
+ }
397
+ /** The same set as `treeTenants`, each row described. The order is the registry's, unchanged. */
398
+ function treePeersDescribed(tree, excludeId) {
399
+ const now = Date.now();
400
+ return treeTenants(tree, excludeId).map((s) => describePeer(s, now));
401
+ }
372
402
  function treeWriter(tree) {
373
403
  return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
374
404
  }
@@ -382,12 +412,16 @@ function announceTree(s) {
382
412
  const tree = treeForWs(s.ws);
383
413
  s.tree = tree || null;
384
414
  if (!tree) return [];
385
- const mates = treeTenants(tree, s.id);
415
+ // DESCRIBED rows, not bare registry objects: the pane line now carries what each co-tenant is
416
+ // doing and what ending it would cost, and it must read from the same join the sidebar does.
417
+ const mates = treePeersDescribed(tree, s.id);
386
418
  const holder = treeWriter(tree);
387
419
  if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
388
420
  if (!mates.length) return []; // alone in the tree: nothing to say
389
421
  const held = holder ? (sessions.get(holder) || {}).name : null;
390
- const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
422
+ const line = treeShare.paneAdvisory(mates, {
423
+ role: holder ? 'reader' : 'writer', writerName: held, writerId: holder || s.id, tree,
424
+ });
391
425
  if (line) appendBuf(s, line);
392
426
  return mates;
393
427
  }
@@ -396,7 +430,9 @@ function treeNoticeFor(s, mates) {
396
430
  if (!mates || !mates.length) return null;
397
431
  const holder = treeWriter(s.tree);
398
432
  const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
399
- return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
433
+ return treeShare.coTenancyNotice(mates, {
434
+ role: holder === s.id ? 'writer' : 'reader', writerName: held, writerId: holder, tree: s.tree,
435
+ });
400
436
  }
401
437
  function wsList() {
402
438
  return [...workspaces.values()].map((w) => {
@@ -410,6 +446,12 @@ function wsList() {
410
446
  // the client renders the first quietly and the second as a fault (R1/R2).
411
447
  repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
412
448
  branch_error: rid.branchError, branch_code: rid.branchCode,
449
+ // The COLLISION UNIT (workspace-session-concurrency), published so a client can answer "who
450
+ // else is in the tree I am about to start in?" BEFORE it starts anything. Without it the
451
+ // browser could only find co-tenants via a session that already exists there, which is
452
+ // exactly one session too late for a chooser. Null for a folder with no git — no index to
453
+ // collide over, so no group.
454
+ tree: rid.tree || null,
413
455
  // Where the tree came from, and the ONE label the repository list can honestly put on a row.
414
456
  // `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
415
457
  // answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
@@ -1559,6 +1601,17 @@ function sessionList() {
1559
1601
  // `failed` does not exist (no observable for it). The client re-derives this from the SAME
1560
1602
  // module — see /lib/session-state.js below — so the two can never hold different opinions.
1561
1603
  shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
1604
+ // The rendered word for that state — computed HERE rather than in each client, because it is
1605
+ // the same join (`sessionState.label`) with the same clock, and a client re-deriving it was
1606
+ // already producing a second, slightly different sentence for one session.
1607
+ label: sessionState.label(sessionState.presented(d.state, hx && hx.turn), {
1608
+ quietMs: d.quietMs,
1609
+ call: hx && hx.openCall && hx.openCall.verb
1610
+ ? { verb: hx.openCall.verb,
1611
+ ageMs: hx.openCall.sinceTs ? Math.max(0, now - Date.parse(hx.openCall.sinceTs)) : null }
1612
+ : null,
1613
+ fmtDur: treeShare.ago,
1614
+ }),
1562
1615
  lastOutputAt: s.lastOutputAt || null,
1563
1616
  lastWords: h.lastWords,
1564
1617
  // the harness proper — structured record, not rendered bytes. Resolved ONCE above and
@@ -2115,7 +2168,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2115
2168
  // Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
2116
2169
  // notice can both be true of one spawn. joinNotices keeps them both rather than letting the
2117
2170
  // second evict the first exactly when the PM most needs both.
2118
- const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
2171
+ const shared = treeNoticeFor(s, treePeersDescribed(s.tree, s.id));
2119
2172
  return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
2120
2173
  }
2121
2174
 
@@ -3091,6 +3144,7 @@ async function handleRequest(req, res) {
3091
3144
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
3092
3145
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
3093
3146
  if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
3147
+ if (p === '/lib/tree-share.js') return serveFile(res, path.join(__dirname, 'lib', 'tree-share.js'));
3094
3148
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
3095
3149
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
3096
3150
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
@@ -3630,9 +3684,13 @@ async function handleRequest(req, res) {
3630
3684
  note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
3631
3685
  }
3632
3686
  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,
3687
+ // Described, not merely listed (workspace-session-choice): a caller deciding between joining a
3688
+ // running session and starting another needs the state, the word for it, what the agent is
3689
+ // doing and what ending it would cost — the same four facts the chooser and the pane line show.
3690
+ const rows = treePeersDescribed(tree, null).map((s) => ({
3691
+ ...s,
3635
3692
  role: treeShare.roleOf(s.id, tree, writer),
3693
+ risk: treeShare.endRisk(s.shown, s.quietMs),
3636
3694
  }));
3637
3695
  return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
3638
3696
  note: writer
@@ -4406,6 +4464,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
4406
4464
  // (one repo registered twice is one tree; two worktrees of one repo are two) without driving
4407
4465
  // every case through a spawn.
4408
4466
  treeForWs, treeTenants, treeWriter, treeClaims,
4467
+ // …and the DESCRIBED form, exported for the same reason: "what is that session doing and what
4468
+ // would ending it cost?" is the half of co-tenancy the PM actually acts on, so the suite asserts
4469
+ // it directly rather than reading it back out of a rendered sentence.
4470
+ describePeer, treePeersDescribed,
4409
4471
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
4410
4472
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
4411
4473
  scrub, harnessOf,