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.
@@ -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
@@ -3792,6 +3850,11 @@ async function handleRequest(req, res) {
3792
3850
  : null,
3793
3851
  artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3794
3852
  initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3853
+ // Which destinations a session could look up, derived from the PM's agent profiles. The home
3854
+ // directory is passed IN rather than read inside four-panel.js for the same reason
3855
+ // readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
3856
+ // tested against the machine running the suite.
3857
+ homeDir: os.homedir(),
3795
3858
  });
3796
3859
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3797
3860
  return json(res, { ws: w ? w.id : null, ...out });
@@ -3819,7 +3882,20 @@ async function handleRequest(req, res) {
3819
3882
  built.row,
3820
3883
  );
3821
3884
  // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3822
- return json(res, { ...wrote, connectors: fourPanel.connectors() }, wrote.ok ? 200 : 500);
3885
+ return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
3886
+ }
3887
+ if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
3888
+ // The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
3889
+ // it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,
3890
+ // an agent session holding the PM's own MCP performs it, and the result comes back through
3891
+ // /api/initiative/link like any other declaration. There is no branch here that reaches out.
3892
+ let body = {};
3893
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
3894
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
3895
+ const built = fourPanel.lookupTask({
3896
+ kind: body.kind, subject: body.subject, hint: body.hint, profile: body.profile,
3897
+ });
3898
+ return json(res, built, built.ok ? 200 : 400);
3823
3899
  }
3824
3900
  if (p === '/api/search') {
3825
3901
  // Episodic recall over this workspace's own past sessions. Localhost only, like everything
@@ -4406,6 +4482,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
4406
4482
  // (one repo registered twice is one tree; two worktrees of one repo are two) without driving
4407
4483
  // every case through a spawn.
4408
4484
  treeForWs, treeTenants, treeWriter, treeClaims,
4485
+ // …and the DESCRIBED form, exported for the same reason: "what is that session doing and what
4486
+ // would ending it cost?" is the half of co-tenancy the PM actually acts on, so the suite asserts
4487
+ // it directly rather than reading it back out of a rendered sentence.
4488
+ describePeer, treePeersDescribed,
4409
4489
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
4410
4490
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
4411
4491
  scrub, harnessOf,