create-pmos 0.18.0 → 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.
@@ -149,6 +149,7 @@ function connect() {
149
149
  for (const rec of panes.values()) {
150
150
  if (rec.kind !== 'real') continue;
151
151
  rec.attachedId = null;
152
+ rec.sentCols = null; rec.sentRows = null; // the next connection has stated no grid yet
152
153
  note(rec, 'pmos: connection to the workspace server lost — retrying');
153
154
  }
154
155
  clearTimeout(SOCK.timer);
@@ -181,6 +182,17 @@ function handle(m) {
181
182
  rec.noted = null;
182
183
  try { rec.term.reset(); } catch { /* a disposed terminal */ }
183
184
  if (m.buf) { try { rec.term.write(m.buf); } catch { /* ignore */ } }
185
+ /* THE PTY DOES NOT KNOW HOW WIDE THIS BROWSER IS. `attached` carries the SERVER's cols/rows —
186
+ * 80×24 for a session nobody ever resized — and the server never adopts the client's grid on
187
+ * attach. The only thing that ever told it was xterm's `onResize`, which fires when a fit
188
+ * CHANGES the grid; the first fit runs while the socket is still connecting, so `sessionId` was
189
+ * null and the message was dropped, and every later fit measured the same box and changed
190
+ * nothing, so it never fired again. Result: a 200-column window rendering a shell that believes
191
+ * it has 80, wrapping a third of the way across — "the terminal only shows a small portion".
192
+ * The grid is therefore PUSHED here, unconditionally, the moment there is a session to push it
193
+ * to. `sent*` is cleared first so a re-attach after a reconnect re-states it. */
194
+ rec.sentCols = null; rec.sentRows = null;
195
+ syncSize(rec);
184
196
  refitVisible();
185
197
  return;
186
198
  }
@@ -199,6 +211,8 @@ function handle(m) {
199
211
  const rec = panes.get(id);
200
212
  if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
201
213
  rec.sessionId = m.id;
214
+ rec.sentCols = null; rec.sentRows = null;
215
+ rememberSession(rec.tab, m.id);
202
216
  attach(rec);
203
217
  return;
204
218
  }
@@ -218,22 +232,38 @@ const recFor = (sessionId) => {
218
232
  };
219
233
 
220
234
  /* ── binding a tab to a real session ───────────────────────────────────────────────────────────
221
- * The tab knows the initiative slug and the session NAME (`term:<slug>:<name>` is its id); the
222
- * server knows session ids. The join is by name within the tab's workspace, which is the same join
223
- * the sidebar has always made — a session's name is what the PM sees and what they named it.
224
- * `sessionId` set on the tab by the data layer wins, when there is one.
235
+ * `sessionId` on the tab is the ONLY exact join, and it is the one that is used whenever it exists:
236
+ * it is the server's own id, the data layer puts it on every tab opened from a listed session, and
237
+ * `rememberSession` writes it back (and so into localStorage) the first time a tab resolves. The
238
+ * name join below is the fallback for a tab that predates all of that.
239
+ *
240
+ * THE NAME JOIN IS SCOPED TO THE TAB'S OWN WORKSPACE, and no longer falls back to "a session with
241
+ * this name anywhere". Session names are not unique across repositories — every `+ session` in
242
+ * every repo produces `agent 1` — so the unscoped fallback quietly attached a tab in one repository
243
+ * to a PTY running in another, and the PM was shown another repo's shell under their own repo's
244
+ * initiative. A name that resolves nowhere in this workspace is now reported as not found, which is
245
+ * the true answer.
225
246
  */
226
247
  function resolveSessionId(tab) {
227
- if (tab.sessionId) return tab.sessionId;
228
248
  const pool = SOCK.sessions.length ? SOCK.sessions
229
249
  : (Array.isArray(state.sessions) ? state.sessions : []);
250
+ if (tab.sessionId) {
251
+ // A restored tab names a session from a previous run of this window — or of this SERVER. Trust
252
+ // it while nothing is known (the socket has not said `hello` yet), but once there is a list,
253
+ // an id that is not in it is a session that no longer exists: fall through rather than
254
+ // `attach`ing to it, which the server answers with silence and the PM reads as a dead pane.
255
+ if (!pool.length || pool.some((s) => s && s.id === tab.sessionId)) return tab.sessionId;
256
+ }
257
+ if (tab.spawn) return null; // a `+ session` tab must START one, never adopt one
230
258
  const name = tab.sess;
231
259
  if (!name) return null;
232
260
  const byId = pool.find((s) => s && s.id === name);
233
261
  if (byId) return byId.id;
234
- const ws = workspaceId();
262
+ const ws = wsForTab(tab);
235
263
  const inWs = pool.find((s) => s && s.name === name && (!ws || s.ws === ws));
236
264
  if (inWs) return inWs.id;
265
+ // No cross-workspace fallback. See the note above: it is how a repository showed another's shell.
266
+ if (ws) return null;
237
267
  const anywhere = pool.find((s) => s && s.name === name);
238
268
  return anywhere ? anywhere.id : null;
239
269
  }
@@ -254,40 +284,95 @@ function workspaceId() {
254
284
  }
255
285
 
256
286
  /**
257
- * Was this session started in THIS window (the `+ session` path)? Only then may a terminal spawn a
258
- * PTY on its own: a tab opened against a session the board listed must attach to that session or
259
- * say it is gone — silently starting a second shell with the same name would be a lie about what
260
- * the PM is looking at.
287
+ * The workspace THIS TAB belongs to — which is not always the selected one. Every open terminal is
288
+ * kept live, including the ones belonging to a repository the PM is not currently looking at (see
289
+ * panel-tabs.js `keepSessionsLive`), so scoping a parked tab's name join by the SELECTED repository
290
+ * would scope it by the wrong repo entirely. `state.productWs` is the data layer's product-name →
291
+ * workspace-id map; a tab whose repository is not in it falls back to the selected workspace, which
292
+ * is right for the tab on screen and is all a fixture render has.
293
+ */
294
+ function wsForTab(tab) {
295
+ const map = state.productWs;
296
+ if (tab && tab.repo && map && typeof map === 'object' && map[tab.repo]) return map[tab.repo];
297
+ return workspaceId();
298
+ }
299
+
300
+ /**
301
+ * Does this tab carry an instruction to START a session? Only then may a terminal spawn a PTY on
302
+ * its own: a tab opened against a session the board listed must attach to that session or say it is
303
+ * gone — silently starting a second shell with the same name would be a lie about what the PM is
304
+ * looking at.
305
+ *
306
+ * The flag is set by `+ session` and travels ON THE TAB. It used to be inferred by looking the
307
+ * tab's session NAME up in `state.extraSess`, which is a different question wearing the same
308
+ * clothes: after a reload `extraSess` is empty and `startN` is back at 1, so a fresh `+ session`
309
+ * produced the name `agent 1`, the name join found the `agent 1` still running from before the
310
+ * reload, and the click ATTACHED to an old session instead of starting a new one. The PM asked for
311
+ * a session and got someone else's.
261
312
  */
262
- function startedHere(tab) {
313
+ function wantsSpawn(tab) {
314
+ if (tab && tab.spawn) return true;
263
315
  const list = (state.extraSess || {})[tab.slug];
264
316
  return Array.isArray(list) && list.some((s) => s && s.name === tab.sess);
265
317
  }
266
318
 
319
+ /**
320
+ * Record the session a tab actually landed on, on the tab record itself — so it is written to
321
+ * localStorage and a reload re-attaches to exactly this PTY rather than re-running the name join.
322
+ * "I don't know which session it has reloaded" is that join being re-run against a list that has
323
+ * moved on; an id does not have that failure mode.
324
+ */
325
+ function rememberSession(tab, sessionId) {
326
+ if (!tab || !sessionId) return;
327
+ // The guard is checked BEFORE the mutation, and on the tab record itself. `rec.tab` is very often
328
+ // the same object that sits in `state.tabs`, so mutating first and then comparing would always
329
+ // find them equal and the repaint that shows the id on the tab would never be scheduled.
330
+ if (tab.sessionId === sessionId && tab.spawn === false) return;
331
+ tab.sessionId = sessionId;
332
+ tab.spawn = false;
333
+ setState((s) => {
334
+ const i = (s.tabs || []).findIndex((t) => t.id === tab.id);
335
+ if (i < 0) return null;
336
+ const next = [...s.tabs];
337
+ next[i] = { ...next[i], sessionId, spawn: false };
338
+ return { tabs: next };
339
+ });
340
+ }
341
+
267
342
  function bind(rec) {
268
343
  if (rec.kind !== 'real' || !rec.term) return;
269
344
  const id = resolveSessionId(rec.tab);
270
345
  if (id) {
271
- if (rec.sessionId !== id) { rec.sessionId = id; rec.attachedId = null; }
346
+ if (rec.sessionId !== id) {
347
+ rec.sessionId = id; rec.attachedId = null; rec.sentCols = null; rec.sentRows = null;
348
+ }
349
+ rememberSession(rec.tab, id);
272
350
  attach(rec);
273
351
  return;
274
352
  }
275
353
  if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
276
354
  if (SOCK.spawning.get(rec.tab.id)) return;
277
- if (startedHere(rec.tab)) {
355
+ if (wantsSpawn(rec.tab)) {
278
356
  // agent 'shell' deliberately: PMOS cannot know which agent CLI the PM meant from a tab alone,
279
357
  // and falling back from an agent to a shell after the fact would be the dishonest version.
280
358
  SOCK.spawning.set(rec.tab.id, true);
281
359
  SOCK.spawnQueue.push(rec.tab.id);
282
360
  note(rec, 'pmos: starting a session…', true);
283
- const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: workspaceId() });
361
+ const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab) });
284
362
  if (!ok) {
285
363
  SOCK.spawning.delete(rec.tab.id);
286
364
  SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
287
365
  }
288
366
  return;
289
367
  }
290
- note(rec, 'pmos: no live session named "' + rec.tab.sess + '" — nothing was attached');
368
+ /* Say WHICH session was looked for and why it could not be found. A tab restored from a previous
369
+ * run names an id; one opened from the board names a name. Both are worth printing — "nothing was
370
+ * attached" without the identity is exactly the report that left the PM unable to tell which
371
+ * session a reloaded tab had landed on. */
372
+ note(rec, rec.tab.sessionId
373
+ ? 'pmos: session ' + rec.tab.sessionId + ' ("' + (rec.tab.sess || '?')
374
+ + '") is no longer running — nothing was attached'
375
+ : 'pmos: no live session named "' + rec.tab.sess + '" in this repository — nothing was attached');
291
376
  }
292
377
 
293
378
  function attach(rec) {
@@ -296,6 +381,24 @@ function attach(rec) {
296
381
  if (send({ t: 'attach', id: rec.sessionId })) SOCK.attached.add(rec.sessionId);
297
382
  }
298
383
 
384
+ /**
385
+ * Tell the PTY the grid this browser is actually rendering. Sent whenever the pane is attached and
386
+ * the terminal's cols/rows differ from what this connection last stated — which covers the attach
387
+ * itself, every refit that moves the grid, and a reconnect (where `sent*` is cleared).
388
+ *
389
+ * Idempotent by design: `sent*` makes a re-render cost nothing, and the guard is on what was SENT
390
+ * rather than on what xterm reports, because the server holds the authoritative copy and a resize
391
+ * this connection never sent is a resize the shell never heard.
392
+ */
393
+ function syncSize(rec) {
394
+ if (!rec || rec.kind !== 'real' || !rec.term) return;
395
+ if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
396
+ const cols = rec.term.cols, rows = rec.term.rows;
397
+ if (!cols || !rows) return;
398
+ if (rec.sentCols === cols && rec.sentRows === rows) return;
399
+ if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
400
+ }
401
+
299
402
  /** Write a status line into the terminal itself — once per distinct message, dimmed. */
300
403
  function note(rec, line, force) {
301
404
  if (!rec.term) return;
@@ -321,6 +424,10 @@ export function refitVisible() {
321
424
  if (!rec.host.isConnected) continue;
322
425
  if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
323
426
  try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
427
+ // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
428
+ // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
429
+ // the shell how wide it is. See the `attached` handler.
430
+ syncSize(rec);
324
431
  }
325
432
  };
326
433
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
@@ -339,7 +446,7 @@ if (typeof window !== 'undefined') {
339
446
 
340
447
  /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
341
448
 
342
- 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';
343
450
  const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
344
451
  const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
345
452
  const PROMPT_STYLE = 'color:#47cf79;flex:none';
@@ -459,9 +566,7 @@ function buildReal(tab) {
459
566
  kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
460
567
  };
461
568
  term.onData((d) => { if (rec.sessionId) send({ t: 'input', id: rec.sessionId, data: d }); });
462
- term.onResize(({ cols, rows }) => {
463
- if (rec.sessionId) send({ t: 'resize', id: rec.sessionId, cols, rows });
464
- });
569
+ term.onResize(() => syncSize(rec));
465
570
  if (typeof ResizeObserver === 'function') {
466
571
  rec.ro = new ResizeObserver(() => refitVisible());
467
572
  try { rec.ro.observe(host); } catch { /* ignore */ }
@@ -534,6 +639,33 @@ export function sendToTerm(tab, data) {
534
639
  return send({ t: 'input', id: rec.sessionId, data });
535
640
  }
536
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
+
658
+ /**
659
+ * The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
660
+ * the session, and the session's identity is this id — not the tab id (which survives a session
661
+ * being replaced) and not the session name (which repeats across repositories).
662
+ */
663
+ export function sessionIdOf(tab) {
664
+ const rec = panes.get(tab && (tab.id || tab));
665
+ if (rec && rec.kind === 'real' && rec.sessionId) return rec.sessionId;
666
+ return (tab && tab.sessionId) || null;
667
+ }
668
+
537
669
  /** Focus the terminal of a tab, if it is live. */
538
670
  export function focusTerm(tab) {
539
671
  const rec = panes.get(tab && (tab.id || tab));
@@ -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 startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
74
75
  const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
75
76
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
76
77
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
@@ -368,6 +369,36 @@ function treeForWs(wsIdent) {
368
369
  function treeTenants(tree, excludeId) {
369
370
  return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
370
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
+ }
371
402
  function treeWriter(tree) {
372
403
  return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
373
404
  }
@@ -381,12 +412,16 @@ function announceTree(s) {
381
412
  const tree = treeForWs(s.ws);
382
413
  s.tree = tree || null;
383
414
  if (!tree) return [];
384
- 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);
385
418
  const holder = treeWriter(tree);
386
419
  if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
387
420
  if (!mates.length) return []; // alone in the tree: nothing to say
388
421
  const held = holder ? (sessions.get(holder) || {}).name : null;
389
- 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
+ });
390
425
  if (line) appendBuf(s, line);
391
426
  return mates;
392
427
  }
@@ -395,7 +430,9 @@ function treeNoticeFor(s, mates) {
395
430
  if (!mates || !mates.length) return null;
396
431
  const holder = treeWriter(s.tree);
397
432
  const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
398
- 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
+ });
399
436
  }
400
437
  function wsList() {
401
438
  return [...workspaces.values()].map((w) => {
@@ -409,6 +446,12 @@ function wsList() {
409
446
  // the client renders the first quietly and the second as a fault (R1/R2).
410
447
  repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
411
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,
412
455
  // Where the tree came from, and the ONE label the repository list can honestly put on a row.
413
456
  // `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
414
457
  // answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
@@ -1306,6 +1349,10 @@ function persist(s) {
1306
1349
  try {
1307
1350
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
1308
1351
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
1352
+ // What is on disk now matches `s.buf`. The periodic writer reads this to skip a session whose
1353
+ // buffer has not moved — see the interval below. Cleared only on a write that SUCCEEDED, so a
1354
+ // disk blip leaves the session dirty and it is retried on the next tick.
1355
+ s.bufDirty = false;
1309
1356
  } catch { /* best-effort; never crash a live session on a disk blip */ }
1310
1357
  }
1311
1358
  function unpersist(id) {
@@ -1554,6 +1601,17 @@ function sessionList() {
1554
1601
  // `failed` does not exist (no observable for it). The client re-derives this from the SAME
1555
1602
  // module — see /lib/session-state.js below — so the two can never hold different opinions.
1556
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
+ }),
1557
1615
  lastOutputAt: s.lastOutputAt || null,
1558
1616
  lastWords: h.lastWords,
1559
1617
  // the harness proper — structured record, not rendered bytes. Resolved ONCE above and
@@ -1641,6 +1699,7 @@ function trimBuf(buf, limit) {
1641
1699
  function appendBuf(s, data) {
1642
1700
  s.buf += data;
1643
1701
  if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
1702
+ s.bufDirty = true; // the periodic writer skips a session that is not dirty
1644
1703
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
1645
1704
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
1646
1705
  s.lastOutputAt = Date.now();
@@ -1808,6 +1867,7 @@ function seedFromPaneHistory(s, name) {
1808
1867
  if (!text) return false;
1809
1868
  s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
1810
1869
  s.parsed = null;
1870
+ s.bufDirty = true; // the ring moved; the file on disk is stale
1811
1871
  return true;
1812
1872
  }
1813
1873
 
@@ -2108,10 +2168,90 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2108
2168
  // Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
2109
2169
  // notice can both be true of one spawn. joinNotices keeps them both rather than letting the
2110
2170
  // second evict the first exactly when the PM most needs both.
2111
- const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
2171
+ const shared = treeNoticeFor(s, treePeersDescribed(s.tree, s.id));
2112
2172
  return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
2113
2173
  }
2114
2174
 
2175
+ /* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
2176
+ * The composer asked six questions to open a terminal, and the one question the PM actually has —
2177
+ * "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
2178
+ * isolation checkbox whose default came from liveSessionCount(). These three functions replace that
2179
+ * with: pick a repository, optionally pick an initiative, press one control.
2180
+ *
2181
+ * The policy itself is lib/start-policy.js — pure, so the git-touching paths are testable without
2182
+ * a repository. This layer only supplies it with facts and carries out what it decides.
2183
+ *
2184
+ * R3 is why `agent` appears NOWHERE below except as a value passed through to the spawn: the
2185
+ * destination is derived from the repository and the initiative, so `shell` and `claude` cannot
2186
+ * land in different directories for the same selection. Previously both resolved through
2187
+ * `state.wsId` — whichever folder the client happened to have selected — which is exactly how they
2188
+ * came apart.
2189
+ */
2190
+ function treesOfRepo(repoKey) {
2191
+ const out = [];
2192
+ for (const w of workspaces.values()) {
2193
+ const ident = repoIdentity(w.root);
2194
+ if (String(ident.key) !== String(repoKey)) continue;
2195
+ out.push({ id: w.id, root: w.root, name: w.name, repo_key: ident.key, branch: ident.branch });
2196
+ }
2197
+ return out;
2198
+ }
2199
+ // The repo key for a request that named a workspace rather than a repository. The client sends
2200
+ // whichever it has; a stale ws id must not silently retarget another repo, so an unknown id is an
2201
+ // error rather than a fallback to "some repository".
2202
+ function repoKeyFor({ repoKey, ws }) {
2203
+ if (repoKey) return String(repoKey);
2204
+ const w = ws && workspaces.get(ws);
2205
+ return w ? String(repoIdentity(w.root).key) : null;
2206
+ }
2207
+ // What the PM is about to get, BEFORE they press anything (R1: the destination is named first).
2208
+ // Read-only: it reads git and resolves the policy, and starts nothing.
2209
+ function startPreview({ initiative, repoKey, ws }) {
2210
+ const key = repoKeyFor({ repoKey, ws });
2211
+ if (!key) return { ok: false, error: 'no repository selected' };
2212
+ const trees = treesOfRepo(key);
2213
+ const main = startPolicy.mainTreeOf(trees);
2214
+ // branchInfo is shelled per call and only when an initiative is named — the no-initiative plan
2215
+ // needs no branch list, and this runs on every selection change.
2216
+ let branches = null;
2217
+ if (initiative && main) {
2218
+ try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
2219
+ }
2220
+ const plan = startPolicy.resolveStart({ initiative, trees, branches });
2221
+ if (!plan.ok) return plan;
2222
+ // The literal directory, so the sentence in the UI is checkable rather than a promise.
2223
+ let dir = null;
2224
+ if (plan.plan === 'new-worktree' && main) { try { dir = gitInfo.worktreePath(main.root, plan.branch); } catch {} }
2225
+ else { const t = trees.find((x) => x.id === plan.wsId); dir = t ? t.root : null; }
2226
+ return { ok: true, plan: plan.plan, why: plan.why, dir, branch: plan.branch, wsId: plan.wsId };
2227
+ }
2228
+ // One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
2229
+ // branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
2230
+ // nothing) is enforced in exactly one place and is not re-implemented here.
2231
+ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
2232
+ const key = repoKeyFor({ repoKey, ws });
2233
+ if (!key) return { ok: false, error: 'no repository selected' };
2234
+ const trees = treesOfRepo(key);
2235
+ const main = startPolicy.mainTreeOf(trees);
2236
+ let branches = null;
2237
+ if (initiative && main) {
2238
+ try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
2239
+ }
2240
+ const plan = startPolicy.resolveStart({ initiative, trees, branches });
2241
+ if (!plan.ok) return plan;
2242
+ const r = spawnWithBranch({
2243
+ name: name || initiative || agent || 'shell',
2244
+ agent: agent || 'shell',
2245
+ ws: plan.wsId,
2246
+ branch: plan.branch,
2247
+ createBranch: plan.createBranch,
2248
+ isolate: plan.isolate,
2249
+ profile,
2250
+ });
2251
+ if (!r.ok) return r;
2252
+ return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
2253
+ }
2254
+
2115
2255
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
2116
2256
  // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
2117
2257
  // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
@@ -2255,8 +2395,17 @@ function renameSession(id, name) {
2255
2395
  return true;
2256
2396
  }
2257
2397
 
2258
- // periodic scrollback persistence so a crash loses at most a few seconds of history
2259
- setInterval(() => { for (const s of sessions.values()) if (s.pty) persist(s); }, 5000).unref?.();
2398
+ // Periodic scrollback persistence so a crash loses at most a few seconds of history — but ONLY for
2399
+ // sessions whose buffer actually moved. `persist()` is two SYNCHRONOUS writes, the second of them
2400
+ // the whole ring buffer (up to 200 KB), and this ran unconditionally for every live session every
2401
+ // five seconds. A workspace with six idle panes open was therefore doing over a megabyte of blocking
2402
+ // disk I/O, on a timer, forever — rewriting bytes identical to the ones already on disk, on the same
2403
+ // thread that carries every keystroke to a PTY and every byte back to the browser. That is a stall
2404
+ // the PM feels as the terminal going unresponsive for no reason they can see.
2405
+ //
2406
+ // `bufDirty` is set by appendBuf (the only writer of `s.buf`) and cleared by persist(), so an idle
2407
+ // session now costs one map lookup per tick and a busy one is written exactly as often as before.
2408
+ setInterval(() => { for (const s of sessions.values()) if (s.pty && s.bufDirty) persist(s); }, 5000).unref?.();
2260
2409
 
2261
2410
  // ── File-mode state, per workspace. ───────────────────────────────────────────────────
2262
2411
  // A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
@@ -2995,6 +3144,7 @@ async function handleRequest(req, res) {
2995
3144
  if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
2996
3145
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
2997
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'));
2998
3148
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
2999
3149
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
3000
3150
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
@@ -3504,6 +3654,16 @@ async function handleRequest(req, res) {
3504
3654
  if (!w) return json(res, { ok: false, error: 'no workspace' });
3505
3655
  return json(res, gitInfo.branchInfo(w.root));
3506
3656
  }
3657
+ if (p === '/api/start/preview') {
3658
+ // repo-first R1 — what one click will do, said BEFORE the click. Read-only: it resolves the
3659
+ // policy and names the literal destination directory, and starts nothing. This exists so the
3660
+ // rule is checkable by the PM rather than inferred after a folder appears.
3661
+ return json(res, startPreview({
3662
+ initiative: url.searchParams.get('initiative') || '',
3663
+ repoKey: url.searchParams.get('repo') || '',
3664
+ ws: url.searchParams.get('ws') || '',
3665
+ }));
3666
+ }
3507
3667
  if (p === '/api/git/worktrees') {
3508
3668
  // Every working tree attached to this workspace's repo, each with the reasons it can or cannot
3509
3669
  // be removed. A worktree the workspace made is a folder the PM has to live with, so the app
@@ -3524,9 +3684,13 @@ async function handleRequest(req, res) {
3524
3684
  note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
3525
3685
  }
3526
3686
  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,
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,
3529
3692
  role: treeShare.roleOf(s.id, tree, writer),
3693
+ risk: treeShare.endRisk(s.shown, s.quietMs),
3530
3694
  }));
3531
3695
  return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
3532
3696
  note: writer
@@ -4159,7 +4323,7 @@ wss.on('connection', (ws) => {
4159
4323
  ws.on('message', (raw) => {
4160
4324
  let m; try { m = JSON.parse(raw); } catch { return; }
4161
4325
  // Gate the shell-spawning / PTY-feeding messages through the buckets before they do any work.
4162
- if ((m.t === 'new' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
4326
+ if ((m.t === 'new' || m.t === 'start' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
4163
4327
  ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
4164
4328
  return;
4165
4329
  }
@@ -4183,6 +4347,15 @@ wss.on('connection', (ws) => {
4183
4347
  // non-repo) — said out loud, never swallowed
4184
4348
  if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
4185
4349
  ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
4350
+ } else if (m.t === 'start') {
4351
+ // repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
4352
+ // never a branch and never an isolate flag: those are the policy's to decide, and a client
4353
+ // that could still send them would be a second place the rule lives.
4354
+ const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
4355
+ agent: m.agent, name: m.name, profile: m.profile });
4356
+ if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
4357
+ if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
4358
+ ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
4186
4359
  } else if (m.t === 'respawn') {
4187
4360
  const old = sessions.get(m.id); if (!old || old.pty) return;
4188
4361
  // `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
@@ -4291,6 +4464,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
4291
4464
  // (one repo registered twice is one tree; two worktrees of one repo are two) without driving
4292
4465
  // every case through a spawn.
4293
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,
4294
4471
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
4295
4472
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
4296
4473
  scrub, harnessOf,