@addai/node 0.30.12 → 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.
- package/dist/capabilities.d.ts +14 -3
- package/dist/capabilities.js +30 -18
- package/dist/claude-models.d.ts +79 -0
- package/dist/claude-models.js +406 -0
- package/package.json +1 -1
package/dist/capabilities.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/capabilities.js
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// a
|
|
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
|
-
//
|
|
110
|
-
// stale — the CLI resolves each to whatever is current in its tier
|
|
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
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.13",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|