create-pmos 0.18.1 → 0.20.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.
@@ -151,12 +151,33 @@ function hasConnector(kind) {
151
151
  return c.some((x) => (typeof x === 'string' ? x : x && x.kind) === kind
152
152
  && (typeof x === 'string' || x.configured !== false));
153
153
  }
154
+ // The server's shape: `{configured:[{kind, profile, ...}], note}`. Checked BEFORE the plain-map
155
+ // branch below, which would otherwise read `c['jira']` on this object, find undefined, and report
156
+ // "no connector" on a machine whose profiles declare one — a false negative dressed as caution.
157
+ if (c && Array.isArray(c.configured)) return c.configured.some((x) => x && x.kind === kind);
154
158
  if (c && typeof c === 'object') return !!c[kind];
155
159
  return false;
156
160
  }
157
161
 
158
162
  const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
159
163
 
164
+ /**
165
+ * Why the two sync approvals are inert in real mode even when a destination IS resolvable.
166
+ *
167
+ * They are publishing and read-back, and both are out of scope by name (PRD non-goals 1–2).
168
+ * Publishing puts PMOS content in two places and needs a fork/drift ruling the PM has not made;
169
+ * read-back has no surviving architecture at all, because D53's inbound half was a hosted-Supabase
170
+ * webhook and D64 deprecated that path. The buttons' old results — `pushed · repo → jira · in sync
171
+ * 12:41` — were a hardcoded clock time presented as a sync confirmation, which is the same defect
172
+ * as the fabricated identifier one level up.
173
+ *
174
+ * Deleting the buttons was the other option and is worse: the PM would be left wondering whether
175
+ * PMOS forgot how to sync. An inert control that states the reason is a surface telling the truth
176
+ * about its own limits.
177
+ */
178
+ const SYNC_OUT_OF_SCOPE = 'publishing and read-back are not built. PMOS records which record this '
179
+ + 'corresponds to; it does not write to that record, and it has read nothing from it.';
180
+
160
181
  /* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
161
182
  * Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
162
183
  * header and search box do.
@@ -276,24 +297,89 @@ function phaseBlock(slug, rec) {
276
297
  }
277
298
 
278
299
  /* ── 5a · LINKS (lines 380–398, derivation 1757–1766, 1797–1798) ────────────────────────────
279
- * External references — NOT repo files. Sessions add them automatically; the three + buttons let
280
- * the PM add one by hand, and adding one opens its preview immediately so the reference is
281
- * verified by looking at it rather than by trusting the row. Multiple links of a kind are allowed:
282
- * the Jira numbering counts the ones already filed rather than replacing them.
300
+ * External references — NOT repo files.
301
+ *
302
+ * WHAT CHANGED HERE, AND WHY IT IS THE POINT. This used to append a reference the moment a + button
303
+ * was clicked, built out of the initiative slug and a counter: `ATL-501`, then `ATL-502`. The PM was
304
+ * never asked which epic. The row then labelled itself `connected`, and after a reload the same row
305
+ * read `declared · not connected` — one row, two labels, and the flattering one was the one visible
306
+ * at the moment of the click. Six clicks recorded six references to six epics that did not exist.
307
+ *
308
+ * A + button now opens a FORM. Nothing is recorded until the PM supplies the reference or a session
309
+ * resolves it, because a reference nobody chose is not a reference — it is a guess written down in
310
+ * a place the PM will later trust. `makeLink` is gone rather than bypassed: a helper that can
311
+ * fabricate an identifier is a helper someone re-wires later.
312
+ */
313
+ /**
314
+ * Hand the lookup to a session that can actually perform it.
315
+ *
316
+ * This is the seam the whole initiative turns on, so it is worth being explicit about what it does
317
+ * NOT do: it does not contact Jira, Confluence or Google, and it does not record a link. It asks
318
+ * the server to compose a task and shows that task to the PM to run in a session on the named
319
+ * profile — the session holds the credential, PMOS does not (D53). The session then records the
320
+ * resolved reference through the same validated endpoint the PM's own typing goes through, where
321
+ * it will be refused unless it names the profile that read it.
322
+ *
323
+ * The alternative — having the workspace perform the search itself — is the design D53 rejected and
324
+ * that `no-network.test.js` fails by construction. There is no version of this function that calls out.
283
325
  */
326
+ function askSessionToResolve(slug, kind, resolver, hint) {
327
+ const data = sibling('data', './data.js');
328
+ if (!data || typeof data.lookupTask !== 'function') return;
329
+ Promise.resolve(data.lookupTask({ kind, subject: slug, hint, profile: resolver.profile }))
330
+ .then((out) => {
331
+ if (!out || out.ok === false) {
332
+ setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: (out && out.error) || 'the task could not be composed' } } }));
333
+ return;
334
+ }
335
+ setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { text: out.text, profile: resolver.profile, kind } } }));
336
+ })
337
+ .catch((e) => setState((s) => ({ linkTask: { ...s.linkTask, [slug]: { error: String((e && e.message) || e) } } })));
338
+ }
339
+
340
+ function linkStateStyle(st) {
341
+ if (st === 'resolved') return { color: 'var(--success)', label: 'resolved' };
342
+ if (st === 'unreachable') return { color: 'var(--warning)', label: 'unreachable' };
343
+ if (st === 'unattributed') return { color: 'var(--muted-foreground)', label: 'unattributed' };
344
+ return { color: 'var(--muted-foreground)', label: 'declared' };
345
+ }
346
+
347
+ /**
348
+ * The one sentence a link row is allowed to say about itself, built from what was actually observed.
349
+ * Every branch names its evidence or admits it has none — there is deliberately no branch that
350
+ * returns a bare status word, because a bare status word is what `connected` was.
351
+ */
352
+ function linkStateText(lk) {
353
+ const st = lk.state || (lk.origin ? 'declared' : 'unattributed');
354
+ if (st === 'resolved') {
355
+ const when = String(lk.resolved_at || '').slice(11, 16);
356
+ return 'resolved by ' + (lk.profile || 'a session') + (when ? ' · read ' + when : '');
357
+ }
358
+ if (st === 'unreachable') return 'unreachable · ' + (lk.reason || 'the lookup failed');
359
+ if (st === 'unattributed') return 'recorded before provenance — origin unknown';
360
+ return 'declared · nothing read it';
361
+ }
362
+
284
363
  function linksRow(slug) {
285
364
  const rec = records()[slug] || {};
286
365
  const manual = (state.initLinks || {})[slug] || [];
287
366
  const links = [...(rec.links || []), ...manual];
288
-
289
- const addLink = (src) => {
290
- const link = FX.makeLink(slug, src, manual);
367
+ const openSrc = (state.linkForm || {})[slug] || null;
368
+
369
+ /**
370
+ * Record what the PM typed. `origin: 'typed'` is sent explicitly and the server will refuse to
371
+ * store it as anything stronger — a typed reference is the PM's word, and the row says so.
372
+ */
373
+ const submitTyped = (src, ref) => {
374
+ const target = String(ref || '').trim();
375
+ if (!target) return;
376
+ const link = { kind: src, label: target, path: target, target, origin: 'typed', state: 'declared' };
291
377
  setState(
292
- (s) => ({ initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] } }),
293
- () => {
294
- declare('recordInitiativeLink', { slug, ...link });
295
- openDoc(slug, link.path, link.kind.toUpperCase());
296
- },
378
+ (s) => ({
379
+ initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] },
380
+ linkForm: { ...s.linkForm, [slug]: null },
381
+ }),
382
+ () => declare('recordInitiativeLink', { slug, ...link }),
297
383
  );
298
384
  };
299
385
 
@@ -304,13 +390,80 @@ function linksRow(slug) {
304
390
  },
305
391
  el('span', { style: 'flex:none;width:76px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--info)' }, iv(lk.kind)),
306
392
  el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
307
- el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
393
+ // A row that CARRIES provenance states what was observed; a row that does not keeps whatever
394
+ // status copy it came with. The branch is on the row, not on the mode, which is the point: the
395
+ // prototype's seeded rows (`auto · session`) render unchanged so the parity comparator still
396
+ // sees a like-for-like surface, while every row this surface actually records — in either mode —
397
+ // is held to the three-word vocabulary. Branching on FIXTURE instead would have left a real row
398
+ // in a fixture-mode session rendering `undefined`.
399
+ (lk.state || lk.origin)
400
+ ? el('span', {
401
+ style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:'
402
+ + linkStateStyle(lk.state || 'declared').color,
403
+ }, iv(linkStateText(lk)))
404
+ : el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
308
405
  ));
309
406
 
407
+ // Which destinations a session could actually look up, and under which profile. Derived by the
408
+ // server from the PM's agent profiles — never a constant, and never a claim that PMOS is connected.
409
+ const conn = (state.connectors && state.connectors.configured) || [];
410
+ const resolverFor = (kind) => conn.find((c) => c.kind === kind) || null;
411
+
412
+ const form = openSrc ? (() => {
413
+ const resolver = resolverFor(openSrc);
414
+ const task = (state.linkTask || {})[slug] || null;
415
+ const inputId = 'linkref-' + slug + '-' + openSrc;
416
+ const read = () => {
417
+ const node = document.getElementById(inputId);
418
+ return node ? node.value : '';
419
+ };
420
+ return el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
421
+ el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:7px' },
422
+ iv(resolver
423
+ ? 'paste the reference, or ask ' + resolver.profile + ' to find it — that session holds your '
424
+ + openSrc + ' MCP. PMOS itself reads nothing.'
425
+ : 'paste the reference. No agent profile declares a ' + openSrc
426
+ + ' MCP, so nothing here can look it up for you — what you type is recorded as your own declaration.')),
427
+ el('input', {
428
+ id: inputId,
429
+ placeholder: openSrc === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
430
+ style: 'width:100%;font-family:var(--mono);font-size:11.5px;padding:6px 8px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
431
+ }),
432
+ el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
433
+ el('button', {
434
+ onClick: () => submitTyped(openSrc, read()),
435
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
436
+ }, 'record this reference'),
437
+ resolver && el('button', {
438
+ onClick: () => askSessionToResolve(slug, openSrc, resolver, read()),
439
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
440
+ hover: 'border-color:var(--ring)',
441
+ }, 'ask ', iv(resolver.profile), ' to find it'),
442
+ el('button', {
443
+ onClick: () => setState((s) => ({ linkForm: { ...s.linkForm, [slug]: null } })),
444
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
445
+ }, 'cancel'),
446
+ ),
447
+ task && (task.error
448
+ ? el('div', { style: 'margin-top:8px;font-size:11.5px;color:var(--warning);line-height:1.5' },
449
+ iv('the lookup task could not be composed — ' + task.error))
450
+ : el('div', { style: 'margin-top:8px' },
451
+ el('div', { style: 'font-size:11px;color:var(--muted-foreground);line-height:1.5;margin-bottom:5px' },
452
+ iv('run this in a session on ' + task.profile + '. it records the reference itself, and PMOS'
453
+ + ' will refuse the result unless it names the profile that read it.')),
454
+ el('pre', {
455
+ style: 'font-family:var(--mono);font-size:10.5px;line-height:1.5;white-space:pre-wrap;'
456
+ + 'padding:8px 9px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);margin:0',
457
+ }, iv(task.text)))),
458
+ );
459
+ })() : null;
460
+
310
461
  const addRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px' },
311
462
  el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
312
463
  FX.LINK_SRCS.map((ls) => el('button', {
313
- onClick: () => addLink(ls.src),
464
+ onClick: () => setState((s) => ({
465
+ linkForm: { ...s.linkForm, [slug]: (s.linkForm || {})[slug] === ls.src ? null : ls.src },
466
+ })),
314
467
  style: 'display:flex;gap:5px;align-items:center;font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
315
468
  hover: 'border-color:var(--ring)',
316
469
  }, '+ ', iv(ls.l))),
@@ -321,6 +474,7 @@ function linksRow(slug) {
321
474
  el('div', { style: 'display:flex;flex-direction:column;gap:2px;min-width:0' },
322
475
  rows,
323
476
  addRow,
477
+ form,
324
478
  el('span', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:3px;line-height:1.5' },
325
479
  'references — not in the repo. sessions add these automatically; link one yourself when you need it.'),
326
480
  ),
@@ -354,19 +508,69 @@ function artifactRow(slug, a) {
354
508
 
355
509
  if (!dest) {
356
510
  // Unlinked: the three destinations it could be linked to.
511
+ // In FIXTURE mode the prototype's own destination copy is drawn, so the parity comparator still
512
+ // sees a like-for-like surface. In REAL mode a click opens a FORM: `linkArtDest` built the
513
+ // destination out of the artifact's own FILENAME — `PM / <basename>`, `ATL epic · <basename>` —
514
+ // which is a name for a page nobody created, recorded as though the PM had chosen it.
515
+ const openHere = (state.artLinkForm || {})[key] || null;
516
+ const inputId = 'artref-' + key.replace(/[^A-Za-z0-9]/g, '-');
517
+ const conn = (state.connectors && state.connectors.configured) || [];
518
+
519
+ const submit = (kind) => {
520
+ const node = document.getElementById(inputId);
521
+ const target = String((node && node.value) || '').trim();
522
+ if (!target) return;
523
+ const d = { kind, target, origin: 'typed', state: 'declared' };
524
+ setState((s) => ({
525
+ artLinks: { ...s.artLinks, [key]: d },
526
+ artLinkForm: { ...s.artLinkForm, [key]: null },
527
+ }));
528
+ declare('recordArtifactLink', { artifact_path: a.path, slug, kind, target, origin: 'typed', state: 'declared' });
529
+ };
530
+
357
531
  const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
358
532
  el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
359
533
  FX.ART_LINK_SRCS.map((ls) => el('button', {
360
534
  onClick: () => {
361
- const d = FX.linkArtDest(key, ls.kind);
362
- setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
363
- declare('recordArtifactLink', { artifact_path: a.path, slug, kind: d.kind, target: d.target });
535
+ if (FIXTURE) {
536
+ const d = FX.linkArtDest(key, ls.kind);
537
+ setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
538
+ return;
539
+ }
540
+ setState((s) => ({
541
+ artLinkForm: { ...s.artLinkForm, [key]: (s.artLinkForm || {})[key] === ls.kind ? null : ls.kind },
542
+ }));
364
543
  },
365
544
  style: 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
366
545
  hover: 'border-color:var(--ring)',
367
546
  }, '+ ', iv(ls.l))),
368
547
  );
369
- return el('div', { style: 'min-width:0' }, head, linkRow);
548
+
549
+ const form = openHere ? el('div', { style: 'margin-top:6px;padding:8px 10px;border:1px solid var(--border);background:var(--background)' },
550
+ el('div', { style: 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-bottom:6px' },
551
+ iv(conn.some((c) => c.kind === openHere)
552
+ ? 'paste the ' + openHere + ' reference this file corresponds to. a session on '
553
+ + (conn.find((c) => c.kind === openHere) || {}).profile + ' can find it for you — PMOS reads nothing.'
554
+ : 'paste the ' + openHere + ' reference this file corresponds to. nothing here can look it up,'
555
+ + ' so what you type is recorded as your own declaration.')),
556
+ el('input', {
557
+ id: inputId,
558
+ placeholder: openHere === 'jira' ? 'ATL-482 or its URL' : 'the page or document URL',
559
+ style: 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
560
+ }),
561
+ el('div', { style: 'display:flex;gap:6px;margin-top:7px;flex-wrap:wrap' },
562
+ el('button', {
563
+ onClick: () => submit(openHere),
564
+ style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
565
+ }, 'record'),
566
+ el('button', {
567
+ onClick: () => setState((s) => ({ artLinkForm: { ...s.artLinkForm, [key]: null } })),
568
+ style: 'font-family:var(--mono);font-size:10px;padding:4px 9px;min-height:26px;background:transparent;border:1px solid var(--border);color:var(--muted-foreground)',
569
+ }, 'cancel'),
570
+ ),
571
+ ) : null;
572
+
573
+ return el('div', { style: 'min-width:0' }, head, linkRow, form);
370
574
  }
371
575
 
372
576
  // Linked: the destination pill, the sync button, and the persisted result of the last approval.
@@ -384,7 +588,9 @@ function artifactRow(slug, a) {
384
588
  let panel = null;
385
589
  if (syncOpen) {
386
590
  const live = hasConnector(dest.kind);
387
- const inert = !live;
591
+ // FIXTURE mode draws the prototype's two working approvals so the comparator sees a like-for-like
592
+ // surface. REAL mode never enables them: they are publishing and read-back, both out of scope.
593
+ const inert = FIXTURE ? !live : true;
388
594
  const decide = (make) => () => {
389
595
  if (inert) return;
390
596
  setState((s) => ({
@@ -395,7 +601,7 @@ function artifactRow(slug, a) {
395
601
  const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
396
602
  panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
397
603
  el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
398
- iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
604
+ iv(FIXTURE ? (live ? FX.artDiffText(dest) : NO_CONNECTOR) : SYNC_OUT_OF_SCOPE)),
399
605
  el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
400
606
  el('button', {
401
607
  onClick: decide(FX.artSyncPushed), disabled: inert,
@@ -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' },