@xsft/panels 0.2.0 → 0.3.1

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
@@ -27,11 +27,11 @@ npx @xsft/panels run xsft/marketing-site/v6 . at http://localhost:4321 --sources
27
27
 
28
28
  Run the command in the folder of the product. Each judge starts in that folder, so `.`, a branch, and relative paths resolve there. The CLI does not test, fetch, or change the target. Four judges run at a time. A judge gets 20 minutes.
29
29
 
30
- ## tmux mode
30
+ ## Terminal mode
31
31
 
32
- On a terminal with `tmux` on PATH, `panels run` opens a tmux session: window 0 shows the scores, and window N runs judge N in the interactive harness. Each judge writes `judges/N/answer.json` and stays open; window 0 writes the report when each judge has an answer or an error, and the command exits from `report.json` when you leave the session. Without a terminal or tmux, the judges run in the background (`claude -p`, `codex exec`).
32
+ On a terminal, `panels run` shows the score table, and each judge runs in its interactive harness in a pseudo-terminal inside the command. Each judge writes `judges/N/answer.json` and stays open. The table writes the report when each judge has an answer or an error, and the command exits from `report.json` when you quit. Without a terminal, the judges run in the background (`claude -p`, `codex exec`).
33
33
 
34
- Keys in window 0: `1` to `9` open the window of that judge (tmux `prefix 0` comes back), `q` ends the session and asks `y` while a judge still runs.
34
+ Keys in the table: `1` to `9` open the screen of that judge, and `q` quits and asks `y` while a judge still runs. In a judge, each key goes to the judge, and `ctrl-]` returns to the table.
35
35
 
36
36
  ## Run folder
37
37
 
@@ -39,8 +39,7 @@ Each run writes to `.panels/<owner>-<name>/<UTC time>/`:
39
39
 
40
40
  - `panel.json`: the panel as the api gave it.
41
41
  - `report.md` and `report.json`: the scores, the evidence, the fixes, and the errors. `schema/report.json` is the schema of `report.json`.
42
- - `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In tmux mode, `start.sh` and `answer.json` take the place of the raw answer.
43
- - `run.json`, in tmux mode: the session name, the harness, the folder, and the target, for window 0.
42
+ - `judges/<k>/`: the prompt, the output schema, and the raw answer of judge k. In terminal mode, `answer.json` takes the place of the raw answer.
44
43
 
45
44
  The last line on stdout is the path of `report.json`.
46
45
 
package/bin/panels.js CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync, spawnSync } from 'node:child_process';
3
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
3
  import { delimiter, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
4
  import { parse, SYNOPSIS } from '../lib/args.js';
7
- import { runJudge, writeJudge } from '../lib/judge.js';
5
+ import { runJudge } from '../lib/judge.js';
8
6
  import { fetchPanel } from '../lib/panel.js';
9
7
  import { buildReport, renderMarkdown } from '../lib/report.js';
8
+ import { runTable } from '../lib/table.js';
10
9
 
11
10
  // More parallel sessions on one account get 429 answers from the model api.
12
11
  const PARALLEL_JUDGES = 4;
@@ -28,17 +27,9 @@ async function pool(items, size, fn) {
28
27
 
29
28
  const EXIT = { pass: 0, fail: 1, error: 2 };
30
29
 
31
- /** Starts the scores window in a new tmux session, attaches to it, and exits from report.json when the attach returns. */
32
- function runInTmux({ panel, runDir, time, harness, cwd, args }) {
33
- const session = `panels-${time.slice(11, 16)}`;
34
- writeFileSync(join(runDir, 'run.json'), JSON.stringify({ session, harness, cwd, target: args.target }, null, 2));
35
- panel.criteria.forEach((c, i) => writeJudge({ dir: join(runDir, 'judges', String(i + 1)), criteria: [{ rank: i + 1, ...c }], harness, cwd, target: args.target, brief: args.brief, sources: args.sources }));
36
- execFileSync('tmux', ['new-session', '-d', '-s', session, '-n', 'scores', process.execPath, fileURLToPath(new URL('../lib/table.js', import.meta.url)), runDir]);
37
- if (process.env.TMUX) {
38
- execFileSync('tmux', ['switch-client', '-t', session]);
39
- return 0;
40
- }
41
- spawnSync('tmux', ['attach-session', '-t', session], { stdio: 'inherit' });
30
+ /** Runs the judges in the score table, and exits from report.json when the engineer quits. */
31
+ async function runOnTerminal({ panel, runDir, harness, cwd, args }) {
32
+ await runTable({ panel, runDir, harness, cwd, target: args.target, brief: args.brief, sources: args.sources });
42
33
  const reportPath = join(runDir, 'report.json');
43
34
  if (!existsSync(reportPath)) return 2;
44
35
  console.log(reportPath);
@@ -76,7 +67,7 @@ async function main() {
76
67
  const runDir = join(cwd, '.panels', `${panel.owner}-${panel.name}`, time);
77
68
  mkdirSync(runDir, { recursive: true });
78
69
  writeFileSync(join(runDir, 'panel.json'), JSON.stringify(panel, null, 2));
79
- if (process.stdout.isTTY && process.stdin.isTTY && onPath('tmux')) return runInTmux({ panel, runDir, time, harness, cwd, args });
70
+ if (process.stdout.isTTY && process.stdin.isTTY) return runOnTerminal({ panel, runDir, harness, cwd, args });
80
71
  const outcomes = await pool(judges, PARALLEL_JUDGES, (indexes, j) => runJudge({
81
72
  dir: join(runDir, 'judges', String(j + 1)),
82
73
  criteria: indexes.map((i) => ({ rank: i + 1, ...panel.criteria[i] })),
package/lib/judge.js CHANGED
@@ -1,11 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
4
 
6
5
  const { judgeResult } = JSON.parse(readFileSync(new URL('../schema/report.json', import.meta.url), 'utf8')).$defs;
7
- const SYSTEM_PROMPT_FILE = fileURLToPath(new URL('../prompts/judge-system-prompt.txt', import.meta.url));
8
- const SYSTEM_PROMPT = readFileSync(SYSTEM_PROMPT_FILE, 'utf8');
6
+ const SYSTEM_PROMPT = readFileSync(new URL('../prompts/judge-system-prompt.txt', import.meta.url), 'utf8');
9
7
  export const MINUTES_PER_CRITERION = 20;
10
8
  const TOOLS = 'Read,Glob,Grep,Bash,WebFetch,WebSearch';
11
9
  // With --permission-mode dontAsk, Claude refuses every tool call that this list does not allow, so Bash runs git reads only.
@@ -146,31 +144,28 @@ export async function runJudge({ dir, criteria, harness, cwd, target, brief, sou
146
144
  return errors.length ? { error: `invalid answer after one re-ask: ${errors.join(' ')}` } : { results: reply.answer.results };
147
145
  }
148
146
 
149
- /** Quotes a string for sh. */
150
- export const sq = (s) => `'${s.replaceAll("'", "'\\''")}'`;
151
-
152
147
  const TOML_ESCAPES = { '"': '\\"', '\\': '\\\\', '\n': '\\n' };
153
148
 
154
149
  /** A TOML basic string, for the value of a codex -c flag. */
155
150
  export const tomlString = (s) => `"${s.replace(/["\\\x00-\x1f\x7f]/g, (c) => TOML_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)}"`;
156
151
 
157
- /** The shell command that starts one judge of the tmux mode in its interactive harness. */
158
- export function startScript({ dir, harness, cwd, brief, sources }) {
159
- const prompt = `"$(cat ${sq(join(dir, 'prompt.md'))})"`;
152
+ /**
153
+ * Writes prompt.md and schema.json of one judge of the terminal mode, and returns the command and the
154
+ * arguments that start the judge in its interactive harness.
155
+ */
156
+ export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
157
+ mkdirSync(dir, { recursive: true });
158
+ const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON, which matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Write that file yourself with a tool, and do not start sub-agents: the JSON in your reply does not count. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
159
+ const prompt = `${userMessage(criteria, { target, brief, sources })}\n${closing}`;
160
+ writeFileSync(join(dir, 'prompt.md'), prompt);
161
+ writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
160
162
  if (harness === 'codex') {
161
- return `codex -C ${sq(dir)} -s workspace-write --disable multi_agent --disable apps -c ${sq(`developer_instructions=${tomlString(SYSTEM_PROMPT)}`)} ${prompt}\n`;
163
+ // Codex ranks the developer instructions above the prompt, and the system prompt says to return the JSON and to
164
+ // write no files. Without the closing in the developer instructions, the judge prints the JSON and writes no answer.json.
165
+ return ['codex', '-C', dir, '-s', 'workspace-write', '--disable', 'multi_agent', '--disable', 'apps', '-c', `developer_instructions=${tomlString(`${SYSTEM_PROMPT}\n${closing}`)}`, prompt];
162
166
  }
163
- const add = addDirs(cwd, brief, sources).map((d) => ` --add-dir ${sq(d)}`).join('');
164
167
  // Claude matches file paths only in Edit rules, which cover Write too; // starts an absolute path.
165
168
  const allowed = `${ALLOWED_TOOLS},Edit(/${join(dir, 'answer.json')})`;
166
- return `claude --safe-mode --system-prompt "$(cat ${sq(SYSTEM_PROMPT_FILE)})" --tools ${TOOLS},Write${add} --allowedTools ${sq(allowed)} --permission-mode dontAsk ${prompt}\n`;
167
- }
168
-
169
- /** Writes prompt.md, schema.json, and start.sh of one judge of the tmux mode. */
170
- export function writeJudge({ dir, criteria, harness, cwd, target, brief, sources }) {
171
- mkdirSync(dir, { recursive: true });
172
- const closing = `The engineer ran the command in ${cwd}. As your last step, write your answer JSON, which matches the schema in ${join(dir, 'schema.json')}, to ${join(dir, 'answer.json')}. Then stop. These two files are the only exceptions to the rules on writing files and on the .panels folder.\n`;
173
- writeFileSync(join(dir, 'prompt.md'), `${userMessage(criteria, { target, brief, sources })}\n${closing}`);
174
- writeFileSync(join(dir, 'schema.json'), JSON.stringify(judgeSchema(criteria), null, 2));
175
- writeFileSync(join(dir, 'start.sh'), startScript({ dir, harness, cwd, brief, sources }));
169
+ const add = addDirs(cwd, brief, sources).flatMap((d) => ['--add-dir', d]);
170
+ return ['claude', '--safe-mode', '--system-prompt', SYSTEM_PROMPT, '--tools', `${TOOLS},Write`, ...add, '--allowedTools', allowed, '--permission-mode', 'dontAsk', prompt];
176
171
  }
package/lib/table.js CHANGED
@@ -1,9 +1,11 @@
1
- // Window 0 of the tmux mode: `node table.js <run folder>`. It starts the judge windows, shows the scores, and writes the report.
2
- import { execFileSync, spawnSync } from 'node:child_process';
1
+ // The terminal mode of `panels run`: the score table, and a view of each judge. Each judge runs its interactive
2
+ // harness in a pseudo-terminal, and a headless terminal keeps its screen for the switch to its view.
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { check, MINUTES_PER_CRITERION, parseAnswer } from './judge.js';
5
+ import pty from '@homebridge/node-pty-prebuilt-multiarch';
6
+ import { SerializeAddon } from '@xterm/addon-serialize';
7
+ import xterm from '@xterm/headless';
8
+ import { check, MINUTES_PER_CRITERION, parseAnswer, writeJudge } from './judge.js';
7
9
  import { buildReport, renderMarkdown } from './report.js';
8
10
 
9
11
  // More parallel sessions on one account get 429 answers from the model api.
@@ -12,9 +14,13 @@ const ORANGE = '\x1b[38;2;255;106;61m';
12
14
  const GREY = '\x1b[90m';
13
15
  const BOLD = '\x1b[1m';
14
16
  const RESET = '\x1b[0m';
17
+ // ctrl-] returns from a judge to the table. A terminal in the kitty keyboard mode sends it as CSI 93;5u.
18
+ const BACK = ['\x1d', '\x1b[93;5u'];
19
+ // Turns off each mode that the screen of a judge can turn on, so the table gets the keys and the screen as before.
20
+ const TABLE_MODES = '\x1b[?1049h\x1b[r\x1b[0m\x1b[4l\x1b[?6l\x1b[?7h\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[<u\x1b[?25l\x1b[2J';
15
21
 
16
22
  /**
17
- * The lines of window 0. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
23
+ * The lines of the table. rows hold { rank, name, state, result }, and state is queued, running, done, or error.
18
24
  * final is null until the report exists, then { status, path }. confirm is true while q waits for y.
19
25
  */
20
26
  export function render({ title, rows, final, confirm, width }) {
@@ -37,17 +43,31 @@ export function render({ title, rows, final, confirm, width }) {
37
43
  return lines;
38
44
  }
39
45
 
40
- function main(runDir) {
41
- const read = (file) => JSON.parse(readFileSync(join(runDir, file), 'utf8'));
42
- const panel = read('panel.json');
43
- const { session, harness, cwd, target } = read('run.json');
46
+ /**
47
+ * The next { view, confirm } after one key, plus send: true when the key goes to the judge, or quit: true.
48
+ * view is 0 for the table, or N for judge N. open holds the ranks of the judges that have started.
49
+ */
50
+ export function press({ view, confirm }, key, { open, running }) {
51
+ if (view) return BACK.includes(key) ? { view: 0, confirm: false } : { view, confirm, send: true };
52
+ if (confirm) return { view: 0, confirm: false, quit: key === 'y' };
53
+ if (key === 'q') return running ? { view: 0, confirm: true } : { view: 0, confirm: false, quit: true };
54
+ if (/^[1-9]$/.test(key) && open.includes(Number(key))) return { view: Number(key), confirm: false };
55
+ return { view: 0, confirm: false };
56
+ }
57
+
58
+ /** Runs the judges, shows the table, and writes the report. Resolves when the engineer quits. */
59
+ export function runTable({ panel, runDir, harness, cwd, target, brief, sources }) {
60
+ const out = process.stdout;
44
61
  const criteria = panel.criteria.map((c, i) => ({ rank: i + 1, ...c }));
45
- const started = [];
62
+ const judges = [];
46
63
  let rows = [];
47
64
  let final = null;
48
- let confirm = false;
65
+ let ui = { view: 0, confirm: false };
66
+ let footerTimer;
49
67
 
50
68
  const judgeDir = (rank) => join(runDir, 'judges', String(rank));
69
+ // A judge gets one line less than the terminal. The last line holds the footer.
70
+ const size = () => ({ cols: out.columns, rows: out.rows - 1 });
51
71
 
52
72
  function state(c, i) {
53
73
  const file = join(judgeDir(c.rank), 'answer.json');
@@ -56,14 +76,60 @@ function main(runDir) {
56
76
  const errors = check(answer, [c]);
57
77
  return errors.length ? { state: 'error', error: `invalid answer: ${errors.join(' ')}` } : { state: 'done', result: answer.results[0] };
58
78
  }
59
- if (started[i] === undefined) return { state: 'queued' };
60
- if (Date.now() - started[i] > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
79
+ if (judges[i] === undefined) return { state: 'queued' };
80
+ if (Date.now() - judges[i].started > MINUTES_PER_CRITERION * 60_000) return { state: 'error', error: `no answer.json after ${MINUTES_PER_CRITERION} minutes` };
61
81
  return { state: 'running' };
62
82
  }
63
83
 
64
84
  function start(rank) {
65
- started[rank - 1] = Date.now();
66
- execFileSync('tmux', ['new-window', '-d', '-t', `${session}:${rank}`, '-n', `judge-${rank}`, '-c', cwd, 'sh', join(judgeDir(rank), 'start.sh')]);
85
+ const [command, ...argv] = writeJudge({ dir: judgeDir(rank), criteria: [criteria[rank - 1]], harness, cwd, target, brief, sources });
86
+ const { cols, rows: lines } = size();
87
+ const term = new xterm.Terminal({ cols, rows: lines, allowProposedApi: true });
88
+ const serializer = new SerializeAddon();
89
+ term.loadAddon(serializer);
90
+ const proc = pty.spawn(command, argv, { name: 'xterm-256color', cols, rows: lines, cwd, env: process.env });
91
+ const j = { started: Date.now(), term, serializer, proc, modes: { 25: 'h', 1006: 'l' }, exited: false };
92
+ // The write callback keeps the order of the live output and of the screen that showJudge writes.
93
+ proc.onData((data) => term.write(data, () => {
94
+ if (ui.view !== rank) return;
95
+ out.write(data);
96
+ footer();
97
+ }));
98
+ proc.onExit(() => { j.exited = true; });
99
+ // The headless terminal answers the queries of the harness, such as the cursor position.
100
+ term.onData((data) => proc.write(data));
101
+ // The serializer does not keep the cursor visibility (25) or the mouse encoding (1006), so each judge keeps them here.
102
+ for (const final of ['h', 'l']) {
103
+ term.parser.registerCsiHandler({ prefix: '?', final }, (params) => {
104
+ for (const p of params) if (p in j.modes) j.modes[p] = final;
105
+ return false;
106
+ });
107
+ }
108
+ judges[rank - 1] = j;
109
+ }
110
+
111
+ function drawFooter() {
112
+ if (!ui.view) return;
113
+ const { cursorX, cursorY } = judges[ui.view - 1].term.buffer.active;
114
+ out.write(`\x1b[${out.rows};1H${RESET}${GREY} judge ${ui.view} ctrl-] scores${RESET}\x1b[K\x1b[${cursorY + 1};${cursorX + 1}H`);
115
+ }
116
+
117
+ // Waits for a pause in the output, so the footer does not land inside an escape sequence that a chunk split.
118
+ function footer() {
119
+ clearTimeout(footerTimer);
120
+ footerTimer = setTimeout(drawFooter, 50);
121
+ }
122
+
123
+ function showJudge(rank) {
124
+ const j = judges[rank - 1];
125
+ j.term.write('', () => {
126
+ if (ui.view !== rank) return;
127
+ // The table already holds the alternate screen, so a judge on its alternate screen gets a clear after
128
+ // the normal screen, which the serializer writes first. The scroll region keeps the judge off the footer line.
129
+ const screen = j.serializer.serialize({ scrollback: 0 }).replace('\x1b[?1049h\x1b[H', '$&\x1b[2J');
130
+ out.write(`${RESET}\x1b[H\x1b[2J\x1b[1;${out.rows - 1}r${screen}${Object.entries(j.modes).map(([mode, final]) => `\x1b[?${mode}${final}`).join('')}`);
131
+ drawFooter();
132
+ });
67
133
  }
68
134
 
69
135
  function finish() {
@@ -76,8 +142,8 @@ function main(runDir) {
76
142
  }
77
143
 
78
144
  function draw() {
79
- const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm, width: Math.min(process.stdout.columns, 100) });
80
- process.stdout.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
145
+ const lines = render({ title: `${panel.owner}/${panel.name} v${panel.version}`, rows, final, confirm: ui.confirm, width: Math.min(out.columns, 100) });
146
+ out.write(`\x1b[H${lines.map((l) => `${l}\x1b[K`).join('\n')}\x1b[J`);
81
147
  }
82
148
 
83
149
  function tick() {
@@ -90,30 +156,45 @@ function main(runDir) {
90
156
  }
91
157
  if (rows.every((r) => r.state === 'done' || r.state === 'error')) finish();
92
158
  }
93
- draw();
159
+ if (!ui.view) draw();
94
160
  }
95
161
 
96
- const quit = () => execFileSync('tmux', ['kill-session', '-t', session]);
97
-
98
- process.stdout.write('\x1b[?1049h\x1b[?25l');
99
- process.stdin.setRawMode(true);
100
- process.stdin.on('data', (data) => {
101
- const key = data.toString();
102
- if (confirm) {
103
- confirm = false;
104
- if (key === 'y') quit();
105
- } else if (key === 'q') {
106
- if (rows.some((r) => r.state === 'running')) confirm = true;
107
- else quit();
108
- } else if (/^[1-9]$/.test(key) && Number(key) <= rows.length) {
109
- // A queued judge has no window yet, so tmux fails, and its message goes to the ignored pipe.
110
- spawnSync('tmux', ['select-window', '-t', `${session}:${key}`]);
111
- }
112
- draw();
162
+ return new Promise((done) => {
163
+ const timer = setInterval(tick, 1000);
164
+ out.write('\x1b[?1049h\x1b[?25l');
165
+ process.stdin.setRawMode(true);
166
+ process.stdin.on('data', (data) => {
167
+ const key = data.toString();
168
+ const from = ui.view;
169
+ const next = press(ui, key, { open: judges.flatMap((j, i) => (j ? [i + 1] : [])), running: rows.some((r) => r.state === 'running') });
170
+ ui = { view: next.view, confirm: next.confirm };
171
+ if (next.send) {
172
+ judges[from - 1].proc.write(key);
173
+ } else if (next.quit) {
174
+ clearInterval(timer);
175
+ // The harness on PATH can be a shell script that runs the real binary as a child and waits for it, so
176
+ // SIGHUP goes to the process group of the judge. The pty makes each judge the leader of its own group.
177
+ for (const j of judges) if (j && !j.exited) process.kill(-j.proc.pid, 'SIGHUP');
178
+ out.write(`${TABLE_MODES}\x1b[?25h\x1b[?1049l`);
179
+ process.stdin.setRawMode(false);
180
+ process.stdin.pause();
181
+ done();
182
+ } else if (ui.view) {
183
+ showJudge(ui.view);
184
+ } else {
185
+ if (from) out.write(TABLE_MODES);
186
+ draw();
187
+ }
188
+ });
189
+ out.on('resize', () => {
190
+ const { cols, rows: lines } = size();
191
+ for (const j of judges) {
192
+ if (j && !j.exited) j.proc.resize(cols, lines);
193
+ j?.term.resize(cols, lines);
194
+ }
195
+ if (ui.view) showJudge(ui.view);
196
+ else draw();
197
+ });
198
+ tick();
113
199
  });
114
- process.stdout.on('resize', draw);
115
- tick();
116
- setInterval(tick, 1000);
117
200
  }
118
-
119
- if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsft/panels",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Run a panel of AI judges from cross.software/panels against a target.",
6
6
  "type": "module",
@@ -8,5 +8,10 @@
8
8
  "files": ["bin", "lib", "prompts", "schema", "README.md"],
9
9
  "license": "UNLICENSED",
10
10
  "engines": { "node": ">=20" },
11
- "scripts": { "test": "node --test test/*.test.js" }
11
+ "scripts": { "test": "node --test test/*.test.js" },
12
+ "dependencies": {
13
+ "@homebridge/node-pty-prebuilt-multiarch": "0.14.1",
14
+ "@xterm/addon-serialize": "0.14.0",
15
+ "@xterm/headless": "6.0.0"
16
+ }
12
17
  }