create-pmos 0.7.1 → 0.7.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/README.md CHANGED
@@ -333,6 +333,35 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.7.2
337
+
338
+ **A fix — read this if you use the Terminal Workspace on a machine with `tmux` installed.**
339
+
340
+ 0.7.0 and 0.7.1 both shipped a workspace terminal that renders **gibberish** there. Box-drawing
341
+ characters, check marks, spinners, emoji and accented letters all arrived damaged, and the output
342
+ lost its structure — which matters because agent CLIs print those constantly, so in practice most
343
+ frames were affected. If your terminal has looked broken since 0.7.0, this is why, and it was our
344
+ bug, not your setup.
345
+
346
+ The cause: the workspace drives `tmux` in control mode, and that stream is **bytes**. `tmux` escapes
347
+ only the control characters and the backslash — every byte of a UTF-8 character passes through raw.
348
+ The workspace decoded that stream as text and then narrowed it back to bytes, which does not merely
349
+ lose characters, it *manufactures* control codes out of the wreckage. `┌` became a **form feed**,
350
+ which is why the layout collapsed and not just the glyphs.
351
+
352
+ - **Fixed.** The control stream is now handled as bytes end to end, with a single UTF-8 decode in
353
+ one place. A character split across two `tmux` frames still survives.
354
+ - **A second, smaller source of the same symptom, also fixed.** The scrollback ring trims from the
355
+ front once it fills, and it used to cut at an arbitrary offset — routinely *inside* an escape
356
+ sequence, so the leftover fragment replayed as literal rubble like `5;203m` when you reattached.
357
+ It now cuts at a line boundary.
358
+ - **Nothing else changed.** 0.7.2 carries this fix and only this fix.
359
+
360
+ **You were only affected if `tmux` is on your PATH.** The workspace falls back to a direct terminal
361
+ when it is not, and that path never had the defect — which is exactly why this survived two
362
+ releases. The regression test now reports which of the two transports it exercised, so a green run
363
+ on a machine without `tmux` can no longer read as if it had covered the one that broke.
364
+
336
365
  ## What's new in 0.7.1
337
366
 
338
367
  **A correction to 0.7.0 — read this if you used the review surface.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.7.1",
3
+ "version": "0.7.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. `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@0c57593
1
+ kernel-source: pmos@bd8056d
2
2
  assembled: 2026-07-28
3
3
  mode: vendor
@@ -17,28 +17,55 @@
17
17
  // The protocol, as it actually arrives on the wire:
18
18
  // \x1bP1000p DCS introducer, once, before the first line
19
19
  // %begin <t> <n> <f> … %end a command's reply is wrapped in a begin/end block
20
- // %output %0 line-1\015\012… pane bytes, NON-PRINTABLES OCTAL-ESCAPED (\015 = CR)
20
+ // %output %0 line-1\015\012… pane bytes, with SOME bytes octal-escaped (\015 = CR)
21
21
  // %exit [reason] the control client is going away
22
22
  // %session-changed, %window-*, … notifications we do not need
23
- // Lines are CRLF-terminated. Because every byte outside printable ASCII is octal-escaped, the
24
- // control stream itself is pure ASCII — so reading the client PTY as utf8 is safe, and the real
25
- // UTF-8 decoding happens HERE, after unescaping, with a streaming decoder (a multi-byte character
26
- // can be split across two %output frames).
23
+ //
24
+ // THE CONTROL STREAM IS BYTES, NOT TEXT. tmux escapes only `c < 0x20` and the backslash itself
25
+ // (control.c); every byte >= 0x80 is written through RAW. So a `─` (E2 94 80) arrives on the wire
26
+ // as three raw high bytes, NOT as three octal escapes. Reading the client PTY as a utf8 STRING and
27
+ // then narrowing each code unit back to a byte therefore destroys every non-ASCII character —
28
+ // U+2500 & 0xff = 0x00, U+250C & 0xff = 0x0C (a form feed), U+2713 & 0xff = 0x13 — which is
29
+ // injected control codes and replacement characters in the pane: box-drawing rubble, mangled
30
+ // layout, gibberish. Verified against tmux 3.4.
31
+ //
32
+ // So this parser works on BUFFERS end to end, and the single UTF-8 decode happens HERE, after
33
+ // unescaping, with a per-pane streaming decoder (a multi-byte character can be split across two
34
+ // %output frames). The caller must read the control client's PTY with `encoding: null`.
27
35
 
28
36
  const { StringDecoder } = require('string_decoder');
29
37
 
38
+ const LF = 0x0a, CR = 0x0d, BACKSLASH = 0x5c, SPACE = 0x20, PCT = 0x25;
39
+ const EMPTY = Buffer.alloc(0);
40
+ const DCS = Buffer.from('\x1bP1000p', 'latin1'); // introducer, once, before the first line
41
+ const ST = Buffer.from('\x1b\\', 'latin1'); // string terminator, may trail the last
42
+
43
+ const isOct = (b) => b >= 0x30 && b <= 0x37;
44
+
45
+ // A control-mode chunk is a byte stream. A string is accepted for ASCII test fixtures and is read
46
+ // as latin1 — one code unit per byte — because that is the only faithful string view of bytes.
47
+ const toBytes = (chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'latin1'));
48
+
49
+ function startsWithAscii(buf, s) {
50
+ if (buf.length < s.length) return false;
51
+ for (let i = 0; i < s.length; i++) if (buf[i] !== s.charCodeAt(i)) return false;
52
+ return true;
53
+ }
54
+
30
55
  // tmux escapes a byte as a backslash + exactly three octal digits (backslash itself is \134),
31
- // so there is no two-character `\\` case to handle — only the octal form.
32
- function unescapeToBytes(s) {
33
- const out = Buffer.allocUnsafe(s.length);
56
+ // so there is no two-character `\\` case to handle — only the octal form. Every other byte,
57
+ // INCLUDING the raw >= 0x80 bytes of a UTF-8 sequence, passes through untouched.
58
+ function unescapeToBytes(input) {
59
+ const buf = toBytes(input);
60
+ const out = Buffer.allocUnsafe(buf.length);
34
61
  let n = 0;
35
- for (let i = 0; i < s.length; i++) {
36
- const c = s.charCodeAt(i);
37
- if (c === 0x5c && i + 3 < s.length) { // '\'
38
- const oct = s.slice(i + 1, i + 4);
39
- if (/^[0-7]{3}$/.test(oct)) { out[n++] = parseInt(oct, 8) & 0xff; i += 3; continue; }
62
+ for (let i = 0; i < buf.length; i++) {
63
+ if (buf[i] === BACKSLASH && i + 3 < buf.length && isOct(buf[i + 1]) && isOct(buf[i + 2]) && isOct(buf[i + 3])) {
64
+ out[n++] = (((buf[i + 1] - 0x30) << 6) | ((buf[i + 2] - 0x30) << 3) | (buf[i + 3] - 0x30)) & 0xff;
65
+ i += 3;
66
+ continue;
40
67
  }
41
- out[n++] = c & 0xff;
68
+ out[n++] = buf[i];
42
69
  }
43
70
  return out.subarray(0, n);
44
71
  }
@@ -49,51 +76,68 @@ function unescapeToBytes(s) {
49
76
  * @param {(paneId: string, text: string) => void} h.onOutput decoded pane bytes
50
77
  * @param {(reason: string) => void} [h.onExit] %exit seen
51
78
  * @param {(lines: string[], isError: boolean) => void} [h.onBlock] a %begin…%end command reply
52
- * @returns {{feed: (chunk: string) => void}}
79
+ * @returns {{feed: (chunk: Buffer|string) => void}}
53
80
  */
54
81
  function createParser({ onOutput, onExit, onBlock } = {}) {
55
- let pending = ''; // partial line carried between chunks
82
+ let pending = EMPTY; // partial line carried between chunks, as BYTES
56
83
  let inBlock = false;
57
84
  let block = [];
58
85
  const decoders = new Map(); // paneId → StringDecoder, for split multi-byte chars
59
86
 
60
87
  function line(raw) {
61
88
  // The DCS introducer rides in front of the very first line; the terminator can trail the last.
62
- let l = raw.replace(/^\x1bP1000p/, '').replace(/\x1b\\$/, '');
89
+ let l = raw;
90
+ if (startsWithAscii(l, '\x1bP1000p')) l = l.subarray(DCS.length);
91
+ if (l.length >= ST.length && l[l.length - 2] === ST[0] && l[l.length - 1] === ST[1]) {
92
+ l = l.subarray(0, l.length - ST.length);
93
+ }
63
94
  if (inBlock) {
64
- if (l.startsWith('%end') || l.startsWith('%error')) {
95
+ // A command reply is text (it is what tmux printed for `capture-pane` and friends), so it
96
+ // decodes as UTF-8 in one shot — a reply line always arrives whole.
97
+ if (startsWithAscii(l, '%end') || startsWithAscii(l, '%error')) {
65
98
  inBlock = false;
66
- if (onBlock) onBlock(block, l.startsWith('%error'));
99
+ if (onBlock) onBlock(block, startsWithAscii(l, '%error'));
67
100
  block = [];
68
- } else block.push(l);
101
+ } else block.push(l.toString('utf8'));
69
102
  return;
70
103
  }
71
- if (l.startsWith('%begin')) { inBlock = true; block = []; return; }
72
- if (l.startsWith('%output ')) {
73
- // %output %<pane> <escaped bytes> — the data may legitimately be empty
74
- const m = /^%output (%\d+) ?([\s\S]*)$/.exec(l);
75
- if (!m || !onOutput) return;
76
- const pane = m[1];
104
+ if (startsWithAscii(l, '%begin')) { inBlock = true; block = []; return; }
105
+ if (startsWithAscii(l, '%output ')) {
106
+ // %output %<pane> <bytes> — the data may legitimately be empty. The header is pure ASCII,
107
+ // so it is scanned by byte offset; everything after the separating space is PANE BYTES and is
108
+ // never treated as text until the decoder below.
109
+ if (!onOutput) return;
110
+ let j = 8; // past '%output '
111
+ if (l[j] !== PCT) return; // '%' of the pane id
112
+ let k = j + 1;
113
+ while (k < l.length && l[k] >= 0x30 && l[k] <= 0x39) k++;
114
+ if (k === j + 1) return; // no digits — not a pane id
115
+ const pane = l.subarray(j, k).toString('latin1');
116
+ const data = l[k] === SPACE ? l.subarray(k + 1) : l.subarray(k);
77
117
  let dec = decoders.get(pane);
78
118
  if (!dec) { dec = new StringDecoder('utf8'); decoders.set(pane, dec); }
79
- const text = dec.write(unescapeToBytes(m[2]));
119
+ const text = dec.write(unescapeToBytes(data));
80
120
  if (text) onOutput(pane, text);
81
121
  return;
82
122
  }
83
- if (l.startsWith('%exit')) { if (onExit) onExit(l.slice(5).trim()); return; }
123
+ if (startsWithAscii(l, '%exit')) { if (onExit) onExit(l.subarray(5).toString('utf8').trim()); return; }
84
124
  // every other %notification (session-changed, window-add, layout-change, …) is not needed here
85
125
  }
86
126
 
87
127
  return {
88
128
  feed(chunk) {
89
- pending += chunk;
90
- let i;
91
- while ((i = pending.indexOf('\n')) !== -1) {
92
- let l = pending.slice(0, i);
93
- pending = pending.slice(i + 1);
94
- if (l.endsWith('\r')) l = l.slice(0, -1);
95
- line(l);
129
+ const b = toBytes(chunk);
130
+ pending = pending.length ? Buffer.concat([pending, b]) : b;
131
+ // %output data never contains a raw LF — tmux escapes every byte below 0x20 — so splitting
132
+ // the stream on LF can never cut a frame in half.
133
+ let start = 0, i;
134
+ while ((i = pending.indexOf(LF, start)) !== -1) {
135
+ const end = i > start && pending[i - 1] === CR ? i - 1 : i;
136
+ line(pending.subarray(start, end));
137
+ start = i + 1;
96
138
  }
139
+ // Copy the remainder rather than holding a subarray: a subarray pins the whole parent chunk.
140
+ pending = start >= pending.length ? EMPTY : (start ? Buffer.from(pending.subarray(start)) : pending);
97
141
  },
98
142
  };
99
143
  }
@@ -898,9 +898,27 @@ function heartbeat() {
898
898
  }
899
899
  try { const _hb = setInterval(heartbeat, LIVENESS_MS); if (_hb.unref) _hb.unref(); } catch {}
900
900
 
901
+ // Trim the ring from the FRONT at a LINE boundary. A blind slice cuts wherever the byte count
902
+ // lands — which is routinely INSIDE an escape sequence, and the truncated tail then replays as
903
+ // literal rubble ("5;203m") the next time a client attaches and we write the buffer to xterm.js;
904
+ // or BETWEEN the halves of a surrogate pair, and the lone surrogate becomes U+FFFD on the JSON hop
905
+ // to the browser. Neither is recoverable downstream. An escape sequence never spans a newline, so
906
+ // moving the cut forward to just past the next one makes both impossible. If the kept region has no
907
+ // newline near its head (one enormous line) the raw cut stands, stepping off a lone low surrogate.
908
+ const TRIM_SCAN = 8192; // how far past the cut to look for a boundary
909
+ function trimBuf(buf, limit) {
910
+ if (buf.length <= limit) return buf;
911
+ let cut = buf.length - limit;
912
+ const nl = buf.indexOf('\n', cut);
913
+ if (nl !== -1 && nl - cut <= TRIM_SCAN) return buf.slice(nl + 1);
914
+ const c = buf.charCodeAt(cut);
915
+ if (c >= 0xdc00 && c <= 0xdfff) cut++; // never start on a low surrogate
916
+ return buf.slice(cut);
917
+ }
918
+
901
919
  function appendBuf(s, data) {
902
920
  s.buf += data;
903
- if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
921
+ if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
904
922
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
905
923
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
906
924
  s.lastOutputAt = Date.now();
@@ -1005,7 +1023,7 @@ function seedFromPaneHistory(s, name) {
1005
1023
  if (r.status !== 0 || !r.stdout) return false;
1006
1024
  const text = r.stdout.replace(/\s+$/, '').replace(/\r?\n/g, '\r\n');
1007
1025
  if (!text) return false;
1008
- s.buf = text + '\r\n';
1026
+ s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
1009
1027
  s.parsed = null;
1010
1028
  return true;
1011
1029
  }
@@ -1022,8 +1040,14 @@ function attachPty(s, { seed = false } = {}) {
1022
1040
  const startedMs = Date.now();
1023
1041
  let p;
1024
1042
  try {
1043
+ // `encoding: null` is LOAD-BEARING, not a style choice. A control-mode stream is BYTES: tmux
1044
+ // escapes only c < 0x20 and the backslash, so every byte of a UTF-8 sequence rides through raw.
1045
+ // Letting node-pty decode it to a string first and re-narrowing to bytes in the parser destroyed
1046
+ // every non-ASCII character — box drawing became NULs and form feeds, ✓ became 0x13, emoji and
1047
+ // accents became U+FFFD. The parser owns the one and only decode. See lib/tmux-control.js.
1025
1048
  p = pty.spawn(TMUX, ['-L', TMUX_SOCK, '-CC', 'attach-session', '-t', name],
1026
- { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
1049
+ { name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd,
1050
+ env: sessionEnv(), encoding: null });
1027
1051
  } catch (e) {
1028
1052
  // A broken node-pty (e.g. a non-executable spawn-helper) throws synchronously here. This must
1029
1053
  // NOT propagate: on the startup first-shell it would crash the whole server and blank the
@@ -2210,6 +2234,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
2210
2234
  // the legacy-scrollback migration, exported so the suite can assert BOTH halves: the tmux
2211
2235
  // client's preamble is stripped, an inner TUI's own alt-screen use is not.
2212
2236
  stripLegacyClientScreen,
2237
+ // the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
2238
+ // inside an escape sequence or between the halves of a surrogate pair.
2239
+ trimBuf, SCROLLBACK,
2213
2240
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
2214
2241
  // asserted without a server that has no clients (the suite always has both).
2215
2242
  shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS };