create-pmos 0.59.1 → 0.59.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `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@2d3ee9f
1
+ kernel-source: pmos@fdeb915
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-06T13:37:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T14:07:26Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -95,6 +95,8 @@ The bar at the top has Back (when you are deeper than a tab), Repository, the na
95
95
 
96
96
  Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
97
97
 
98
+ When the keyboard opens or closes, or you turn the phone, the terminal fits itself to the new space. The session is told its new size when the keyboard stops moving, not at every step, so an agent redraws its screen once. Scrolling the page does not resize the terminal.
99
+
98
100
  ## The older layout
99
101
 
100
102
  The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.59.1",
3
+ "version": "0.59.2",
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",
@@ -505,10 +505,11 @@ export function mountMobile(context) {
505
505
  if (theme) theme.addEventListener('click', toggleTheme);
506
506
  document.addEventListener('click', onChromeClick);
507
507
 
508
- if (window.visualViewport) {
509
- window.visualViewport.addEventListener('resize', refit);
510
- window.visualViewport.addEventListener('scroll', refit);
511
- }
508
+ /* `resize` only. A visual-viewport SCROLL moves the page under the keyboard and changes no box,
509
+ * so there is nothing to re-fit — but it fires on every frame of a touch scroll, and each one used
510
+ * to become a synthetic window `resize` that every resize listener on the page answered. That was
511
+ * the terminal stuttering under a finger on a phone. */
512
+ if (window.visualViewport) window.visualViewport.addEventListener('resize', refit);
512
513
  window.addEventListener('orientationchange', () => setTimeout(refit, 250));
513
514
 
514
515
  // Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
@@ -678,7 +678,25 @@ function syncSize(rec) {
678
678
  if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
679
679
  const cols = rec.term.cols, rows = rec.term.rows;
680
680
  if (!cols || !rows) return;
681
- if (rec.sentCols === cols && rec.sentRows === rows) return;
681
+ if (rec.sentCols === cols && rec.sentRows === rows) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; return; }
682
+ /* The FIRST statement on an attach goes out at once: until it does, the shell is drawing into the
683
+ * 80×24 it was spawned with. A CHANGE after that waits for the grid to settle. A phone's soft
684
+ * keyboard slides in over about a quarter of a second and the pane is re-fitted on every frame of
685
+ * it; each `resize` the PTY gets makes a full-screen agent CLI repaint everything it shows, so
686
+ * stating every intermediate grid buried the session under a dozen redraws of screens that were
687
+ * gone before they arrived. The local fit is not delayed — only what the shell is told. */
688
+ if (rec.sentCols == null) { sendSize(rec); return; }
689
+ clearTimeout(rec.sizeTimer);
690
+ rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; sendSize(rec); }, RESIZE_SETTLE_MS);
691
+ }
692
+
693
+ /** How long the grid must hold still before a change is stated to the PTY. See syncSize. */
694
+ const RESIZE_SETTLE_MS = 120;
695
+
696
+ function sendSize(rec) {
697
+ if (!rec.term || !rec.sessionId || rec.attachedId !== rec.sessionId) return;
698
+ const cols = rec.term.cols, rows = rec.term.rows;
699
+ if (!cols || !rows || (rec.sentCols === cols && rec.sentRows === rows)) return;
682
700
  if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
683
701
  }
684
702
 
@@ -694,19 +712,40 @@ function note(rec, line, force) {
694
712
  * The single entry point. Coalesced into one animation frame so the six triggers below cost one
695
713
  * measure per frame rather than six. A pane that is not in the document, or has no box yet, is
696
714
  * skipped — fitting a zero-sized element is how a terminal ends up 1×1 and stays there.
715
+ *
716
+ * A pane whose box has not moved since it was last fitted is NOT re-measured. Every state change
717
+ * in the app lands here (the `subscribe` below), and a fit is a getComputedStyle round-trip on the
718
+ * terminal's parent per pane — on a phone, that was a forced style pass on every render for a grid
719
+ * that could not have changed. The grid depends on the box and on the cell size; the box is the key
720
+ * here, and the one event that moves the cell size without moving the box (the web font arriving)
721
+ * passes `force`.
697
722
  */
698
723
  let refitQueued = false;
724
+ let refitForce = false;
699
725
 
700
- export function refitVisible() {
726
+ export function refitVisible(force) {
727
+ if (force === true) refitForce = true;
701
728
  if (refitQueued) return;
702
729
  refitQueued = true;
703
730
  const run = () => {
704
731
  refitQueued = false;
732
+ const forced = refitForce;
733
+ refitForce = false;
705
734
  for (const rec of panes.values()) {
706
735
  if (rec.kind !== 'real' || !rec.fit || !rec.host) continue;
707
736
  if (!rec.host.isConnected) continue;
708
- if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
709
- try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
737
+ const w = rec.host.offsetWidth, h = rec.host.offsetHeight;
738
+ if (!w || !h) continue;
739
+ if (!forced && rec.fitW === w && rec.fitH === h) { syncSize(rec); continue; }
740
+ try {
741
+ // Measured first, so a pane whose renderer cannot measure yet is retried next time rather
742
+ // than remembered as fitted at a grid it never reached.
743
+ const dims = rec.fit.proposeDimensions();
744
+ if (dims && dims.cols > 0 && dims.rows > 0) {
745
+ if (dims.cols !== rec.term.cols || dims.rows !== rec.term.rows) rec.fit.fit();
746
+ rec.fitW = w; rec.fitH = h;
747
+ }
748
+ } catch { /* a terminal mid-teardown */ }
710
749
  // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
711
750
  // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
712
751
  // the shell how wide it is. See the `attached` handler.
@@ -724,7 +763,8 @@ if (typeof window !== 'undefined') {
724
763
  subscribe(() => refitVisible());
725
764
  window.addEventListener('resize', () => refitVisible());
726
765
  document.addEventListener('visibilitychange', () => { if (!document.hidden) refitVisible(); });
727
- if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible()).catch(() => {});
766
+ // The face changes the CELL, not the box, so this one re-measures every pane regardless.
767
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible(true)).catch(() => {});
728
768
  }
729
769
 
730
770
  /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
@@ -931,6 +971,7 @@ export function disposeTerm(tab) {
931
971
  * pane for the id and detaches from the orphan instead. */
932
972
  if (rec.kind !== 'real') return;
933
973
  if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
974
+ if (rec.sizeTimer) { clearTimeout(rec.sizeTimer); rec.sizeTimer = null; }
934
975
  if (rec.sessionId) {
935
976
  send({ t: 'detach', id: rec.sessionId });
936
977
  SOCK.attached.delete(rec.sessionId);
@@ -1058,6 +1099,9 @@ export function termProbe() {
1058
1099
  */
1059
1100
  export function __handleForTest(m) { return handle(m); }
1060
1101
 
1061
- if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes, requestSpawn };
1102
+ /* `fit` is the name mobile.js asks for when the soft keyboard or a rotation moves the box. Without it
1103
+ * that module fell back to dispatching a synthetic window `resize`, which every resize listener on
1104
+ * the page answered — not only this one. */
1105
+ if (typeof window !== 'undefined') window.pmTerm = { refitVisible, fit: () => refitVisible(), termProbe, panes, requestSpawn };
1062
1106
 
1063
1107
  export default mountTerm;
@@ -453,6 +453,13 @@ function profileOverlay(profile) {
453
453
  }
454
454
 
455
455
  const SCROLLBACK = 200_000; // bytes of ring buffer per session
456
+ const SCROLLBACK_SLACK = 32_768; // how far the ring may run over before a trim (appendBuf)
457
+ // PTY output goes on the wire at most once per this window per session (queueOutput). node-pty
458
+ // hands over whatever one read() returned, which for an agent CLI repainting its screen is many
459
+ // small chunks — each one a JSON frame, a WebSocket message and a parse in the browser. The first
460
+ // chunk after a quiet spell is sent at once, so a keystroke's echo is never delayed by it.
461
+ const OUTPUT_COALESCE_MS = 4;
462
+ const OUTPUT_COALESCE_MAX = 64 * 1024; // …and flushed at once past this, so a flood stays bounded
456
463
  // How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
457
464
  // 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
458
465
  // a 2s cadence puts the worst-case error in "quiet 31s" at one tick — under the rounding the UI
@@ -2836,7 +2843,11 @@ function trimBuf(buf, limit) {
2836
2843
 
2837
2844
  function appendBuf(s, data) {
2838
2845
  s.buf += data;
2839
- if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
2846
+ // Trimmed with SLACK, not on every byte past the limit. Once a busy session's ring is full, a trim
2847
+ // per chunk flattens and scans the whole 200 KB string for each few bytes an agent CLI repaints —
2848
+ // a spinner alone is dozens of chunks a second. Letting the ring run SCROLLBACK_SLACK over and then
2849
+ // cutting it back to SCROLLBACK does the same work once per 32 KB of output.
2850
+ if (s.buf.length > SCROLLBACK + SCROLLBACK_SLACK) s.buf = trimBuf(s.buf, SCROLLBACK);
2840
2851
  s.bufDirty = true; // the periodic writer skips a session that is not dirty
2841
2852
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
2842
2853
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
@@ -2844,6 +2855,46 @@ function appendBuf(s, data) {
2844
2855
  s.parsed = null; // buffer moved; the cached parse is stale
2845
2856
  }
2846
2857
 
2858
+ /**
2859
+ * Put PTY output on the wire as at most ONE frame per OUTPUT_COALESCE_MS per session.
2860
+ *
2861
+ * Leading edge, then a window: a chunk that arrives when nothing has been sent recently goes out at
2862
+ * once — a keystroke's echo is never held back — and opens a window in which further chunks are
2863
+ * gathered and sent together when it closes. A burst (an agent CLI repainting its screen) therefore
2864
+ * costs one frame per window instead of one per node-pty read, and a lone echo costs nothing.
2865
+ *
2866
+ * The bytes reach the ring (`appendBuf`) at the same moment they reach the clients, never before:
2867
+ * a client that attaches in between gets `buf` without the pending bytes and then the flush, so it
2868
+ * sees every byte exactly once and in order. Anything that writes to `buf` directly while output may
2869
+ * be pending (the exit note) calls `flushOutput` first for the same reason.
2870
+ */
2871
+ function queueOutput(s, data) {
2872
+ if (!data) return;
2873
+ if (s._out) s._out += data; else s._out = data;
2874
+ if (!s._outTimer) { sendOutput(s); s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS); return; }
2875
+ if (s._out.length >= OUTPUT_COALESCE_MAX) sendOutput(s);
2876
+ }
2877
+ function closeOutputWindow(s) {
2878
+ s._outTimer = null;
2879
+ if (!s._out) return;
2880
+ // Output kept arriving through the window: send it, and keep gathering for one more window.
2881
+ sendOutput(s);
2882
+ s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS);
2883
+ }
2884
+ function flushOutput(s) {
2885
+ if (s._outTimer) { clearTimeout(s._outTimer); s._outTimer = null; }
2886
+ sendOutput(s);
2887
+ }
2888
+ function sendOutput(s) {
2889
+ const data = s._out;
2890
+ if (!data) return;
2891
+ s._out = '';
2892
+ appendBuf(s, data);
2893
+ if (!s.clients || !s.clients.size) return;
2894
+ const frame = JSON.stringify({ t: 'data', id: s.id, data });
2895
+ for (const c of s.clients) if (c.readyState === 1) c.send(frame);
2896
+ }
2897
+
2847
2898
  // Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
2848
2899
  // node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
2849
2900
  // the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
@@ -3077,8 +3128,7 @@ function attachPty(s, { seed = false } = {}) {
3077
3128
  onOutput: (pane, text) => {
3078
3129
  if (paneId && pane !== paneId) return; // only this session's pane
3079
3130
  if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
3080
- appendBuf(s, text);
3081
- for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: text }));
3131
+ queueOutput(s, text);
3082
3132
  },
3083
3133
  });
3084
3134
  p.onData((d) => parser.feed(d));
@@ -3086,6 +3136,7 @@ function attachPty(s, { seed = false } = {}) {
3086
3136
  // on the client PTY's own window size.
3087
3137
  client.resize(s.cols || 80, s.rows || 24);
3088
3138
  p.onExit(({ exitCode }) => {
3139
+ flushOutput(s); // whatever the pane said last lands before a reconnect or the exit note
3089
3140
  s.pty = null;
3090
3141
  // Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
3091
3142
  // the tmux session is still alive, the process lives on — reconnect (bounded, to never spin).
@@ -3327,11 +3378,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3327
3378
  announceStateHealth(s);
3328
3379
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
3329
3380
  announceModel(s);
3330
- p.onData((d) => {
3331
- appendBuf(s, d);
3332
- for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
3333
- });
3381
+ p.onData((d) => queueOutput(s, d));
3334
3382
  p.onExit(({ exitCode }) => {
3383
+ flushOutput(s); // the last bytes the process wrote come before the exit note, in the ring and on the wire
3335
3384
  s.state = 'exited'; s.pty = null;
3336
3385
  const fast = Date.now() - startedMs < 800; // exited almost immediately → surface a hint
3337
3386
  s.note = fast
@@ -8495,7 +8544,7 @@ module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost },
8495
8544
  stripLegacyClientScreen,
8496
8545
  // the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
8497
8546
  // inside an escape sequence or between the halves of a surrogate pair.
8498
- trimBuf, SCROLLBACK,
8547
+ trimBuf, SCROLLBACK, queueOutput, flushOutput, OUTPUT_COALESCE_MS,
8499
8548
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
8500
8549
  // asserted without a server that has no clients (the suite always has both).
8501
8550
  shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,