viewable-one 0.0.0-stage → 0.12.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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Human CLI presentation only. Never use on the MCP JSON-RPC stream. */
|
|
2
|
+
const clean = value => String(value ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
3
|
+
export function colorEnabled({ env = process.env, isTTY = process.stdout.isTTY } = {}) {
|
|
4
|
+
if (Object.hasOwn(env, 'NO_COLOR') || env.FORCE_COLOR === '0' || env.TERM === 'dumb') return false;
|
|
5
|
+
return Boolean(isTTY || (env.FORCE_COLOR && env.FORCE_COLOR !== '0'));
|
|
6
|
+
}
|
|
7
|
+
const paint = (text, code, color) => color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
8
|
+
export function heading(title, { color = colorEnabled() } = {}) {
|
|
9
|
+
return paint(clean(title), '1;36', color);
|
|
10
|
+
}
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// THE PAINT BOX (brief 31, Move 7, 2026-09-12). The ruling: "make sure you
|
|
13
|
+
// format the terminal stuff properly, use colors and make it look good to
|
|
14
|
+
// work in terminal for user."
|
|
15
|
+
//
|
|
16
|
+
// NO DEPENDENCY. `chalk`/`kleur` buy nothing here: four colours and bold is
|
|
17
|
+
// `paint` above, and every helper below goes through `colorEnabled` and
|
|
18
|
+
// `clean`, so NO_COLOR / TERM=dumb / a pipe all get plain text and the
|
|
19
|
+
// first-line rule of this file - never on the MCP JSON-RPC stream - holds
|
|
20
|
+
// for the new helpers exactly as it does for `heading`.
|
|
21
|
+
//
|
|
22
|
+
// WIDTH IS `wrap`'s JOB, not each caller's. `step`, `hint` and `bullets` all
|
|
23
|
+
// go through it, so an 80-column terminal is the floor everything is built
|
|
24
|
+
// against rather than something remembered per line.
|
|
25
|
+
|
|
26
|
+
/** Secondary text: a URL to paste, a "press ^C to cancel". */
|
|
27
|
+
export function dim(text, { color = colorEnabled() } = {}) {
|
|
28
|
+
return paint(clean(text), '2', color);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The one line that says which thing is asking. */
|
|
32
|
+
export function bold(text, { color = colorEnabled() } = {}) {
|
|
33
|
+
return paint(clean(text), '1', color);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A result that landed. */
|
|
37
|
+
export function ok(text, { color = colorEnabled() } = {}) {
|
|
38
|
+
return paint(clean(text), '32', color);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A result that was skipped, or landed with something left to do. */
|
|
42
|
+
export function warn(text, { color = colorEnabled() } = {}) {
|
|
43
|
+
return paint(clean(text), '33', color);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A result that did not land. `die` paints its FIRST line with this and
|
|
47
|
+
* leaves the rest plain, so a pasted error is still readable in an issue. */
|
|
48
|
+
export function fail(text, { color = colorEnabled() } = {}) {
|
|
49
|
+
return paint(clean(text), '31', color);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "Step 2 of 4 · Connect this project" - the walk `viewable init` became.
|
|
53
|
+
* Same bold cyan as `heading`, because it IS the heading of that step. */
|
|
54
|
+
export function step(n, total, text, { color = colorEnabled(), width = process.stdout.columns || 88 } = {}) {
|
|
55
|
+
const title = `Step ${Number(n)} of ${Number(total)} \u00b7 ${clean(text)}`;
|
|
56
|
+
const lines = wrap(title, Math.max(32, Math.min(100, width)), '');
|
|
57
|
+
return lines.map((line) => paint(line, '1;36', color)).join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Dim, indented, wrapped: the URL under a browser hand-off, the next step
|
|
61
|
+
* under a row. Returns one string so a caller can `say(hint(...))`. */
|
|
62
|
+
export function hint(text, { color = colorEnabled(), width = process.stdout.columns || 88, indent = ' ' } = {}) {
|
|
63
|
+
const w = Math.max(32, Math.min(100, width));
|
|
64
|
+
return wrap(text, w, indent).map((line) => paint(line, '2', color)).join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A short list under a heading. Returns LINES, like `checkSections`, because
|
|
68
|
+
* a list is many lines and joining them is the caller's choice. */
|
|
69
|
+
export function bullets(items, { color = colorEnabled(), width = process.stdout.columns || 88 } = {}) {
|
|
70
|
+
const w = Math.max(32, Math.min(100, width));
|
|
71
|
+
const lines = [];
|
|
72
|
+
for (const item of items ?? []) {
|
|
73
|
+
const wrapped = wrap(item, w, ' ');
|
|
74
|
+
if (!wrapped.length) continue;
|
|
75
|
+
lines.push(` ${paint('\u2022', '2', color)} ${wrapped[0].trimStart()}`);
|
|
76
|
+
lines.push(...wrapped.slice(1));
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// THE DOCTOR-SHAPED ROW (brief 31 review, 2026-09-12). `{key, label, ok,
|
|
83
|
+
// state, detail, action?}` plus the `warn` variant (`ok:true, warn:true`,
|
|
84
|
+
// ruling 8B: a warning is green) is the one shape lib/doctor.mjs,
|
|
85
|
+
// lib/uninstall.mjs and lib/uninstall-project.mjs all print - three
|
|
86
|
+
// byte-identical copies of these two constructors were found on review.
|
|
87
|
+
// Homed here rather than in doctor.mjs because this is the module that
|
|
88
|
+
// already knows how to PAINT a row (`renderRow` below), and every caller of
|
|
89
|
+
// `row`/`warnRow` needs that too.
|
|
90
|
+
export const row = (key, label, ok, state, detail = '', action) =>
|
|
91
|
+
action ? { key, label, ok, state, detail, action } : { key, label, ok, state, detail };
|
|
92
|
+
|
|
93
|
+
/** A row that is neither ok nor FAIL: something is missing, the install
|
|
94
|
+
* still works, and what it costs is said out loud. `ok:true` on purpose -
|
|
95
|
+
* ruling 8B counts a warning as green. */
|
|
96
|
+
export const warnRow = (key, label, state, detail = '') => ({ key, label, ok: true, warn: true, state, detail });
|
|
97
|
+
|
|
98
|
+
/** One row, painted: the STATUS/label line plus its wrapped detail. Shared by
|
|
99
|
+
* `checkSections` (which groups rows under headings first) and `printedRows`
|
|
100
|
+
* in lib/uninstall.mjs (which has no groups) - both used to keep their own
|
|
101
|
+
* copy of exactly this rendering, found identical on review 2026-09-12. The
|
|
102
|
+
* `beat` label override is `checkSections`' own special case; a row from
|
|
103
|
+
* anywhere else never carries that key, so it is harmless to apply here for
|
|
104
|
+
* everyone. */
|
|
105
|
+
export function renderRow(row, { color = colorEnabled(), width = process.stdout.columns || 88 } = {}) {
|
|
106
|
+
const w = Math.max(32, Math.min(100, width));
|
|
107
|
+
const status = row.warn ? 'WARN' : row.ok ? 'OK' : 'FAIL';
|
|
108
|
+
const code = row.warn ? '33' : row.ok ? '32' : '31';
|
|
109
|
+
const label = row.key === 'beat' ? 'Runner heartbeat' : clean(row.label);
|
|
110
|
+
const state = row.state ? ` — ${clean(row.state)}` : '';
|
|
111
|
+
const wrapped = wrap(`${label}${state}`, w - 7, '');
|
|
112
|
+
const lines = [` ${paint(status.padEnd(4), code, color)} ${wrapped[0] ?? label}`];
|
|
113
|
+
lines.push(...wrapped.slice(1).map(line => ` ${line}`));
|
|
114
|
+
if (row.detail) lines.push(...wrap(row.detail, w, ' '));
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function wrap(text, width = 80, indent = ' ') {
|
|
119
|
+
const available = Math.max(12, width - indent.length);
|
|
120
|
+
const words = clean(text).trim().split(/\s+/);
|
|
121
|
+
const lines = []; let line = '';
|
|
122
|
+
for (let word of words) {
|
|
123
|
+
if (line && line.length + word.length + 1 > available) { lines.push(indent + line); line = ''; }
|
|
124
|
+
while (word.length > available) { lines.push(indent + word.slice(0, available)); word = word.slice(available); }
|
|
125
|
+
if (word) line += (line ? ' ' : '') + word;
|
|
126
|
+
}
|
|
127
|
+
if (line) lines.push(indent + line);
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
export function checkSections(rows, { color = colorEnabled(), width = process.stdout.columns || 88 } = {}) {
|
|
131
|
+
width = Math.max(32, Math.min(100, width));
|
|
132
|
+
const groups = [
|
|
133
|
+
['Project', ['registry', 'mirror', 'mcp', 'opencode', 'codex']],
|
|
134
|
+
['Runner', ['plist', 'daemonVersion', 'daemon', 'beat', 'boardAuth']],
|
|
135
|
+
['Coding agents', ['agents', 'login']],
|
|
136
|
+
['System', ['platform', 'git', 'version']],
|
|
137
|
+
];
|
|
138
|
+
const known = new Set(groups.flatMap(([, keys]) => keys));
|
|
139
|
+
groups.push(['Other checks', rows.filter(r => !known.has(r.key)).map(r => r.key)]);
|
|
140
|
+
const lines = [];
|
|
141
|
+
for (const [title, keys] of groups) {
|
|
142
|
+
const selected = rows.filter(row => keys.includes(row.key));
|
|
143
|
+
if (!selected.length) continue;
|
|
144
|
+
lines.push('', heading(title, { color }));
|
|
145
|
+
for (const row of selected) lines.push(...renderRow(row, { color, width }));
|
|
146
|
+
}
|
|
147
|
+
const actions = new Set(rows.filter(row => !row.ok).map(row => {
|
|
148
|
+
// A row that came with its own next step knows better than the default
|
|
149
|
+
// below: "run glytch init" is a loop when init is what was refused.
|
|
150
|
+
if (row.action) return clean(row.action);
|
|
151
|
+
if (row.key === 'mirror') return 'Open your board and link the project folder shown above.';
|
|
152
|
+
if (row.key === 'beat') return 'Wait a few seconds, then run viewable doctor from this project. If the heartbeat stays missing, run viewable init.';
|
|
153
|
+
if (row.key === 'login') return 'Run claude once and sign in.';
|
|
154
|
+
if (row.key === 'agents') return 'Install a selected coding agent, or run viewable init to change your selection.';
|
|
155
|
+
return 'Run viewable init from this project folder to refresh its setup, then viewable doctor.';
|
|
156
|
+
}));
|
|
157
|
+
if (actions.size) {
|
|
158
|
+
lines.push('', heading('Next steps', { color }));
|
|
159
|
+
for (const action of actions) lines.push(...wrap(action, width, ' '));
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
export function summary(rows, { color = colorEnabled() } = {}) {
|
|
164
|
+
const failures = rows.filter(row => !row.ok).length;
|
|
165
|
+
const warnings = rows.filter(row => row.warn).length;
|
|
166
|
+
const message = failures
|
|
167
|
+
? `${failures} check${failures === 1 ? ' needs' : 's need'} attention. Follow the instructions under FAIL.`
|
|
168
|
+
: warnings ? `No failed checks. ${warnings} warning${warnings === 1 ? '' : 's'} to review.` : 'All checks passed.';
|
|
169
|
+
return paint(message, failures ? '1;31' : warnings ? '1;33' : '1;32', color);
|
|
170
|
+
}
|
|
171
|
+
export function homeNotice(root, home, options) {
|
|
172
|
+
if (root !== home) return [];
|
|
173
|
+
return ['', heading('Check your project folder', options),
|
|
174
|
+
...wrap('You are in your home folder. Run project commands inside the folder linked to your board.', 88, ' '),
|
|
175
|
+
' Example: cd ~/Projects/your-project', ''];
|
|
176
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026.8.3",
|
|
3
|
+
"assets": {
|
|
4
|
+
"darwin-x64": {
|
|
5
|
+
"name": "cloudflared-darwin-amd64.tgz",
|
|
6
|
+
"sha256": "61e1316266a00fd70ce40da011d612badc805367fb65293dd1925f938f704c99"
|
|
7
|
+
},
|
|
8
|
+
"darwin-arm64": {
|
|
9
|
+
"name": "cloudflared-darwin-arm64.tgz",
|
|
10
|
+
"sha256": "40c9144d86df8937c5b43293a1f7d2d2107029aa74725023dd46b1b27154352f"
|
|
11
|
+
},
|
|
12
|
+
"linux-x64": {
|
|
13
|
+
"name": "cloudflared-linux-amd64",
|
|
14
|
+
"sha256": "f29324fe934d1e100617484c78deef803c4dc2cd351d645bbde42e96b4fccc5e"
|
|
15
|
+
},
|
|
16
|
+
"linux-arm64": {
|
|
17
|
+
"name": "cloudflared-linux-arm64",
|
|
18
|
+
"sha256": "4bcfd35521a7cbc545ebfd5d57334a71ee180e2a64874981f374c81472118391"
|
|
19
|
+
},
|
|
20
|
+
"win32-x64": {
|
|
21
|
+
"name": "cloudflared-windows-amd64.exe",
|
|
22
|
+
"sha256": "83e726ed18ea78c5ad5213c4c3a3a27051393950d2bc8ed4de69bec12d14eaae"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Pinned official binaries; no system package manager or administrator access. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile, writeFile, mkdir, rename, rm, chmod } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { gunzipSync } from 'node:zlib';
|
|
7
|
+
import release from './cloudflared-release.json' with { type: 'json' };
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
export function cloudflaredAsset(platform = process.platform, arch = process.arch) {
|
|
12
|
+
const asset = release.assets[`${platform}-${arch}`];
|
|
13
|
+
if (!asset) throw new Error(`Automatic preview setup is not available for ${platform}/${arch}. Set PM_CLOUDFLARED_BIN to a compatible cloudflared executable.`);
|
|
14
|
+
return { ...asset, version: release.version };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Read only the expected regular file from the verified archive; never extract paths.
|
|
18
|
+
export function unpackCloudflared(bytes, name) {
|
|
19
|
+
if (!name.endsWith('.tgz')) return bytes;
|
|
20
|
+
const tar = gunzipSync(bytes, { maxOutputLength: MAX_BYTES });
|
|
21
|
+
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
22
|
+
const header = tar.subarray(offset, offset + 512);
|
|
23
|
+
const file = header.subarray(0, 100).toString().replace(/\0.*$/s, '');
|
|
24
|
+
const size = parseInt(header.subarray(124, 136).toString().replace(/\0.*$/s, '').trim(), 8);
|
|
25
|
+
if (!file) break;
|
|
26
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) break;
|
|
27
|
+
if ((file === 'cloudflared' || file === './cloudflared') && [0, 48].includes(header[156])) {
|
|
28
|
+
return tar.subarray(offset + 512, offset + 512 + size);
|
|
29
|
+
}
|
|
30
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
31
|
+
}
|
|
32
|
+
throw new Error('The verified cloudflared archive contains no executable.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function download(url, fetchImpl, signal) {
|
|
36
|
+
const response = await fetchImpl(url, { signal });
|
|
37
|
+
if (!response.ok || !response.body) throw new Error(`Preview setup download failed (${response.status}). Retry when online.`);
|
|
38
|
+
const chunks = []; let size = 0;
|
|
39
|
+
for await (const chunk of response.body) {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > MAX_BYTES) throw new Error('Preview setup download exceeds the size limit.');
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function ensureCloudflared({
|
|
48
|
+
platform = process.platform, arch = process.arch,
|
|
49
|
+
cacheDir = join(homedir(), '.glytch', 'bin', 'cloudflared'),
|
|
50
|
+
fetchImpl = fetch, signal, asset = cloudflaredAsset(platform, arch),
|
|
51
|
+
} = {}) {
|
|
52
|
+
const combinedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000);
|
|
53
|
+
combinedSignal.throwIfAborted();
|
|
54
|
+
const dir = join(cacheDir, asset.version, `${platform}-${arch}`);
|
|
55
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
const archive = join(dir, asset.name + '.download');
|
|
57
|
+
let bytes;
|
|
58
|
+
try { bytes = await readFile(archive); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
59
|
+
if (!bytes || hash(bytes) !== asset.sha256) {
|
|
60
|
+
bytes = await download(`https://github.com/cloudflare/cloudflared/releases/download/${asset.version}/${asset.name}`, fetchImpl, combinedSignal);
|
|
61
|
+
if (hash(bytes) !== asset.sha256) throw new Error('Preview setup checksum verification failed. No downloaded program was run.');
|
|
62
|
+
await atomicWrite(archive, bytes);
|
|
63
|
+
}
|
|
64
|
+
combinedSignal.throwIfAborted();
|
|
65
|
+
const executable = unpackCloudflared(bytes, asset.name);
|
|
66
|
+
const bin = join(dir, platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
67
|
+
let current;
|
|
68
|
+
try { current = await readFile(bin); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
69
|
+
if (!current || hash(current) !== hash(executable)) await atomicWrite(bin, executable);
|
|
70
|
+
if (platform !== 'win32') await chmod(bin, 0o700);
|
|
71
|
+
combinedSignal.throwIfAborted();
|
|
72
|
+
return bin;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function atomicWrite(path, bytes) {
|
|
76
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: 0o700 });
|
|
79
|
+
await rename(temporary, path);
|
|
80
|
+
} finally { await rm(temporary, { force: true }); }
|
|
81
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Codex app-server JSON-RPC over newline-delimited stdio. No model turn is
|
|
2
|
+
// started by connecting or querying model/list. Protocol checked against
|
|
3
|
+
// codex-cli 0.153.4 and https://learn.chatgpt.com/docs/app-server (2026-09-08).
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { runnableCommand } from "./windows-command.mjs";
|
|
6
|
+
|
|
7
|
+
export function startCodex({ bin = "codex", args = ["app-server"], cwd, env = process.env, platform = process.platform } = {}) {
|
|
8
|
+
const command = runnableCommand(bin, args, { platform, env });
|
|
9
|
+
return spawn(command.file, command.args, { cwd, env, shell: command.shell, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function codexRpc(child, { onNotification = () => {}, onRequest = () => { throw new Error("This client cannot answer this request; use the board ask_user tool."); }, timeoutMs = 15_000 } = {}) {
|
|
13
|
+
let nextId = 0;
|
|
14
|
+
let buffer = "";
|
|
15
|
+
let closed = false;
|
|
16
|
+
const pending = new Map();
|
|
17
|
+
const received = new Set();
|
|
18
|
+
const send = (message) => {
|
|
19
|
+
if (closed || child.stdin.destroyed || !child.stdin.writable) throw new Error("Codex connection closed");
|
|
20
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
21
|
+
};
|
|
22
|
+
const close = (error = new Error("Codex connection closed")) => {
|
|
23
|
+
closed = true;
|
|
24
|
+
for (const { reject, timer } of pending.values()) { clearTimeout(timer); reject(error); }
|
|
25
|
+
pending.clear();
|
|
26
|
+
};
|
|
27
|
+
child.stdin.on("error", close);
|
|
28
|
+
child.on("error", close);
|
|
29
|
+
child.on("close", () => close());
|
|
30
|
+
child.stdout.setEncoding("utf8");
|
|
31
|
+
child.stdout.on("data", (chunk) => {
|
|
32
|
+
buffer += chunk;
|
|
33
|
+
if (buffer.length > 2_000_000) { close(new Error("Codex protocol line exceeds limit")); child.kill(); return; }
|
|
34
|
+
let end;
|
|
35
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
36
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
37
|
+
let message;
|
|
38
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
39
|
+
if (!message || typeof message !== "object") continue;
|
|
40
|
+
if (message.method && message.id !== undefined) {
|
|
41
|
+
// A repeated server request must neither file another question nor
|
|
42
|
+
// receive a second answer. IDs are scoped to this connection.
|
|
43
|
+
if (received.has(message.id)) continue;
|
|
44
|
+
received.add(message.id);
|
|
45
|
+
Promise.resolve().then(() => onRequest(message.method, message.params ?? {}, message.id)).then(
|
|
46
|
+
(result) => { if (!closed) send({ id: message.id, result }); },
|
|
47
|
+
(error) => { if (!closed) send({ id: message.id, error: { code: -32601, message: error.message } }); },
|
|
48
|
+
).catch(() => {});
|
|
49
|
+
} else if (message.method) onNotification(message.method, message.params ?? {});
|
|
50
|
+
else if (pending.has(message.id)) {
|
|
51
|
+
const call = pending.get(message.id); pending.delete(message.id); clearTimeout(call.timer);
|
|
52
|
+
if (message.error) call.reject(new Error(message.error.message ?? "Codex request failed"));
|
|
53
|
+
else call.resolve(message.result);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const request = (method, params = {}) => new Promise((resolve, reject) => {
|
|
58
|
+
const id = ++nextId;
|
|
59
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Codex ${method} timed out`)); }, timeoutMs);
|
|
60
|
+
pending.set(id, { resolve, reject, timer });
|
|
61
|
+
try { send({ id, method, params }); } catch (error) { clearTimeout(timer); pending.delete(id); reject(error); }
|
|
62
|
+
});
|
|
63
|
+
return { request, close, async initialize() {
|
|
64
|
+
await request("initialize", { clientInfo: { name: "glytch", title: "Viewable board runner", version: "0.2.0" }, capabilities: {} });
|
|
65
|
+
send({ method: "initialized", params: {} });
|
|
66
|
+
} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function discoverCodex({ bin = "codex", args = ["app-server"], env = process.env, timeoutMs = 5000, spawnImpl = startCodex } = {}) {
|
|
70
|
+
const child = spawnImpl({ bin, args, env });
|
|
71
|
+
// Drain stderr, but never expose config/auth diagnostics in a runner mark.
|
|
72
|
+
child.stderr?.resume();
|
|
73
|
+
const rpc = codexRpc(child, { timeoutMs });
|
|
74
|
+
const deadline = setTimeout(() => { rpc.close(new Error("Codex discovery timed out")); child.kill(); }, timeoutMs);
|
|
75
|
+
try {
|
|
76
|
+
await rpc.initialize();
|
|
77
|
+
const account = await rpc.request("account/read", { refreshToken: false });
|
|
78
|
+
const models = [];
|
|
79
|
+
let cursor;
|
|
80
|
+
do {
|
|
81
|
+
const page = await rpc.request("model/list", { ...(cursor ? { cursor } : {}), limit: 100 });
|
|
82
|
+
for (const model of page.data ?? []) {
|
|
83
|
+
if (model.hidden || typeof model.model !== "string") continue;
|
|
84
|
+
models.push({ id: model.model, label: model.displayName || model.model,
|
|
85
|
+
efforts: (model.supportedReasoningEfforts ?? []).map((e) => e.reasoningEffort).filter((e) => typeof e === "string"),
|
|
86
|
+
defaultEffort: model.defaultReasoningEffort, isDefault: model.isDefault === true });
|
|
87
|
+
}
|
|
88
|
+
cursor = page.nextCursor;
|
|
89
|
+
} while (cursor && models.length < 200);
|
|
90
|
+
return { loggedIn: account.account != null || account.requiresOpenaiAuth === false, models };
|
|
91
|
+
} finally { clearTimeout(deadline); rpc.close(); child.stdin.end(); child.kill(); }
|
|
92
|
+
}
|