@addai/node 0.30.11 → 0.30.13

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.
@@ -1,4 +1,5 @@
1
1
  import type { EngineInfo } from './desktop/provider';
2
+ import { type ClaudeModelEntry } from './claude-models';
2
3
  import { type AutostartState } from './autostart';
3
4
  import { type AutoUpdateState } from './auto-update';
4
5
  import { type MachineSample } from './machine-metrics';
@@ -42,9 +43,7 @@ interface CapabilitiesShape {
42
43
  * UIs show "outdated daemon" hints and helps us debug field issues
43
44
  * ("which version was this row produced by?"). */
44
45
  daemon_version?: string;
45
- claude: BinaryProbe & {
46
- models?: string[];
47
- };
46
+ claude: BinaryProbe & ClaudeModelFields;
48
47
  codex: BinaryProbe & {
49
48
  models?: string[];
50
49
  };
@@ -80,6 +79,18 @@ interface CapabilitiesShape {
80
79
  * version silently changes with no explanation. */
81
80
  auto_update?: AutoUpdateState;
82
81
  }
82
+ /** What the heartbeat says about claude's models. `models` keeps its old
83
+ * shape — plain ids — because Studio and Flows read it as strings; the
84
+ * newer fields sit beside it and older readers simply never look. */
85
+ interface ClaudeModelFields {
86
+ models?: string[];
87
+ /** The CLI's own entries with their names ('Opus 5.5') and alias flags.
88
+ * Built-in extras in `models` have none, and are shown by id. */
89
+ model_catalog?: ClaudeModelEntry[];
90
+ /** 'cli' when the list came from the CLI, 'builtin' when it is the
91
+ * fallback above — so a stale picker can be told apart from a live one. */
92
+ models_source?: 'cli' | 'builtin';
93
+ }
83
94
  /** Codex's credential store: $CODEX_HOME (or ~/.codex) + /auth.json. */
84
95
  export declare function codexHome(): string;
85
96
  /**
@@ -47,6 +47,7 @@ const fs = __importStar(require("fs"));
47
47
  const path = __importStar(require("path"));
48
48
  const os = __importStar(require("os"));
49
49
  const claude_binary_1 = require("./claude-binary");
50
+ const claude_models_1 = require("./claude-models");
50
51
  const kimi_binary_1 = require("./kimi-binary");
51
52
  const gemini_binary_1 = require("./gemini-binary");
52
53
  const grok_binary_1 = require("./grok-binary");
@@ -92,25 +93,21 @@ function runCmd(cmd, args, timeoutMs = 5000) {
92
93
  }
93
94
  });
94
95
  }
95
- // Curated model lists baked into the daemon. The CLIs still expose no
96
- // non-interactive "list models" command, so this stays a constant — but
97
- // WHAT goes in it is what decides whether the UI's dropdown rots.
96
+ // Curated model lists baked into the daemon.
98
97
  //
99
- // Do not "fix" this by probing the CLI. Checked against Claude Code
100
- // 2.1.266: there is no `claude models` subcommand and no `claude config
101
- // list`. Both strings are read as a PROMPT — the CLI starts a billed
102
- // session and answers in prose. Probing that on every heartbeat would
103
- // spend money several times a minute and still return nothing parseable.
98
+ // Claude's is now the FLOOR, not the source: the CLI itself is asked which
99
+ // models it offers (claude-models.ts, over the SDK control protocol), so a new
100
+ // Claude model reaches the pickers when a node's CLI updates, not when someone
101
+ // edits this file. This list is what a node reports until that answer arrives
102
+ // or when its CLI cannot give one — and its older pinned ids are kept beside
103
+ // the CLI's answer, which (asked without a login) lists only the aliases.
104
104
  //
105
- // `claude --help` documents the real contract instead: --model takes
106
- // "an alias for the latest model (e.g. 'fable', 'opus', or 'sonnet') or
107
- // a model's full name (e.g. 'claude-fable-5')".
105
+ // Do not "fix" the fallback by running `claude models` or `claude config
106
+ // list`. Neither exists (checked to 2.1.304): both strings are read as a
107
+ // PROMPT — the CLI starts a billed session and answers in prose.
108
108
  //
109
- // So the ALIASES lead the list. They are the only entries that cannot go
110
- // stale — the CLI resolves each to whatever is current in its tier — and
111
- // staleness is exactly what went wrong before: this list was still
112
- // offering claude-opus-4-1 and claude-sonnet-4-5 long after they were
113
- // superseded, and no amount of heartbeating fixes a wrong constant.
109
+ // The ALIASES lead the list. They are the only entries that cannot go
110
+ // stale — the CLI resolves each to whatever is current in its tier.
114
111
  // Pinned ids follow, newest first, for anyone who must hold one model.
115
112
  //
116
113
  // Availability is plan-gated (these nodes sign in with claude.ai
@@ -124,9 +121,12 @@ const CLAUDE_MODELS = [
124
121
  'haiku',
125
122
  'fable',
126
123
  // Pinned ids, newest first.
124
+ 'claude-opus-5-5',
125
+ 'claude-sonnet-5-5',
126
+ 'claude-haiku-5-5',
127
+ 'claude-fable-5-1',
127
128
  'claude-opus-5',
128
129
  'claude-sonnet-5',
129
- 'claude-fable-5-1',
130
130
  'claude-opus-4-8',
131
131
  'claude-opus-4-7',
132
132
  'claude-sonnet-4-6',
@@ -178,7 +178,19 @@ async function probeClaude() {
178
178
  }
179
179
  catch { /* fall through unauthed */ }
180
180
  }
181
- return { available: true, version, models: CLAUDE_MODELS, ...authShape };
181
+ // Asked once per binary and version — the probe never sees the login, so
182
+ // an account change cannot move its answer. Never waits; see
183
+ // claude-models.ts. No default model is reported: a CLI asked without a
184
+ // login cannot say what this node's account defaults to.
185
+ const live = (0, claude_models_1.claudeModelsFor)(bin, `${bin}|${version ?? ''}`);
186
+ return {
187
+ available: true,
188
+ version,
189
+ models: live ? (0, claude_models_1.withBuiltin)(live, CLAUDE_MODELS).models : CLAUDE_MODELS,
190
+ ...(live ? { model_catalog: live.catalog } : {}),
191
+ models_source: live ? 'cli' : 'builtin',
192
+ ...authShape,
193
+ };
182
194
  }
183
195
  /** Codex's credential store: $CODEX_HOME (or ~/.codex) + /auth.json. */
184
196
  function codexHome() {
@@ -0,0 +1,79 @@
1
+ import { type CliInvocation } from './win';
2
+ export interface ClaudeModelEntry {
3
+ /** What goes to `--model`: an alias ('opus') or a full id. */
4
+ id: string;
5
+ /** What the CLI runs for it today — the same as `id` for a full id. */
6
+ resolved: string;
7
+ /** The CLI's own label, e.g. 'Opus 5.5'. */
8
+ name: string;
9
+ /** An alias names a tier, not a version, and moves when the CLI updates. */
10
+ alias: boolean;
11
+ /** Effort levels this model takes, when the CLI says. */
12
+ efforts?: string[];
13
+ }
14
+ export interface ClaudeModelReport {
15
+ /** Plain ids, aliases first — the shape `capabilities.claude.models` has
16
+ * always had. Studio and Flows read it as strings; keep it that way. */
17
+ models: string[];
18
+ /** The same models with names, for the UIs that know this field. */
19
+ catalog: ClaudeModelEntry[];
20
+ /** What a run with no model set gets — for a logged-OUT CLI, which is all
21
+ * the probe ever sees, so it is not reported as this node's default. */
22
+ defaultModel?: string;
23
+ }
24
+ export type ProbeResult = {
25
+ ok: true;
26
+ report: ClaudeModelReport;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /**
32
+ * Turn the `models` array of an initialize response into a report.
33
+ *
34
+ * Every alias also gets a pinned entry for what it resolves to. The CLI lists
35
+ * `opus` → claude-opus-5-5 but no `claude-opus-5-5` of its own, and without one
36
+ * nobody could hold an entity on Opus 5.5 once `opus` moves on.
37
+ *
38
+ * `default` is left out of `models`: a blank model already means the default,
39
+ * and nobody has tested what `--model default` does.
40
+ *
41
+ * Returns null when nothing usable is in there.
42
+ */
43
+ export declare function parseInitializeModels(raw: unknown): ClaudeModelReport | null;
44
+ /**
45
+ * What the heartbeat reports: the CLI's models first, then any built-in id it
46
+ * did not mention. A logged-out CLI lists only its aliases, and dropping the
47
+ * older pinned ids (Opus 4.8, Sonnet 4.6, …) from every picker would take
48
+ * options away from people who use them. The built-in extras carry no catalog
49
+ * entry — a UI shows them by id, exactly as it always has.
50
+ */
51
+ export declare function withBuiltin(report: ClaudeModelReport, builtin: readonly string[]): ClaudeModelReport;
52
+ /**
53
+ * Ask one Claude binary for its models. Never throws; resolves within
54
+ * `timeoutMs` however the CLI behaves, and leaves no process behind.
55
+ */
56
+ export declare function probeClaudeModels(bin: string, opts?: {
57
+ timeoutMs?: number;
58
+ /** Tests only: run something else in place of the CLI. */
59
+ resolve?: (bin: string, args: string[]) => CliInvocation;
60
+ /** Tests only: where the throwaway folder goes. */
61
+ dir?: string;
62
+ }): Promise<ProbeResult>;
63
+ /** Tests only. */
64
+ export declare function __resetIsolationForTests(): void;
65
+ export interface ModelCacheDeps {
66
+ probe: (bin: string) => Promise<ProbeResult>;
67
+ now: () => number;
68
+ log: (line: string) => void;
69
+ }
70
+ export declare function createClaudeModelCache(deps: ModelCacheDeps): {
71
+ /**
72
+ * The models for this binary state, or null to mean "use the built-in
73
+ * list". Never waits: a missing or stale answer is fetched in the
74
+ * background and arrives on a later call.
75
+ */
76
+ get(bin: string, key: string): ClaudeModelReport | null;
77
+ };
78
+ /** What capabilities.ts reports for claude's models. */
79
+ export declare function claudeModelsFor(bin: string, key: string): ClaudeModelReport | null;
@@ -0,0 +1,406 @@
1
+ "use strict";
2
+ // Which models the installed Claude Code offers — asked of the CLI itself.
3
+ //
4
+ // Until 0.30.13 this was only the CLAUDE_MODELS constant in capabilities.ts,
5
+ // and a constant is only as current as the last release: Opus 5.5 shipped and
6
+ // every node went on offering Opus 5, because nobody had edited the list.
7
+ //
8
+ // The CLI has no `claude models` command (still true in 2.1.304, and anything
9
+ // it does not recognise is read as a PROMPT, which is billed). But it does
10
+ // answer the SDK control protocol: start it in stream-json mode, send the
11
+ // `initialize` control request, and the response carries `models` — the list
12
+ // its own /model picker shows, with what each alias resolves to today. No user
13
+ // message is ever sent, so nothing is billed.
14
+ //
15
+ // ⚠️ THE PROBE MUST NEVER SEE THE NODE'S LOGIN. A Claude process that starts
16
+ // with an expired access token refreshes it, which rotates the refresh token;
17
+ // one that exits a second later — as a probe does — can lose the rotated token,
18
+ // and the next process finds a dead login and wipes it. That logged popadom's
19
+ // Claude out on 2026-10-20 while this was being built. So the probe runs with
20
+ // a throwaway CLAUDE_CONFIG_DIR, `--bare` (no keychain reads — the macOS
21
+ // login lives there, not in the config dir), and no credential env vars. The
22
+ // CLI then reports `tokenSource: "none"` and still answers, with the
23
+ // account-independent list: the aliases and what they resolve to.
24
+ //
25
+ // 2.1.278 `opus[1m]` → claude-opus-5[1m], `sonnet` → claude-sonnet-5
26
+ // 2.1.304 `opus` → claude-opus-5-5, `sonnet` → claude-sonnet-5-5
27
+ //
28
+ // If a CLI ever answers WITH a login despite all that, isolation has failed:
29
+ // this process stops probing for good and says so in the log.
30
+ //
31
+ // A probe costs a CLI start (~1s) and the heartbeat runs every 30s, so the
32
+ // answer is cached per (binary, version) and the heartbeat never waits for it:
33
+ // the first beat after an install or upgrade still reports what it had, kicks
34
+ // the probe off in the background, and the next beat carries the answer. A
35
+ // probe that fails — an old CLI, a protocol change — leaves the built-in list
36
+ // in place (or the last answer that same binary gave) and is not retried for
37
+ // RETRY_AFTER_FAIL_MS, so a broken CLI never costs a spawn every 30 seconds.
38
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
39
+ if (k2 === undefined) k2 = k;
40
+ var desc = Object.getOwnPropertyDescriptor(m, k);
41
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
42
+ desc = { enumerable: true, get: function() { return m[k]; } };
43
+ }
44
+ Object.defineProperty(o, k2, desc);
45
+ }) : (function(o, m, k, k2) {
46
+ if (k2 === undefined) k2 = k;
47
+ o[k2] = m[k];
48
+ }));
49
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
50
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
51
+ }) : function(o, v) {
52
+ o["default"] = v;
53
+ });
54
+ var __importStar = (this && this.__importStar) || (function () {
55
+ var ownKeys = function(o) {
56
+ ownKeys = Object.getOwnPropertyNames || function (o) {
57
+ var ar = [];
58
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
59
+ return ar;
60
+ };
61
+ return ownKeys(o);
62
+ };
63
+ return function (mod) {
64
+ if (mod && mod.__esModule) return mod;
65
+ var result = {};
66
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
67
+ __setModuleDefault(result, mod);
68
+ return result;
69
+ };
70
+ })();
71
+ Object.defineProperty(exports, "__esModule", { value: true });
72
+ exports.parseInitializeModels = parseInitializeModels;
73
+ exports.withBuiltin = withBuiltin;
74
+ exports.probeClaudeModels = probeClaudeModels;
75
+ exports.__resetIsolationForTests = __resetIsolationForTests;
76
+ exports.createClaudeModelCache = createClaudeModelCache;
77
+ exports.claudeModelsFor = claudeModelsFor;
78
+ const child_process_1 = require("child_process");
79
+ const fs = __importStar(require("fs"));
80
+ const os = __importStar(require("os"));
81
+ const path = __importStar(require("path"));
82
+ const paths_1 = require("./paths");
83
+ const win_1 = require("./win");
84
+ // What a model id may look like before it is passed on: `claude-opus-5-5`,
85
+ // `opus[1m]`, `claude-haiku-4-5-20251001`. Anything else is dropped rather
86
+ // than shipped to every picker.
87
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:[\]-]{0,119}$/;
88
+ const MAX_ENTRIES = 64;
89
+ /** An alias carries no version number: `opus`, `sonnet`, `opus[1m]`. */
90
+ function isAlias(id) {
91
+ return !/\d/.test(id.replace(/\[[^\]]*\]$/, ''));
92
+ }
93
+ /**
94
+ * Turn the `models` array of an initialize response into a report.
95
+ *
96
+ * Every alias also gets a pinned entry for what it resolves to. The CLI lists
97
+ * `opus` → claude-opus-5-5 but no `claude-opus-5-5` of its own, and without one
98
+ * nobody could hold an entity on Opus 5.5 once `opus` moves on.
99
+ *
100
+ * `default` is left out of `models`: a blank model already means the default,
101
+ * and nobody has tested what `--model default` does.
102
+ *
103
+ * Returns null when nothing usable is in there.
104
+ */
105
+ function parseInitializeModels(raw) {
106
+ if (!Array.isArray(raw))
107
+ return null;
108
+ const aliases = [];
109
+ const pinned = [];
110
+ const derived = [];
111
+ let defaultModel;
112
+ for (const item of raw.slice(0, MAX_ENTRIES)) {
113
+ if (!item || typeof item !== 'object')
114
+ continue;
115
+ const o = item;
116
+ const id = typeof o.value === 'string' ? o.value.trim() : '';
117
+ if (!SAFE_ID.test(id))
118
+ continue;
119
+ const resolved = typeof o.resolvedModel === 'string' && SAFE_ID.test(o.resolvedModel.trim())
120
+ ? o.resolvedModel.trim() : id;
121
+ const name = typeof o.displayName === 'string' && o.displayName.trim()
122
+ ? o.displayName.trim().slice(0, 80) : id;
123
+ const efforts = Array.isArray(o.supportedEffortLevels)
124
+ ? o.supportedEffortLevels.filter((e) => typeof e === 'string' && /^[a-z]{1,20}$/.test(e))
125
+ : [];
126
+ if (id === 'default') {
127
+ if (resolved !== 'default')
128
+ defaultModel = resolved;
129
+ continue;
130
+ }
131
+ const entry = { id, resolved, name, alias: isAlias(id), ...(efforts.length ? { efforts } : {}) };
132
+ (entry.alias ? aliases : pinned).push(entry);
133
+ if (entry.alias && resolved !== id) {
134
+ derived.push({ id: resolved, resolved, name, alias: false, ...(efforts.length ? { efforts } : {}) });
135
+ }
136
+ }
137
+ // Aliases first (they never go stale), then what they point at (the newest
138
+ // models), then the CLI's own pinned list in its order.
139
+ const seen = new Set();
140
+ const catalog = [];
141
+ for (const e of [...aliases, ...derived, ...pinned]) {
142
+ if (seen.has(e.id))
143
+ continue;
144
+ seen.add(e.id);
145
+ catalog.push(e);
146
+ }
147
+ if (catalog.length === 0)
148
+ return null;
149
+ return { models: catalog.map(e => e.id), catalog, ...(defaultModel ? { defaultModel } : {}) };
150
+ }
151
+ /**
152
+ * What the heartbeat reports: the CLI's models first, then any built-in id it
153
+ * did not mention. A logged-out CLI lists only its aliases, and dropping the
154
+ * older pinned ids (Opus 4.8, Sonnet 4.6, …) from every picker would take
155
+ * options away from people who use them. The built-in extras carry no catalog
156
+ * entry — a UI shows them by id, exactly as it always has.
157
+ */
158
+ function withBuiltin(report, builtin) {
159
+ const have = new Set(report.models);
160
+ return { ...report, models: [...report.models, ...builtin.filter(id => !have.has(id))] };
161
+ }
162
+ /** Never read a login: the CLI must not see, refresh or clear one. */
163
+ const CREDENTIAL_ENV = [
164
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
165
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CONFIG_DIR',
166
+ ];
167
+ /** A throwaway folder that is both the probe's cwd and its CLAUDE_CONFIG_DIR,
168
+ * so no project's settings or CLAUDE.md are read and nothing the CLI writes
169
+ * lands in the real ~/.claude. Folders older than a day are swept — a
170
+ * probe's own cleanup can lose a race with Windows releasing the cwd. */
171
+ function makeProbeDir() {
172
+ const root = path.join(paths_1.RUNTIME_HOME, 'model-probe');
173
+ try {
174
+ fs.mkdirSync(root, { recursive: true });
175
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
176
+ for (const name of fs.readdirSync(root)) {
177
+ const p = path.join(root, name);
178
+ try {
179
+ if (fs.statSync(p).mtimeMs < cutoff)
180
+ fs.rmSync(p, { recursive: true, force: true });
181
+ }
182
+ catch { /* next time */ }
183
+ }
184
+ return fs.mkdtempSync(path.join(root, 'p-'));
185
+ }
186
+ catch {
187
+ try {
188
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ainode-model-probe-'));
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ }
195
+ const PROBE_ARGS = [
196
+ '--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
197
+ // No keychain reads (the macOS login), no hooks, no plugin sync, no
198
+ // background prefetches. A CLI too old to know --bare refuses to start,
199
+ // and that node keeps the built-in list — the safe way to be wrong.
200
+ '--bare',
201
+ // No MCP servers: the handshake needs none.
202
+ '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
203
+ ];
204
+ const MAX_LINE = 2 * 1024 * 1024;
205
+ /** Set once a CLI answers with a login despite the isolation above. */
206
+ let isolationBroken = false;
207
+ /**
208
+ * Ask one Claude binary for its models. Never throws; resolves within
209
+ * `timeoutMs` however the CLI behaves, and leaves no process behind.
210
+ */
211
+ function probeClaudeModels(bin, opts = {}) {
212
+ return new Promise(done => {
213
+ if (isolationBroken) {
214
+ done({ ok: false, reason: 'probing is off: a CLI saw a login despite the isolation' });
215
+ return;
216
+ }
217
+ let settled = false;
218
+ let inv;
219
+ try {
220
+ inv = (opts.resolve ?? win_1.resolveCliInvocation)(bin, PROBE_ARGS);
221
+ }
222
+ catch (err) {
223
+ done({ ok: false, reason: err.message || String(err) });
224
+ return;
225
+ }
226
+ const dir = opts.dir ?? makeProbeDir();
227
+ if (!dir) {
228
+ done({ ok: false, reason: 'no folder to probe from' });
229
+ return;
230
+ }
231
+ const cleanup = () => {
232
+ if (opts.dir)
233
+ return;
234
+ try {
235
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
236
+ }
237
+ catch { /* swept later */ }
238
+ };
239
+ const env = { ...process.env, TERM: 'dumb' };
240
+ for (const k of CREDENTIAL_ENV)
241
+ delete env[k];
242
+ env.CLAUDE_CONFIG_DIR = dir;
243
+ // A probe is read-only: it must never start a self-update of the CLI.
244
+ env.DISABLE_AUTOUPDATER = '1';
245
+ let child;
246
+ try {
247
+ child = (0, child_process_1.spawn)(inv.file, inv.args, { cwd: dir, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
248
+ }
249
+ catch (err) {
250
+ cleanup();
251
+ done({ ok: false, reason: err.message || String(err) });
252
+ return;
253
+ }
254
+ child.once('close', cleanup);
255
+ const finish = (result) => {
256
+ if (settled)
257
+ return;
258
+ settled = true;
259
+ clearTimeout(timer);
260
+ // EOF first — in stream-json mode the CLI exits on its own when its
261
+ // input ends. The kill is the backstop for one that does not.
262
+ try {
263
+ child.stdin?.end();
264
+ }
265
+ catch { /* already closed */ }
266
+ const reap = setTimeout(() => {
267
+ if (child.exitCode === null && child.signalCode === null)
268
+ (0, win_1.killProcessTree)(child);
269
+ }, 3000);
270
+ reap.unref();
271
+ child.once('exit', () => clearTimeout(reap));
272
+ done(result);
273
+ };
274
+ const timer = setTimeout(() => {
275
+ finish({ ok: false, reason: `no answer within ${opts.timeoutMs ?? 30000}ms` });
276
+ (0, win_1.killProcessTree)(child);
277
+ }, opts.timeoutMs ?? 30000);
278
+ timer.unref();
279
+ child.on('error', err => finish({ ok: false, reason: err.message || String(err) }));
280
+ // 'close', not 'exit': close waits for stdout to drain, so an answer the
281
+ // CLI wrote just before exiting is read rather than reported missing.
282
+ child.on('close', (code, signal) => {
283
+ finish({ ok: false, reason: `exited ${code ?? signal} before answering${stderr ? `: ${stderr.trim().slice(0, 200)}` : ''}` });
284
+ });
285
+ child.stdin?.on('error', () => { });
286
+ let stderr = '';
287
+ child.stderr?.on('data', (d) => { if (stderr.length < 4096)
288
+ stderr += d.toString('utf8'); });
289
+ const requestId = `models-${process.pid}-${Date.now()}`;
290
+ let buf = '';
291
+ child.stdout?.on('data', (d) => {
292
+ buf += d.toString('utf8');
293
+ if (buf.length > MAX_LINE) {
294
+ finish({ ok: false, reason: 'oversized output' });
295
+ return;
296
+ }
297
+ let nl;
298
+ while ((nl = buf.indexOf('\n')) >= 0) {
299
+ const line = buf.slice(0, nl).trim();
300
+ buf = buf.slice(nl + 1);
301
+ if (!line.startsWith('{'))
302
+ continue;
303
+ let msg;
304
+ try {
305
+ msg = JSON.parse(line);
306
+ }
307
+ catch {
308
+ continue;
309
+ }
310
+ if (msg.type !== 'control_response')
311
+ continue;
312
+ const res = msg.response ?? {};
313
+ if (res.request_id && res.request_id !== requestId)
314
+ continue;
315
+ if (res.subtype === 'error') {
316
+ finish({ ok: false, reason: `initialize refused: ${String(res.error ?? 'unknown').slice(0, 200)}` });
317
+ return;
318
+ }
319
+ const tokenSource = res.response?.account?.tokenSource;
320
+ if (typeof tokenSource === 'string' && tokenSource !== 'none') {
321
+ isolationBroken = true;
322
+ finish({ ok: false, reason: `isolation failed — the CLI saw a login (${tokenSource}); probing is now off on this node` });
323
+ return;
324
+ }
325
+ const report = parseInitializeModels(res.response?.models);
326
+ finish(report ? { ok: true, report } : { ok: false, reason: 'initialize answered without a model list' });
327
+ return;
328
+ }
329
+ });
330
+ try {
331
+ child.stdin?.write(JSON.stringify({
332
+ type: 'control_request', request_id: requestId, request: { subtype: 'initialize' },
333
+ }) + '\n');
334
+ }
335
+ catch { /* reported via 'close' / 'error' */ }
336
+ });
337
+ }
338
+ /** Tests only. */
339
+ function __resetIsolationForTests() { isolationBroken = false; }
340
+ /* ── the cache the heartbeat reads ─────────────────────────────────────── */
341
+ /** Re-ask now and then even when nothing changed: the CLI's list can move
342
+ * without a CLI update. */
343
+ const REFRESH_MS = 6 * 60 * 60 * 1000;
344
+ const RETRY_AFTER_FAIL_MS = 30 * 60 * 1000;
345
+ function createClaudeModelCache(deps) {
346
+ let good = null;
347
+ let failed = null;
348
+ let inflight = null;
349
+ const start = (bin, key) => {
350
+ if (inflight)
351
+ return;
352
+ inflight = deps.probe(bin).then(result => {
353
+ if (result.ok) {
354
+ const changed = !good || good.report.models.join() !== result.report.models.join();
355
+ good = { key, report: result.report, at: deps.now() };
356
+ if (failed?.key === key)
357
+ failed = null;
358
+ if (changed) {
359
+ const head = result.report.catalog.filter(e => e.alias && e.resolved !== e.id)
360
+ .map(e => `${e.id} → ${e.resolved}`).join(', ');
361
+ deps.log(`[models] claude: ${result.report.models.length} models from the CLI${head ? ` (${head})` : ''}`);
362
+ }
363
+ }
364
+ else {
365
+ // An answer this exact binary already gave stays good: a refresh
366
+ // that fails is a reason to retry, not to forget it.
367
+ failed = { key, at: deps.now() };
368
+ const kept = good?.key === key;
369
+ deps.log(`[models] could not read claude's model list (${result.reason}) — ${kept ? 'keeping the last answer' : 'offering the built-in list'}; next try in ${RETRY_AFTER_FAIL_MS / 60000} min`);
370
+ }
371
+ }).catch(() => { failed = { key, at: deps.now() }; })
372
+ .finally(() => { inflight = null; });
373
+ };
374
+ return {
375
+ /**
376
+ * The models for this binary state, or null to mean "use the built-in
377
+ * list". Never waits: a missing or stale answer is fetched in the
378
+ * background and arrives on a later call.
379
+ */
380
+ get(bin, key) {
381
+ const now = deps.now();
382
+ const recentlyFailed = failed?.key === key && now - failed.at < RETRY_AFTER_FAIL_MS;
383
+ if (good?.key === key) {
384
+ if (now - good.at > REFRESH_MS && !recentlyFailed)
385
+ start(bin, key);
386
+ return good.report;
387
+ }
388
+ if (!recentlyFailed)
389
+ start(bin, key);
390
+ // A new CLI version that has not answered yet: keep showing the last
391
+ // real list — closer to the truth than the constant, and it stops the
392
+ // pickers flickering to the built-in list across an upgrade. Once the
393
+ // new version has actually failed, the built-in list it is.
394
+ return failed?.key === key ? null : (good?.report ?? null);
395
+ },
396
+ };
397
+ }
398
+ const sharedCache = createClaudeModelCache({
399
+ probe: bin => probeClaudeModels(bin),
400
+ now: () => Date.now(),
401
+ log: line => console.log(line),
402
+ });
403
+ /** What capabilities.ts reports for claude's models. */
404
+ function claudeModelsFor(bin, key) {
405
+ return sharedCache.get(bin, key);
406
+ }
@@ -25,14 +25,30 @@ export interface CodexInput {
25
25
  * Mirrors the claude --strict-mcp-config behavior. */
26
26
  mcpServers?: CodexMcpServer[];
27
27
  }
28
+ export interface CodexHomeOptions {
29
+ /** Forward a remote server's credentials as headers via env_http_headers.
30
+ * spawnCodex passes codexSupportsEnvHttpHeaders(bin), so a codex too old to
31
+ * know the key keeps exactly the config it had before. Default true. */
32
+ envHttpHeaders?: boolean;
33
+ }
28
34
  /** Build a config.toml that declares only the supplied MCP servers and
29
35
  * return the CODEX_HOME directory plus any env vars the child needs
30
- * (bearer tokens for remote servers). Caller is responsible for cleanup
31
- * (we drop it under os.tmpdir() so it gets reaped). */
32
- export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string): {
36
+ * (bearer tokens and header values for remote servers). Caller is
37
+ * responsible for cleanup (we drop it under os.tmpdir() so it gets reaped). */
38
+ export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string, opts?: CodexHomeOptions): {
33
39
  home: string;
34
40
  extraEnv: Record<string, string>;
35
41
  };
42
+ /**
43
+ * Does this codex know env_http_headers? Asked by having it parse a throwaway
44
+ * config (`codex mcp list --json`, which reads config and contacts nothing)
45
+ * and checking that the header survived. Remembered per binary. Any failure
46
+ * answers "no", which keeps the bearer-only config every install ran before.
47
+ */
48
+ export declare function codexSupportsEnvHttpHeaders(bin: string, probe?: (bin: string, codexHome: string) => string): boolean;
49
+ /** Read `codex mcp list --json` output: did the probe server's header survive?
50
+ * Exported for tests. */
51
+ export declare function codexListShowsEnvHeaders(stdout: string): boolean;
36
52
  /** First line of an AGENTS.md this daemon wrote. A project's own AGENTS.md
37
53
  * never carries it and is never touched. */
38
54
  export declare const AGENTS_MD_MARKER = "<!-- ainode: entity system prompt \u2014 rewritten before every turn; edits here do not persist -->";
@@ -42,6 +42,8 @@ var __importStar = (this && this.__importStar) || (function () {
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.AGENTS_MD_MARKER = void 0;
44
44
  exports.writeCodexHome = writeCodexHome;
45
+ exports.codexSupportsEnvHttpHeaders = codexSupportsEnvHttpHeaders;
46
+ exports.codexListShowsEnvHeaders = codexListShowsEnvHeaders;
45
47
  exports.writeCodexAgentsMd = writeCodexAgentsMd;
46
48
  exports.codexEffort = codexEffort;
47
49
  exports.spawnCodex = spawnCodex;
@@ -55,6 +57,7 @@ const diskguard_1 = require("./diskguard");
55
57
  const win_1 = require("./win");
56
58
  const events_1 = require("./events");
57
59
  const prompt_transport_1 = require("./prompt-transport");
60
+ const mcp_headers_1 = require("./mcp-headers");
58
61
  /** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
59
62
  function tomlString(s) {
60
63
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
@@ -63,11 +66,17 @@ function tomlString(s) {
63
66
  * Only the NAME goes in config.toml — the value rides on the child env, so
64
67
  * the token never lands on disk. */
65
68
  const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
69
+ /** Env var name carrying one HTTP header's value into the codex process.
70
+ * config.toml references it by name via env_http_headers, so the value never
71
+ * lands on disk either. */
72
+ const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
73
+ `_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
66
74
  /** Build a config.toml that declares only the supplied MCP servers and
67
75
  * return the CODEX_HOME directory plus any env vars the child needs
68
- * (bearer tokens for remote servers). Caller is responsible for cleanup
69
- * (we drop it under os.tmpdir() so it gets reaped). */
70
- function writeCodexHome(servers, workingDirectory) {
76
+ * (bearer tokens and header values for remote servers). Caller is
77
+ * responsible for cleanup (we drop it under os.tmpdir() so it gets reaped). */
78
+ function writeCodexHome(servers, workingDirectory, opts = {}) {
79
+ const envHttpHeaders = opts.envHttpHeaders !== false;
71
80
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entities-codex-'));
72
81
  const extraEnv = {};
73
82
  let toml = '# Generated by ainode — sandboxes codex against host config\n\n';
@@ -92,6 +101,8 @@ function writeCodexHome(servers, workingDirectory) {
92
101
  toml += `url = ${tomlString(url)}\n`;
93
102
  // codex reads the bearer from its OWN environment (--env is stdio-only),
94
103
  // so the value goes on the child env and only the var name in the file.
104
+ // The bearer is exactly what this adapter always sent, kept so anything
105
+ // that works on codex today keeps working.
95
106
  const creds = Object.entries(s.env || {})
96
107
  .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
97
108
  const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
@@ -99,15 +110,34 @@ function writeCodexHome(servers, workingDirectory) {
99
110
  const varName = bearerEnvVar(s.slug);
100
111
  extraEnv[varName] = String(bearer[1]);
101
112
  toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
102
- if (creds.length > 1) {
103
- // codex carries one bearer and no arbitrary headers. Anything that
104
- // authenticates with several custom headers is only partly wired,
105
- // and saying so beats a server that 401s for no visible reason.
106
- console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
107
- `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
108
- `field(s) were dropped`);
113
+ }
114
+ // The bearer alone was never enough. The servers read each credential
115
+ // from its own header (the claude path sends them all, see
116
+ // remoteMcpHeaders) and fall back to the bearer for any that is missing,
117
+ // so +Ai Drive, which carries three, used its API key as its workspace
118
+ // id and answered "Access denied to workspace" on every codex run.
119
+ // codex does send custom headers: env_http_headers maps a header name to
120
+ // the NAME of an env var, so the values stay off disk like the bearer.
121
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
122
+ // An OAuth access token is already the bearer above.
123
+ delete headers.Authorization;
124
+ const entries = Object.entries(headers);
125
+ if (entries.length > 0 && envHttpHeaders) {
126
+ // A sub-table must follow the parent table's scalar keys.
127
+ toml += `[mcp_servers.${name}.env_http_headers]\n`;
128
+ for (const [header, value] of entries) {
129
+ const varName = headerEnvVar(s.slug, header);
130
+ extraEnv[varName] = value;
131
+ toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
109
132
  }
110
133
  }
134
+ else if (creds.length > 1) {
135
+ // A codex too old to know env_http_headers carries the bearer only.
136
+ // Saying so beats a server that refuses for no visible reason.
137
+ console.error(`[codex] MCP ${s.slug}: this codex cannot send custom headers, so only ` +
138
+ `${bearer?.[0]} is forwarded and ${creds.length - 1} other credential ` +
139
+ `field(s) were dropped`);
140
+ }
111
141
  toml += '\n';
112
142
  continue;
113
143
  }
@@ -159,6 +189,76 @@ function writeCodexHome(servers, workingDirectory) {
159
189
  }
160
190
  return { home: dir, extraEnv };
161
191
  }
192
+ const PROBE_SERVER = 'ainode_probe';
193
+ const PROBE_HEADER = 'X-Ainode-Probe';
194
+ // A real answer holds until the binary might have been updated; a probe that
195
+ // could not run at all is retried sooner.
196
+ const PROBE_TTL_MS = 6 * 60 * 60 * 1000;
197
+ const PROBE_FAILED_TTL_MS = 10 * 60 * 1000;
198
+ const envHeaderSupport = new Map();
199
+ /**
200
+ * Does this codex know env_http_headers? Asked by having it parse a throwaway
201
+ * config (`codex mcp list --json`, which reads config and contacts nothing)
202
+ * and checking that the header survived. Remembered per binary. Any failure
203
+ * answers "no", which keeps the bearer-only config every install ran before.
204
+ */
205
+ function codexSupportsEnvHttpHeaders(bin, probe = codexMcpListJson) {
206
+ const hit = envHeaderSupport.get(bin);
207
+ if (hit && hit.until > Date.now())
208
+ return hit.ok;
209
+ let ok = false;
210
+ let failed = false;
211
+ let home = null;
212
+ try {
213
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'ainode-codex-probe-'));
214
+ fs.writeFileSync(path.join(home, 'config.toml'), `[mcp_servers.${PROBE_SERVER}]\n` +
215
+ `url = "http://127.0.0.1:9/mcp"\n` +
216
+ // A bearer keeps codex from looking for stored OAuth credentials.
217
+ `bearer_token_env_var = "AINODE_PROBE_TOKEN"\n` +
218
+ `[mcp_servers.${PROBE_SERVER}.env_http_headers]\n` +
219
+ `"${PROBE_HEADER}" = "AINODE_PROBE_HEADER"\n`, { mode: 0o600 });
220
+ ok = codexListShowsEnvHeaders(probe(bin, home));
221
+ }
222
+ catch {
223
+ ok = false;
224
+ failed = true;
225
+ }
226
+ finally {
227
+ if (home) {
228
+ try {
229
+ fs.rmSync(home, { recursive: true, force: true });
230
+ }
231
+ catch { /* reaped from tmp */ }
232
+ }
233
+ }
234
+ envHeaderSupport.set(bin, { ok, until: Date.now() + (failed ? PROBE_FAILED_TTL_MS : PROBE_TTL_MS) });
235
+ return ok;
236
+ }
237
+ /** Read `codex mcp list --json` output: did the probe server's header survive?
238
+ * Exported for tests. */
239
+ function codexListShowsEnvHeaders(stdout) {
240
+ const start = stdout.search(/^\s*\[/m);
241
+ if (start < 0)
242
+ return false;
243
+ let list;
244
+ try {
245
+ list = JSON.parse(stdout.slice(start));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ if (!Array.isArray(list))
251
+ return false;
252
+ const entry = list.find((e) => e && e.name === PROBE_SERVER);
253
+ return entry?.transport?.env_http_headers?.[PROBE_HEADER] === 'AINODE_PROBE_HEADER';
254
+ }
255
+ function codexMcpListJson(bin, codexHome) {
256
+ const inv = (0, win_1.resolveCliInvocation)(bin, ['mcp', 'list', '--json']);
257
+ return (0, child_process_1.execFileSync)(inv.file, inv.args, {
258
+ encoding: 'utf8', timeout: 20_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
259
+ env: { ...process.env, CODEX_HOME: codexHome },
260
+ });
261
+ }
162
262
  /** First line of an AGENTS.md this daemon wrote. A project's own AGENTS.md
163
263
  * never carries it and is never touched. */
164
264
  exports.AGENTS_MD_MARKER = '<!-- ainode: entity system prompt — rewritten before every turn; edits here do not persist -->';
@@ -270,10 +370,19 @@ function spawnCodex(input) {
270
370
  const args = input.resumeThreadId
271
371
  ? ['exec', ...baseFlags, 'resume', input.resumeThreadId, input.prompt]
272
372
  : ['exec', ...baseFlags, input.prompt];
373
+ // Resolve binary path before spawn. Previously called spawn('codex', ...)
374
+ // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
375
+ // and rejected `done` before any session_end event fired → request
376
+ // stuck at 'running' on the DB. Resolved first now, because the config
377
+ // below depends on what this codex understands.
378
+ const bin = (0, codex_binary_1.findCodexBinary)();
273
379
  // Sandbox codex against an isolated CODEX_HOME so the host's
274
380
  // ~/.codex/config.toml mcp_servers don't leak in. TERM=dumb keeps
275
381
  // codex's TTY heuristics happy (no fancy terminal output).
276
- const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
382
+ const servers = input.mcpServers ?? [];
383
+ // Only a remote server that carries credentials needs the header probe.
384
+ const wantsHeaders = servers.some((s) => (0, mcp_headers_1.isRemoteMcp)(s.command) && Object.keys(s.env ?? {}).length > 0);
385
+ const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(servers, input.workingDirectory, { envHttpHeaders: wantsHeaders && bin ? codexSupportsEnvHttpHeaders(bin) : false });
277
386
  // Register the scratch dir with DiskGuard: marks it active (so a
278
387
  // concurrent sweep won't evict a live run) and writes a PID lock so a
279
388
  // different runtime process can tell it's still owned. dispose(true)
@@ -284,15 +393,11 @@ function spawnCodex(input) {
284
393
  ...process.env,
285
394
  TERM: 'dumb',
286
395
  CODEX_HOME: codexHome,
287
- // Bearer tokens for remote (http/sse) MCP servers. config.toml names these
288
- // vars via bearer_token_env_var; the values only ever live here.
396
+ // Bearer tokens and header values for remote (http/sse) MCP servers.
397
+ // config.toml names these vars via bearer_token_env_var and
398
+ // env_http_headers; the values only ever live here.
289
399
  ...mcpBearerEnv,
290
400
  };
291
- // Resolve binary path before spawn. Previously called spawn('codex', ...)
292
- // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
293
- // and rejected `done` before any session_end event fired → request
294
- // stuck at 'running' on the DB.
295
- const bin = (0, codex_binary_1.findCodexBinary)();
296
401
  const listeners = [];
297
402
  const emit = (e) => { for (const l of listeners)
298
403
  try {
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@addai/node",
3
- "version": "0.30.11",
4
- "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
- "license": "MIT",
6
- "keywords": [
7
- "addai",
8
- "entity-studio",
9
- "claude",
10
- "codex",
11
- "kimi",
12
- "gemini",
13
- "agent",
14
- "daemon",
15
- "supabase",
16
- "mcp"
17
- ],
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/just-AddAi/addai-entity-runtime.git"
21
- },
22
- "homepage": "https://github.com/just-AddAi/addai-entity-runtime#readme",
23
- "bugs": {
24
- "url": "https://github.com/just-AddAi/addai-entity-runtime/issues"
25
- },
26
- "type": "commonjs",
27
- "main": "dist/index.js",
28
- "bin": {
29
- "ainode": "dist/cli.js"
30
- },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
- "files": [
35
- "dist",
36
- "scripts",
37
- "README.md",
38
- "assets"
39
- ],
40
- "scripts": {
41
- "build": "tsc -p tsconfig.json && node scripts/copy-assets.js",
42
- "start": "node dist/cli.js",
43
- "dev": "tsc -p tsconfig.json && node dist/cli.js",
44
- "test": "npm run build && node --test test/*.test.mjs",
45
- "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
46
- "postinstall": "node scripts/fix-pty-helper.js",
47
- "prepublishOnly": "npm run build"
48
- },
49
- "engines": {
50
- "node": ">=18"
51
- },
52
- "dependencies": {
53
- "@addai/node-flows": "^1.0.0",
54
- "node-pty": "^1.1.0",
55
- "ws": "^8.21.3"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^20.0.0",
59
- "@types/ws": "^8.18.1",
60
- "typescript": "^5.6.0"
61
- }
62
- }
1
+ {
2
+ "name": "@addai/node",
3
+ "version": "0.30.13",
4
+ "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "addai",
8
+ "entity-studio",
9
+ "claude",
10
+ "codex",
11
+ "kimi",
12
+ "gemini",
13
+ "agent",
14
+ "daemon",
15
+ "supabase",
16
+ "mcp"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/just-AddAi/addai-entity-runtime.git"
21
+ },
22
+ "homepage": "https://github.com/just-AddAi/addai-entity-runtime#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/just-AddAi/addai-entity-runtime/issues"
25
+ },
26
+ "type": "commonjs",
27
+ "main": "dist/index.js",
28
+ "bin": {
29
+ "ainode": "dist/cli.js"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "scripts",
37
+ "README.md",
38
+ "assets"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json && node scripts/copy-assets.js",
42
+ "start": "node dist/cli.js",
43
+ "dev": "tsc -p tsconfig.json && node dist/cli.js",
44
+ "test": "npm run build && node --test test/*.test.mjs",
45
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
46
+ "postinstall": "node scripts/fix-pty-helper.js",
47
+ "prepublishOnly": "npm run build"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "dependencies": {
53
+ "@addai/node-flows": "^1.0.0",
54
+ "node-pty": "^1.1.0",
55
+ "ws": "^8.21.3"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^20.0.0",
59
+ "@types/ws": "^8.18.1",
60
+ "typescript": "^5.6.0"
61
+ }
62
+ }