create-pmos 0.6.4 → 0.7.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.
- package/README.md +37 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/scripts/gate-initiative.sh +4 -1
- package/workspace/lib/apply-change.js +106 -0
- package/workspace/lib/correction-record.js +127 -0
- package/workspace/lib/finding-contract.js +162 -0
- package/workspace/lib/harness-parse.js +5 -1
- package/workspace/lib/layout.js +171 -0
- package/workspace/lib/skill-store.js +28 -9
- package/workspace/lib/tmux-control.js +124 -0
- package/workspace/public/app.js +727 -87
- package/workspace/public/index.html +88 -23
- package/workspace/public/styles.css +195 -19
- package/workspace/server.js +325 -14
- package/workspace/skills/doc-review.skill +114 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* layout — the workspace's arrangement model (workspace-layout-system, R1/R2/R7/R8).
|
|
2
|
+
*
|
|
3
|
+
* One source of truth for "what is an arrangement", used on BOTH sides:
|
|
4
|
+
* - server.js requires it (validate + clamp before anything is written to disk)
|
|
5
|
+
* - the browser loads it as /lib/layout.js before app.js (window.PMOSLayout)
|
|
6
|
+
* so a layout the client can produce and a layout the server will store can never disagree.
|
|
7
|
+
*
|
|
8
|
+
* A layout is a NAMED STATE TUPLE. The grid template is COMPUTED from it (gridFor) rather than
|
|
9
|
+
* hand-written per layout, because the twelve side×reader combinations are exactly where a
|
|
10
|
+
* hand-written template drifts: a column count that no longer matches its area row produces an
|
|
11
|
+
* unusable window, and the PM's live agent sessions are inside it.
|
|
12
|
+
*
|
|
13
|
+
* Two properties here are load-bearing and tested directly:
|
|
14
|
+
* 1. gridFor() always returns a template whose column count equals every area row's cell count.
|
|
15
|
+
* 2. sanitize() turns ANY input — a truncated file, a hand-edited JSON, a future version's
|
|
16
|
+
* extra keys — into a usable set of layouts. It never throws and never returns empty.
|
|
17
|
+
* The second is the reason the veto dimension on the rubric can be satisfied at all: the failure
|
|
18
|
+
* mode this feature must not have is a blank body or a zero-width column at boot.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
(function (root, factory) {
|
|
22
|
+
const api = factory();
|
|
23
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
24
|
+
else root.PMOSLayout = api;
|
|
25
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
26
|
+
|
|
27
|
+
const SIDE = ['left', 'right', 'off'];
|
|
28
|
+
const READER = ['left', 'right', 'bottom', 'off']; // FOUR positions (R1) — every position
|
|
29
|
+
const SPLIT = ['off', 'on']; // the state can hold has a control
|
|
30
|
+
const FOLD_KEYS = ['ws', 'sess', 'lib', 'doc'];
|
|
31
|
+
|
|
32
|
+
const SIDE_MIN = 160, SIDE_MAX = 520;
|
|
33
|
+
const READ_MIN = 280, READ_MAX = 1000;
|
|
34
|
+
const BOTTOM_H = 300; // the reader's height when docked bottom
|
|
35
|
+
|
|
36
|
+
// `review` is byte-for-byte the arrangement that shipped before this feature (R2): a PM who
|
|
37
|
+
// never opens the layout control must see no change at all.
|
|
38
|
+
const STOCK = [
|
|
39
|
+
{ id: 'focus', name: 'focus', key: 1, side: 'off', reader: 'off', split: 'off', sideW: 262, readW: 400 },
|
|
40
|
+
{ id: 'read', name: 'read', key: 2, side: 'left', reader: 'left', split: 'off', sideW: 214, readW: 760 },
|
|
41
|
+
{ id: 'compare', name: 'compare', key: 3, side: 'left', reader: 'off', split: 'on', sideW: 214, readW: 400 },
|
|
42
|
+
{ id: 'review', name: 'review', key: 4, side: 'left', reader: 'right', split: 'off', sideW: 262, readW: 400 },
|
|
43
|
+
];
|
|
44
|
+
const DEFAULT_ID = 'review';
|
|
45
|
+
const STOCK_IDS = STOCK.map((l) => l.id);
|
|
46
|
+
const isStock = (id) => STOCK_IDS.indexOf(id) !== -1;
|
|
47
|
+
|
|
48
|
+
const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n);
|
|
49
|
+
const oneOf = (v, list, fallback) => (list.indexOf(v) !== -1 ? v : fallback);
|
|
50
|
+
const num = (v, fallback) => {
|
|
51
|
+
const n = typeof v === 'number' ? v : parseFloat(v);
|
|
52
|
+
return Number.isFinite(n) ? n : fallback;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function defaultFolds() { return { ws: true, sess: true, lib: true, doc: true }; }
|
|
56
|
+
function folds(v) {
|
|
57
|
+
const out = defaultFolds();
|
|
58
|
+
if (v && typeof v === 'object') for (const k of FOLD_KEYS) if (typeof v[k] === 'boolean') out[k] = v[k];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* A stock layout, fresh. `restore` (R4) is this function — auto-keep is destructive, so the
|
|
63
|
+
definition has to be re-derivable from code, never read back from the stored copy. */
|
|
64
|
+
function stockLayout(id) {
|
|
65
|
+
const s = STOCK.find((l) => l.id === id);
|
|
66
|
+
if (!s) return null;
|
|
67
|
+
return Object.assign({}, s, { folds: defaultFolds(), lastUsedAt: 0 });
|
|
68
|
+
}
|
|
69
|
+
function stockLayouts() { return STOCK.map((s) => stockLayout(s.id)); }
|
|
70
|
+
|
|
71
|
+
/* Coerce one layout-shaped thing into a valid layout. Unknown keys are dropped, bad values fall
|
|
72
|
+
back, sizes clamp. Never throws. */
|
|
73
|
+
function layout(raw, index) {
|
|
74
|
+
const r = (raw && typeof raw === 'object') ? raw : {};
|
|
75
|
+
const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim().slice(0, 64) : 'c' + (index + 1);
|
|
76
|
+
const stock = isStock(id);
|
|
77
|
+
const base = stock ? STOCK.find((l) => l.id === id) : null;
|
|
78
|
+
let name = typeof r.name === 'string' ? r.name.trim().slice(0, 40) : '';
|
|
79
|
+
if (!name) name = base ? base.name : 'layout ' + (index + 1);
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
name: stock ? base.name : name, // stock names are not editable (R5)
|
|
83
|
+
key: stock ? base.key : null, // ⌃1–⌃4 belong to the stock four
|
|
84
|
+
side: oneOf(r.side, SIDE, base ? base.side : 'left'),
|
|
85
|
+
reader: oneOf(r.reader, READER, base ? base.reader : 'right'),
|
|
86
|
+
split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
|
|
87
|
+
sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
|
|
88
|
+
readW: Math.round(clamp(num(r.readW, base ? base.readW : 400), READ_MIN, READ_MAX)),
|
|
89
|
+
folds: folds(r.folds),
|
|
90
|
+
lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Turn ANY stored value into a usable {layouts, currentId}. This is the boot path, so its
|
|
95
|
+
contract is absolute: it never throws, the four stock layouts are always present, and
|
|
96
|
+
currentId always names a layout that exists. A file containing `{`, `null`, `[]`, a string,
|
|
97
|
+
or a future version's shape all land on a usable window (rubric dimension 1). */
|
|
98
|
+
function sanitize(raw) {
|
|
99
|
+
let doc = raw;
|
|
100
|
+
if (typeof doc === 'string') { try { doc = JSON.parse(doc); } catch { doc = null; } }
|
|
101
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
|
|
102
|
+
|
|
103
|
+
const list = Array.isArray(doc.layouts) ? doc.layouts : [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const custom = [];
|
|
106
|
+
for (let i = 0; i < list.length && custom.length < 40; i++) {
|
|
107
|
+
const l = layout(list[i], i);
|
|
108
|
+
if (isStock(l.id) || seen.has(l.id)) continue; // stock entries are rebuilt below
|
|
109
|
+
seen.add(l.id);
|
|
110
|
+
custom.push(l);
|
|
111
|
+
}
|
|
112
|
+
// stock layouts keep the PM's adjustments (auto-keep, R4) but can never go missing
|
|
113
|
+
const stored = new Map();
|
|
114
|
+
for (const raw2 of list) if (raw2 && isStock(raw2.id)) stored.set(raw2.id, raw2);
|
|
115
|
+
const stock = STOCK.map((s, i) => (stored.has(s.id) ? layout(stored.get(s.id), i) : stockLayout(s.id)));
|
|
116
|
+
|
|
117
|
+
const layouts = stock.concat(custom);
|
|
118
|
+
const wanted = typeof doc.currentId === 'string' ? doc.currentId : DEFAULT_ID;
|
|
119
|
+
const currentId = layouts.some((l) => l.id === wanted) ? wanted : DEFAULT_ID;
|
|
120
|
+
return { layouts, currentId };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* The computed grid. Returns {columns, rows, areas} as CSS strings.
|
|
124
|
+
INVARIANT: every area row names exactly as many cells as `columns` has tracks. */
|
|
125
|
+
function gridFor(l) {
|
|
126
|
+
const S = l.sideW + 'px', R = l.readW + 'px';
|
|
127
|
+
if (l.reader === 'bottom') {
|
|
128
|
+
const rows = 'minmax(0,1fr) ' + BOTTOM_H + 'px';
|
|
129
|
+
if (l.side === 'left') return { columns: S + ' minmax(0,1fr)', rows, areas: '"side term" "side read"' };
|
|
130
|
+
if (l.side === 'right') return { columns: 'minmax(0,1fr) ' + S, rows, areas: '"term side" "read side"' };
|
|
131
|
+
return { columns: 'minmax(0,1fr)', rows, areas: '"term" "read"' };
|
|
132
|
+
}
|
|
133
|
+
const cols = [], names = [];
|
|
134
|
+
if (l.side === 'left') { cols.push(S); names.push('side'); }
|
|
135
|
+
if (l.reader === 'left') { cols.push(R); names.push('read'); }
|
|
136
|
+
cols.push('minmax(0,1fr)'); names.push('term');
|
|
137
|
+
if (l.reader === 'right'){ cols.push(R); names.push('read'); }
|
|
138
|
+
if (l.side === 'right') { cols.push(S); names.push('side'); }
|
|
139
|
+
return { columns: cols.join(' '), rows: 'minmax(0,1fr)', areas: '"' + names.join(' ') + '"' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* A layout that hides every pane is legal (`focus` is exactly that) — but a layout the PM
|
|
143
|
+
cannot get OUT of is not. The header control is always rendered, so the only real dead end
|
|
144
|
+
would be a template with no terminal track; assert it here so a future edit cannot introduce
|
|
145
|
+
one silently (rubric dimension 1, checklist item 4). */
|
|
146
|
+
function hasEscape(l) {
|
|
147
|
+
return gridFor(l).areas.indexOf('term') !== -1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
|
|
151
|
+
lost on upgrade. Reads the five localStorage values the shipped app wrote:
|
|
152
|
+
rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off). */
|
|
153
|
+
function migrateLegacy(legacy) {
|
|
154
|
+
const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
|
|
155
|
+
const review = stockLayout(DEFAULT_ID);
|
|
156
|
+
const railOff = g('rail') === 'off';
|
|
157
|
+
if (railOff) review.reader = 'off';
|
|
158
|
+
const w = num(String(g('railw') || '').replace('px', ''), null);
|
|
159
|
+
if (w !== null) review.readW = Math.round(clamp(w, READ_MIN, READ_MAX));
|
|
160
|
+
if (g('fold-lib') === 'off') review.folds.lib = false;
|
|
161
|
+
if (g('fold-doc') === 'off') review.folds.doc = false;
|
|
162
|
+
const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
|
|
163
|
+
return { layouts, currentId: DEFAULT_ID };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
SIDE, READER, SPLIT, FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
|
|
168
|
+
SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, BOTTOM_H,
|
|
169
|
+
isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
@@ -107,7 +107,7 @@ function scaffoldSkill({ name, description, output, body } = {}) {
|
|
|
107
107
|
let out = Array.isArray(output) ? output : String(output || '').split(/[\n,]/);
|
|
108
108
|
out = out.map((x) => String(x).replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, MAX_OUTPUT);
|
|
109
109
|
if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
|
|
110
|
-
const proc = String(body || '').trim() || defaultBody(label, desc, out);
|
|
110
|
+
const proc = String(body || '').trim() || defaultBody(label, desc, out, slug);
|
|
111
111
|
const content = [
|
|
112
112
|
'---',
|
|
113
113
|
`name: ${slug}`,
|
|
@@ -125,18 +125,37 @@ function scaffoldSkill({ name, description, output, body } = {}) {
|
|
|
125
125
|
return { ok: true, slug, label, description: desc, output: out, content };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
// A spec-shaped scaffold body (SKILL-FORMAT.md): procedure-first, every step ending on a checkable
|
|
129
|
+
// done-condition, and — load-bearing — the workspace FILING CONTRACT. A scheduled agent skill is NOT
|
|
130
|
+
// auto-filed by the server (fireSchedule's agent branch only spawns the PTY); the body itself must
|
|
131
|
+
// write the deliverable and append a STRUCTURED needs.json object, or the console reds the queue
|
|
132
|
+
// (needsResult treats a non-array queue as corrupt). No prose "note to needs-you", ever. This is a
|
|
133
|
+
// starting shape a PM or the AI-draft session then sharpens with the task's real steps.
|
|
134
|
+
function defaultBody(label, desc, out, slug) {
|
|
135
|
+
const id = slug || slugify(label) || 'run';
|
|
129
136
|
return [
|
|
130
|
-
`You are running the "${label}" skill in this PMOS workspace (local, file mode)
|
|
137
|
+
`You are running the "${label}" skill in this PMOS workspace (local, file mode). Work only from`,
|
|
138
|
+
"this repository's own files, git, and CLIs — no network (D54).",
|
|
131
139
|
'',
|
|
132
|
-
`Goal: ${desc || '(describe what this skill
|
|
140
|
+
`Goal: ${desc || '(describe, in one line, what this skill produces)'}`,
|
|
133
141
|
'',
|
|
134
|
-
'
|
|
135
|
-
'
|
|
136
|
-
|
|
142
|
+
'Procedure — run the numbered steps in order; each ends on a checkable done-condition:',
|
|
143
|
+
'',
|
|
144
|
+
"1. Gather inputs from this repo's own local sources — name the exact commands/paths the run",
|
|
145
|
+
' depends on (e.g. `git log`, `gh pr view <n> --json title,body`, a file under `planning/`).',
|
|
146
|
+
' Done when: every input the sections below need is in hand, each traceable to a real source.',
|
|
147
|
+
'2. Produce each required section from those inputs, in the exact order of the output contract.',
|
|
148
|
+
' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
|
|
149
|
+
' Done when: every contract section is filled or explicitly "None this period.".',
|
|
150
|
+
`3. File the result so the PM sees it. Pick a unique run id (e.g. \`${id}-<UTC-timestamp>\`), then:`,
|
|
151
|
+
' a. Write the deliverable Markdown to `workspace/state/deliverables/<run-id>.md`.',
|
|
152
|
+
' b. Read `workspace/state/needs.json` (treat a missing file as `[]`), append EXACTLY this object,',
|
|
153
|
+
' and write the whole array back — it MUST stay a valid JSON array or the console reds the queue:',
|
|
154
|
+
` {"run_id":"<run-id>","kind":"report","initiative_id":"${id}","question":"${label} — ready","deliverable":"<run-id>"}`,
|
|
155
|
+
' Done when: the deliverable file exists and needs.json is a valid array containing the new object.',
|
|
137
156
|
'',
|
|
138
|
-
'
|
|
139
|
-
|
|
157
|
+
'Output contract — produce EXACTLY these sections, the same shape every run:',
|
|
158
|
+
...out.map((o) => ` - ${o}`),
|
|
140
159
|
].join('\n');
|
|
141
160
|
}
|
|
142
161
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ── tmux CONTROL MODE (`tmux -CC attach-session`) — the parser + command encoder. ──────────────
|
|
3
|
+
//
|
|
4
|
+
// Why this exists. A normal tmux client PAINTS a screen: it emits `smcup` on attach, which puts
|
|
5
|
+
// xterm.js into the alternate screen buffer, where xterm.js keeps NO scrollback. The workspace
|
|
6
|
+
// terminal was therefore unscrollable — `scrollback: 5000` (public/app.js) never applied, and the
|
|
7
|
+
// pane never left the alt buffer for the life of the session. Measured alternatives (see
|
|
8
|
+
// planning/initiatives/workspace-terminal-scrollback.md) both failed: stripping smcup/rmcup from
|
|
9
|
+
// the client's terminfo reaches the normal buffer but scrolls 0px, because tmux repaints by cursor
|
|
10
|
+
// addressing rather than scrolling lines off the top; and `mouse on` scrolls only via tmux
|
|
11
|
+
// copy-mode, at the cost of swallowing keystrokes typed while scrolled back.
|
|
12
|
+
//
|
|
13
|
+
// In control mode the client never paints. tmux instead reports the pane's RAW byte stream as
|
|
14
|
+
// `%output` notifications, so the bytes reaching xterm.js are the same sequential stream the
|
|
15
|
+
// no-tmux fallback path already delivers — and xterm.js accumulates its own scrollback normally.
|
|
16
|
+
//
|
|
17
|
+
// The protocol, as it actually arrives on the wire:
|
|
18
|
+
// \x1bP1000p DCS introducer, once, before the first line
|
|
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)
|
|
21
|
+
// %exit [reason] the control client is going away
|
|
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).
|
|
27
|
+
|
|
28
|
+
const { StringDecoder } = require('string_decoder');
|
|
29
|
+
|
|
30
|
+
// 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);
|
|
34
|
+
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; }
|
|
40
|
+
}
|
|
41
|
+
out[n++] = c & 0xff;
|
|
42
|
+
}
|
|
43
|
+
return out.subarray(0, n);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Streaming parser for a control-mode client's stdout.
|
|
48
|
+
* @param {object} h
|
|
49
|
+
* @param {(paneId: string, text: string) => void} h.onOutput decoded pane bytes
|
|
50
|
+
* @param {(reason: string) => void} [h.onExit] %exit seen
|
|
51
|
+
* @param {(lines: string[], isError: boolean) => void} [h.onBlock] a %begin…%end command reply
|
|
52
|
+
* @returns {{feed: (chunk: string) => void}}
|
|
53
|
+
*/
|
|
54
|
+
function createParser({ onOutput, onExit, onBlock } = {}) {
|
|
55
|
+
let pending = ''; // partial line carried between chunks
|
|
56
|
+
let inBlock = false;
|
|
57
|
+
let block = [];
|
|
58
|
+
const decoders = new Map(); // paneId → StringDecoder, for split multi-byte chars
|
|
59
|
+
|
|
60
|
+
function line(raw) {
|
|
61
|
+
// 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\\$/, '');
|
|
63
|
+
if (inBlock) {
|
|
64
|
+
if (l.startsWith('%end') || l.startsWith('%error')) {
|
|
65
|
+
inBlock = false;
|
|
66
|
+
if (onBlock) onBlock(block, l.startsWith('%error'));
|
|
67
|
+
block = [];
|
|
68
|
+
} else block.push(l);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
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];
|
|
77
|
+
let dec = decoders.get(pane);
|
|
78
|
+
if (!dec) { dec = new StringDecoder('utf8'); decoders.set(pane, dec); }
|
|
79
|
+
const text = dec.write(unescapeToBytes(m[2]));
|
|
80
|
+
if (text) onOutput(pane, text);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (l.startsWith('%exit')) { if (onExit) onExit(l.slice(5).trim()); return; }
|
|
84
|
+
// every other %notification (session-changed, window-add, layout-change, …) is not needed here
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
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);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Input goes back through the control client's OWN stdin as commands — never a spawned
|
|
102
|
+
// `tmux send-keys` per keystroke, which would fork a process on every character typed.
|
|
103
|
+
// `-H` takes raw byte values, so arbitrary input (escape sequences, paste, UTF-8) passes through
|
|
104
|
+
// unaltered. Long input is chunked: a tmux command line is not unbounded.
|
|
105
|
+
const SEND_CHUNK = 180; // bytes per send-keys command
|
|
106
|
+
|
|
107
|
+
function sendKeysCommands(paneId, data) {
|
|
108
|
+
const buf = Buffer.from(data, 'utf8');
|
|
109
|
+
const cmds = [];
|
|
110
|
+
for (let i = 0; i < buf.length; i += SEND_CHUNK) {
|
|
111
|
+
const hex = [];
|
|
112
|
+
for (const b of buf.subarray(i, i + SEND_CHUNK)) hex.push(b.toString(16).padStart(2, '0'));
|
|
113
|
+
cmds.push(`send-keys -t ${paneId} -H ${hex.join(' ')}\n`);
|
|
114
|
+
}
|
|
115
|
+
return cmds;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// In control mode the client has no window size of its own to resize — the pane follows the
|
|
119
|
+
// client's declared size, which is set with refresh-client -C.
|
|
120
|
+
function resizeCommand(cols, rows) {
|
|
121
|
+
return `refresh-client -C ${Math.max(1, cols | 0)}x${Math.max(1, rows | 0)}\n`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createParser, unescapeToBytes, sendKeysCommands, resizeCommand, SEND_CHUNK };
|