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 +29 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/tmux-control.js +79 -35
- package/workspace/server.js +30 -3
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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
33
|
-
|
|
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 <
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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++] =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
72
|
-
if (l
|
|
73
|
-
// %output %<pane> <
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
119
|
+
const text = dec.write(unescapeToBytes(data));
|
|
80
120
|
if (text) onOutput(pane, text);
|
|
81
121
|
return;
|
|
82
122
|
}
|
|
83
|
-
if (l
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
}
|
package/workspace/server.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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 };
|