@resolflow/cli 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Daemon = exports.PROTOCOL_VERSION = void 0;
4
+ exports.backoffDelay = backoffDelay;
5
+ const socket_io_client_1 = require("socket.io-client");
6
+ const protocol_1 = require("./protocol");
7
+ Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_1.PROTOCOL_VERSION; } });
8
+ const types_1 = require("./adapters/types");
9
+ const openai_compatible_1 = require("./adapters/openai-compatible");
10
+ const auth_1 = require("./agents/auth");
11
+ const MODELS_REFRESH_MS = 5 * 60_000;
12
+ const KEEP_ALIVE_MS = 60_000;
13
+ /** Exponential backoff with full jitter, capped. */
14
+ function backoffDelay(attempt, minMs = 1_000, maxMs = 60_000, rand = Math.random) {
15
+ const cap = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 16));
16
+ return Math.max(minMs, Math.floor(rand() * cap));
17
+ }
18
+ /**
19
+ * The `resolflow start` daemon (RF-141bbf2a, P3.1): one OUTBOUND socket.io connection (websocket transport only;
20
+ * wss:// for any non-loopback API) to the Resolflow API, namespace /cli, authenticated with the device token in
21
+ * the handshake auth (never in a URL). It forwards `llm_request`s to the local OpenAI-compatible server and
22
+ * streams the answer back. It listens on no port, runs no commands, and logs no prompt or answer.
23
+ */
24
+ class Daemon {
25
+ opts;
26
+ socket = null;
27
+ active = new Map();
28
+ stopped = false;
29
+ reconnectTimer = null;
30
+ heartbeatTimer = null;
31
+ modelsTimer = null;
32
+ /** Ref'd on purpose: the one handle that keeps the process alive between connection attempts (RF-780ee2a4). */
33
+ keepAliveTimer = null;
34
+ wasConnected = false;
35
+ attempt = 0;
36
+ st;
37
+ adapters;
38
+ inputHandlers = new Map();
39
+ policy;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ this.adapters = opts.adapters ?? [new openai_compatible_1.OpenAiCompatibleAdapter(opts.endpoint, { listModels: opts.listModels, streamChat: opts.streamChat })];
43
+ this.policy = opts.policy ?? { current: null };
44
+ this.st = {
45
+ connected: false,
46
+ deviceId: null,
47
+ workspaceId: null,
48
+ paused: opts.paused,
49
+ maxConcurrency: clamp(opts.maxConcurrency),
50
+ allowBackground: opts.allowBackground,
51
+ active: 0,
52
+ served: 0,
53
+ failed: 0,
54
+ models: [],
55
+ lastError: null,
56
+ reconnectAttempt: 0,
57
+ };
58
+ }
59
+ /** The CLI login policy last received from the server (null: none yet — login mode is off). */
60
+ cliLoginPolicy() {
61
+ return this.policy.current ? { ...this.policy.current } : null;
62
+ }
63
+ /**
64
+ * A policy from the server (auth_success, hello_ack, policy event). Only the two booleans are read; anything else in
65
+ * the message is ignored, so it can never change how this machine authenticates.
66
+ */
67
+ applyPolicy(raw) {
68
+ const next = (0, auth_1.readPolicy)(raw);
69
+ const prev = this.policy.current;
70
+ this.policy.current = next;
71
+ if (prev && prev['claude-code'] === next['claude-code'] && prev.codex === next.codex)
72
+ return;
73
+ this.opts.onPolicy?.({ ...next });
74
+ }
75
+ /** Ids of the adapters this daemon offers (hello.capabilities.adapters). */
76
+ adapterIds() {
77
+ return this.adapters.map((a) => a.id);
78
+ }
79
+ status() {
80
+ return { ...this.st, active: this.active.size, models: [...this.st.models] };
81
+ }
82
+ /** Connect (and keep reconnecting with backoff until stop() or a fatal auth error). */
83
+ start() {
84
+ this.stopped = false;
85
+ if (this.keepAliveTimer)
86
+ clearInterval(this.keepAliveTimer);
87
+ this.keepAliveTimer = setInterval(() => undefined, KEEP_ALIVE_MS); // NOT unref'd: reconnecting forever must not let the process exit
88
+ this.connect();
89
+ this.modelsTimer = setInterval(() => void this.refreshModels(), MODELS_REFRESH_MS);
90
+ this.modelsTimer.unref?.();
91
+ }
92
+ stop() {
93
+ this.stopped = true;
94
+ if (this.reconnectTimer)
95
+ clearTimeout(this.reconnectTimer);
96
+ if (this.heartbeatTimer)
97
+ clearInterval(this.heartbeatTimer);
98
+ if (this.modelsTimer)
99
+ clearInterval(this.modelsTimer);
100
+ if (this.keepAliveTimer)
101
+ clearInterval(this.keepAliveTimer);
102
+ this.reconnectTimer = this.heartbeatTimer = this.modelsTimer = this.keepAliveTimer = null;
103
+ for (const ctrl of this.active.values())
104
+ ctrl.abort();
105
+ this.active.clear();
106
+ this.socket?.removeAllListeners();
107
+ this.socket?.disconnect();
108
+ this.socket = null;
109
+ this.st.connected = false;
110
+ }
111
+ /** Apply local settings (pause switch, concurrency, background consent) and tell the server. */
112
+ applySettings(s) {
113
+ if (s.paused !== undefined)
114
+ this.st.paused = s.paused;
115
+ if (s.maxConcurrency !== undefined)
116
+ this.st.maxConcurrency = clamp(s.maxConcurrency);
117
+ if (s.allowBackground !== undefined)
118
+ this.st.allowBackground = s.allowBackground;
119
+ if (this.st.paused)
120
+ for (const ctrl of this.active.values())
121
+ ctrl.abort(); // pause = stop serving now
122
+ this.sendHello();
123
+ }
124
+ async refreshModels() {
125
+ const all = [];
126
+ for (const a of this.adapters) {
127
+ try {
128
+ all.push(...(await a.listModels()));
129
+ }
130
+ catch (err) {
131
+ this.opts.logger.warn(`${a.id}: ${err.message}`);
132
+ }
133
+ }
134
+ this.st.models = [...new Set(all)];
135
+ this.sendHello();
136
+ }
137
+ connect() {
138
+ if (this.stopped)
139
+ return;
140
+ const io = this.opts.io ?? socket_io_client_1.io;
141
+ const socket = io(`${this.opts.apiUrl}/cli`, {
142
+ transports: ['websocket'],
143
+ auth: { token: this.opts.token },
144
+ reconnection: false, // our own backoff (below), so a revoked device does not hammer the server
145
+ timeout: 20_000,
146
+ forceNew: true,
147
+ });
148
+ this.socket = socket;
149
+ socket.on('auth_success', (info) => {
150
+ if (info?.protocol !== protocol_1.PROTOCOL_VERSION) {
151
+ this.fatal(`This Resolflow server speaks protocol ${info?.protocol ?? 1}; this CLI speaks ${protocol_1.PROTOCOL_VERSION}. Update the CLI or the server.`);
152
+ return;
153
+ }
154
+ const reconnected = this.wasConnected || this.attempt > 0;
155
+ this.wasConnected = true;
156
+ this.attempt = 0;
157
+ this.st.reconnectAttempt = 0;
158
+ this.st.connected = true;
159
+ this.st.deviceId = info.deviceId ?? null;
160
+ this.st.workspaceId = info.workspaceId ?? null;
161
+ this.st.lastError = null;
162
+ this.opts.logger.info(`${reconnected ? 'Reconnected' : 'Connected'} to Resolflow as device ${info.deviceId}${this.st.paused ? ' (paused)' : ''}`);
163
+ this.applyPolicy(info.policy);
164
+ if (this.heartbeatTimer)
165
+ clearInterval(this.heartbeatTimer);
166
+ this.heartbeatTimer = setInterval(() => socket.connected && socket.emit('heartbeat'), Math.max(5_000, info.heartbeatMs ?? 20_000));
167
+ this.heartbeatTimer.unref?.();
168
+ void this.refreshModels();
169
+ });
170
+ socket.on('auth_error', (e) => {
171
+ this.fatal(e?.message || 'Authentication refused');
172
+ });
173
+ socket.on('job', (job) => void this.serve(job));
174
+ // RF-90e75d6d: the administrator changed the CLI login switch (or this hello was answered)
175
+ socket.on('policy', (m) => this.applyPolicy(m));
176
+ socket.on('hello_ack', (m) => {
177
+ if (m && typeof m === 'object' && 'policy' in m)
178
+ this.applyPolicy(m.policy);
179
+ });
180
+ socket.on('job_cancel', (m) => {
181
+ const ctrl = typeof m?.jobId === 'string' ? this.active.get(m.jobId) : undefined;
182
+ if (ctrl) {
183
+ ctrl.abort();
184
+ this.opts.logger.info(`Job ${short(m.jobId)} cancelled by Resolflow`);
185
+ }
186
+ });
187
+ socket.on('job_input', (m) => {
188
+ const h = typeof m?.jobId === 'string' ? this.inputHandlers.get(m.jobId) : undefined;
189
+ if (h && typeof m.questionId === 'string' && typeof m.answer === 'string')
190
+ h(m.questionId, m.answer);
191
+ });
192
+ socket.on('connect_error', (err) => {
193
+ this.st.lastError = err.message;
194
+ this.opts.logger.warn(`Cannot reach Resolflow (${err.message})`);
195
+ this.scheduleReconnect();
196
+ });
197
+ socket.on('disconnect', (reason) => {
198
+ this.st.connected = false;
199
+ if (this.heartbeatTimer)
200
+ clearInterval(this.heartbeatTimer);
201
+ // Requests in flight are failed by the server (this device went offline); stop the local work too
202
+ for (const ctrl of this.active.values())
203
+ ctrl.abort();
204
+ if (this.stopped)
205
+ return;
206
+ this.opts.logger.warn(`Disconnected (${reason})`);
207
+ this.scheduleReconnect();
208
+ });
209
+ }
210
+ scheduleReconnect() {
211
+ if (this.stopped || this.reconnectTimer)
212
+ return;
213
+ this.socket?.removeAllListeners();
214
+ this.socket?.disconnect();
215
+ this.socket = null;
216
+ const b = this.opts.backoff ?? { minMs: 1_000, maxMs: 60_000 };
217
+ const delay = backoffDelay(this.attempt++, b.minMs, b.maxMs);
218
+ this.st.reconnectAttempt = this.attempt;
219
+ this.opts.logger.info(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.attempt})`);
220
+ this.reconnectTimer = setTimeout(() => {
221
+ this.reconnectTimer = null;
222
+ this.connect();
223
+ }, delay); // ref'd: a pending reconnect keeps the process alive
224
+ }
225
+ fatal(message) {
226
+ this.st.lastError = message;
227
+ this.opts.logger.error(`Resolflow refused this device: ${message}`);
228
+ this.stop();
229
+ this.opts.onFatal?.(message);
230
+ }
231
+ sendHello() {
232
+ if (!this.socket?.connected)
233
+ return;
234
+ this.socket.emit('hello', {
235
+ models: this.st.models,
236
+ maxConcurrency: this.st.maxConcurrency,
237
+ paused: this.st.paused,
238
+ allowBackground: this.st.allowBackground,
239
+ capabilities: {
240
+ kinds: [...new Set(this.adapters.flatMap((a) => [...a.kinds]))],
241
+ adapters: this.adapters.map((a) => a.id),
242
+ // RF-90e75d6d: per coding agent, the mode NAME only (api_key | login) — never a path, a token or an env value
243
+ auth: Object.fromEntries(this.adapters.filter((a) => a.authMode).map((a) => [a.id, a.authMode])),
244
+ },
245
+ cliVersion: this.opts.cliVersion,
246
+ platform: this.opts.platform,
247
+ });
248
+ }
249
+ /** One job → its adapter → normalised job_event stream (exactly one terminal result | error). */
250
+ async serve(raw) {
251
+ const socket = this.socket;
252
+ if (!socket || !(0, protocol_1.isJob)(raw))
253
+ return;
254
+ const job = raw;
255
+ const jobId = job.jobId;
256
+ let seq = 0;
257
+ let terminal = null;
258
+ let errorKind;
259
+ let usage;
260
+ const send = (event) => {
261
+ if (terminal || !this.socket?.connected)
262
+ return;
263
+ if (event.type === 'result')
264
+ terminal = 'ok';
265
+ if (event.type === 'error') {
266
+ terminal = 'error';
267
+ errorKind = event.kind;
268
+ }
269
+ if (event.type === 'usage')
270
+ usage = event.usage;
271
+ socket.emit('job_event', { jobId, seq: seq++, event });
272
+ };
273
+ const model = job.kind === 'llm_request' ? String(job.payload.model ?? '') : job.adapter ?? job.kind;
274
+ if (this.st.paused)
275
+ return send({ type: 'error', kind: 'device_error', message: 'Device paused' });
276
+ if (this.active.size >= this.st.maxConcurrency)
277
+ return send({ type: 'error', kind: 'device_error', message: 'Device busy' });
278
+ const adapter = (0, types_1.pickAdapter)(this.adapters, job);
279
+ if (!adapter)
280
+ return send({ type: 'error', kind: 'unsupported', message: `This device cannot run ${job.kind}${job.adapter ? ` with ${job.adapter}` : ''}` });
281
+ const ctrl = new AbortController();
282
+ this.active.set(jobId, ctrl);
283
+ const t0 = Date.now();
284
+ this.opts.logger.info(`Job ${short(jobId)}: ${job.kind} via ${adapter.id}${model ? ` (${model})` : ''}`);
285
+ try {
286
+ await adapter.run(job, {
287
+ signal: ctrl.signal,
288
+ emit: (e) => {
289
+ if (!ctrl.signal.aborted)
290
+ send(e);
291
+ },
292
+ onInput: (h) => this.inputHandlers.set(jobId, h),
293
+ });
294
+ if (!ctrl.signal.aborted && !terminal)
295
+ send({ type: 'result' });
296
+ }
297
+ catch {
298
+ if (!ctrl.signal.aborted && !terminal)
299
+ send({ type: 'error', kind: 'device_error', message: 'Job failed on the device' });
300
+ }
301
+ finally {
302
+ this.active.delete(jobId);
303
+ this.inputHandlers.delete(jobId);
304
+ const status = ctrl.signal.aborted ? 'aborted' : terminal === 'ok' ? 'ok' : 'error';
305
+ if (status === 'ok')
306
+ this.st.served++;
307
+ else if (status === 'error')
308
+ this.st.failed++;
309
+ const ms = Date.now() - t0;
310
+ this.opts.logger.info(`Job ${short(jobId)}: ${status}${errorKind && status === 'error' ? ` (${errorKind})` : ''} in ${(ms / 1000).toFixed(1)}s`);
311
+ this.opts.runLog?.append({ at: new Date(t0).toISOString(), requestId: jobId, model, status, errorKind: status === 'error' ? errorKind : undefined, ms, ...(usage ?? {}) });
312
+ }
313
+ }
314
+ }
315
+ exports.Daemon = Daemon;
316
+ const clamp = (n) => Math.min(4, Math.max(1, Math.floor(Number(n)) || 1));
317
+ const short = (id) => id.slice(0, 8);
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const commands_1 = require("./commands");
6
+ const config_1 = require("./config");
7
+ const log_1 = require("./log");
8
+ const runtime_1 = require("./runtime");
9
+ const update_check_1 = require("./update-check");
10
+ (0, runtime_1.guardPipes)();
11
+ const program = new commander_1.Command();
12
+ program
13
+ .name('resolflow')
14
+ .description('Resolflow CLI (Beta): run your Resolflow agent profiles on local models (Ollama, LM Studio, vLLM) or local coding agents (Claude Code, Codex)')
15
+ .version(commands_1.CLI_VERSION)
16
+ .option('-v, --verbose', 'more logging (never prompts, answers or secrets)');
17
+ const ctx = () => (0, commands_1.makeCtx)({ verbose: program.opts().verbose });
18
+ const run = (fn) => async () => {
19
+ try {
20
+ await fn();
21
+ }
22
+ catch (err) {
23
+ process.stderr.write(`resolflow: ${(0, log_1.redact)(err.message)}\n`);
24
+ process.exitCode = err instanceof commands_1.RefusedError ? err.exitCode : 1;
25
+ }
26
+ };
27
+ program
28
+ .command('pair [code]')
29
+ .description('pair this machine with Resolflow using the code from Agents › Devices')
30
+ .option('--api <url>', 'Resolflow API URL (default https://api.resolflow.ai)')
31
+ .option('--name <name>', 'device name shown in Resolflow (default: hostname)')
32
+ .action((code, opts) => run(() => (0, commands_1.cmdPair)(ctx(), code, opts))());
33
+ program
34
+ .command('login')
35
+ .description('same as pair')
36
+ .option('--pair <code>', 'pairing code')
37
+ .option('--api <url>', 'Resolflow API URL')
38
+ .option('--name <name>', 'device name')
39
+ .action((opts) => run(() => (0, commands_1.cmdPair)(ctx(), opts.pair, opts))());
40
+ program
41
+ .command('start')
42
+ .description('connect to Resolflow and serve requests with your local models (Ctrl+C to stop)')
43
+ .option('--endpoint <url>', 'local OpenAI-compatible server (default http://127.0.0.1:11434/v1)')
44
+ .option('--concurrency <n>', 'requests at the same time, 1-4')
45
+ .option('--allow-background', 'this machine may serve background runs (also needs the per-profile opt-in)')
46
+ .option('--ui', 'local status page on 127.0.0.1 (random token, this session only)')
47
+ .option('--ui-port <port>', 'port for --ui (default random)')
48
+ .action((opts) => run(async () => {
49
+ const stop = await (0, commands_1.cmdStart)(ctx(), opts);
50
+ const shutdown = () => void stop().then(() => process.exit(process.exitCode ?? 0));
51
+ process.once('SIGINT', shutdown);
52
+ process.once('SIGTERM', shutdown);
53
+ })());
54
+ program.command('status').description('pairing, connection and local models').action(() => run(() => (0, commands_1.cmdStatus)(ctx()))());
55
+ program
56
+ .command('models')
57
+ .description('list the models of the local server')
58
+ .option('--endpoint <url>', 'local OpenAI-compatible server')
59
+ .action((opts) => run(() => (0, commands_1.cmdModels)(ctx(), opts))());
60
+ program.command('unpair').description('revoke this device and remove its token').action(() => run(() => (0, commands_1.cmdUnpair)(ctx()))());
61
+ program.command('pause').description('stop serving requests (kill switch)').action(() => run(() => (0, commands_1.cmdPause)(ctx(), true))());
62
+ program.command('resume').description('serve requests again').action(() => run(() => (0, commands_1.cmdPause)(ctx(), false))());
63
+ program
64
+ .command('agents')
65
+ .description('local coding agents (Claude Code, Codex, Antigravity): installed, version, auth (API key or CLI login), offered')
66
+ .action(() => run(() => (0, commands_1.cmdAgents)(ctx()))());
67
+ program
68
+ .command('config [key] [value]')
69
+ .description('show settings, or set endpoint | concurrency (1-4) | background (on|off) | api | agent-turns (1-20) | agent-budget (USD) | agent-timeout (min) | agent-daily-budget (USD per day, 0 = no limit); ' +
70
+ 'agent-key <claude|codex> stores a coding agent API key in the keychain (prompted, never printed); ' +
71
+ 'agent-auth <claude|codex> <api_key|login> chooses an API key or your existing CLI login for that tool on this machine')
72
+ .argument('[mode]', 'with agent-auth: api_key | login')
73
+ .option('--delete', 'with agent-key: remove the stored key')
74
+ .action((key, value, mode, opts) => run(() => key === 'agent-key'
75
+ ? (0, commands_1.cmdAgentKey)(ctx(), value, opts)
76
+ : key === 'agent-auth'
77
+ ? (0, commands_1.cmdAgentAuth)(ctx(), value, mode)
78
+ : key === 'set' // `resolflow config set <key> <value>` = `resolflow config <key> <value>`
79
+ ? (0, commands_1.cmdConfig)(ctx(), value, mode)
80
+ : (0, commands_1.cmdConfig)(ctx(), key, value))());
81
+ // Non-blocking update hint (at most one registry request a day, cached; RESOLFLOW_NO_UPDATE_CHECK=1 turns it off)
82
+ // (not for --version / --help: commander exits right away, the check could neither finish nor be cached)
83
+ const updateCheck = (0, runtime_1.skipUpdateCheck)() ? null : (0, update_check_1.startUpdateCheck)({ current: commands_1.CLI_VERSION, dir: (0, config_1.configDir)() });
84
+ void program.parseAsync(process.argv).then(async () => {
85
+ const hint = await updateCheck?.finish();
86
+ if (hint)
87
+ process.stderr.write(`\n${hint}\n`);
88
+ });
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocalModelError = void 0;
4
+ exports.buildChatBody = buildChatBody;
5
+ exports.listModels = listModels;
6
+ exports.parseChatSse = parseChatSse;
7
+ exports.streamChat = streamChat;
8
+ const urls_1 = require("./urls");
9
+ class LocalModelError extends Error {
10
+ kind;
11
+ constructor(kind, message) {
12
+ super(message);
13
+ this.kind = kind;
14
+ }
15
+ }
16
+ exports.LocalModelError = LocalModelError;
17
+ const ROLES = new Set(['system', 'user', 'assistant']);
18
+ function buildChatBody(req) {
19
+ const messages = [];
20
+ if (req.systemPrompt)
21
+ messages.push({ role: 'system', content: req.systemPrompt });
22
+ for (const m of req.messages ?? []) {
23
+ messages.push({ role: ROLES.has(m.role) ? m.role : 'user', content: String(m.content ?? '') });
24
+ }
25
+ return {
26
+ model: req.model,
27
+ messages,
28
+ stream: true,
29
+ stream_options: { include_usage: true },
30
+ ...(typeof req.temperature === 'number' ? { temperature: req.temperature } : {}),
31
+ ...(typeof req.maxTokens === 'number' && req.maxTokens > 0 ? { max_tokens: Math.floor(req.maxTokens) } : {}),
32
+ ...(req.responseFormat === 'json' ? { response_format: { type: 'json_object' } } : {}),
33
+ };
34
+ }
35
+ function connectionError(err) {
36
+ const e = err;
37
+ if (e?.name === 'AbortError')
38
+ return new LocalModelError('aborted', 'Cancelled');
39
+ const code = e?.cause?.code;
40
+ return new LocalModelError('endpoint_unreachable', `Local model server not reachable${code ? ` (${code})` : ''}`);
41
+ }
42
+ async function errorFromResponse(res) {
43
+ let detail = '';
44
+ try {
45
+ const text = (await res.text()).slice(0, 2000);
46
+ const j = JSON.parse(text);
47
+ detail = String(j?.error?.message ?? j?.error ?? j?.message ?? '').slice(0, 200);
48
+ }
49
+ catch {
50
+ /* not JSON */
51
+ }
52
+ if (res.status === 404 || /model .*not found|not found.*model|no such model/i.test(detail)) {
53
+ return new LocalModelError('model_not_found', detail || 'Model not found on the local server');
54
+ }
55
+ return new LocalModelError('device_error', `Local model server answered ${res.status}${detail ? `: ${detail}` : ''}`);
56
+ }
57
+ async function listModels(endpoint, timeoutMs = 5000) {
58
+ const base = (0, urls_1.normaliseLocalEndpoint)(endpoint);
59
+ let res;
60
+ try {
61
+ res = await fetch(`${base}/models`, { redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
62
+ }
63
+ catch (err) {
64
+ throw connectionError(err);
65
+ }
66
+ if (!res.ok)
67
+ throw await errorFromResponse(res);
68
+ const body = (await res.json());
69
+ return (body.data ?? [])
70
+ .map((m) => m.id)
71
+ .filter((id) => typeof id === 'string' && id.length > 0 && id.length <= 200)
72
+ .slice(0, 200);
73
+ }
74
+ /** Parse an SSE stream of Chat Completions chunks into deltas. */
75
+ async function* parseChatSse(body) {
76
+ const reader = body.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buf = '';
79
+ try {
80
+ for (;;) {
81
+ const { value, done } = await reader.read();
82
+ if (done)
83
+ break;
84
+ buf += decoder.decode(value, { stream: true });
85
+ let nl;
86
+ while ((nl = buf.indexOf('\n')) >= 0) {
87
+ const line = buf.slice(0, nl).replace(/\r$/, '');
88
+ buf = buf.slice(nl + 1);
89
+ if (!line.startsWith('data:'))
90
+ continue;
91
+ const data = line.slice(5).trim();
92
+ if (!data || data === '[DONE]')
93
+ continue;
94
+ let j;
95
+ try {
96
+ j = JSON.parse(data);
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ if (j?.error)
102
+ throw new LocalModelError('device_error', String(j.error?.message ?? j.error).slice(0, 200));
103
+ const delta = j?.choices?.[0]?.delta ?? {};
104
+ const out = {};
105
+ if (typeof delta.content === 'string' && delta.content)
106
+ out.content = delta.content;
107
+ if ((typeof delta.reasoning === 'string' && delta.reasoning) || (typeof delta.reasoning_content === 'string' && delta.reasoning_content))
108
+ out.reasoning = true;
109
+ if (j?.usage && (j.usage.prompt_tokens != null || j.usage.completion_tokens != null)) {
110
+ out.usage = { promptTokens: Number(j.usage.prompt_tokens) || 0, completionTokens: Number(j.usage.completion_tokens) || 0 };
111
+ }
112
+ if (out.content || out.reasoning || out.usage)
113
+ yield out;
114
+ }
115
+ }
116
+ }
117
+ finally {
118
+ reader.releaseLock();
119
+ }
120
+ }
121
+ /** Stream one chat request from the local server. The signal cancels the HTTP request (the model stops). */
122
+ async function* streamChat(endpoint, req, signal) {
123
+ const base = (0, urls_1.normaliseLocalEndpoint)(endpoint);
124
+ let res;
125
+ try {
126
+ res = await fetch(`${base}/chat/completions`, {
127
+ method: 'POST',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify(buildChatBody(req)),
130
+ redirect: 'error',
131
+ signal,
132
+ });
133
+ }
134
+ catch (err) {
135
+ throw connectionError(err);
136
+ }
137
+ if (!res.ok || !res.body)
138
+ throw await errorFromResponse(res);
139
+ try {
140
+ yield* parseChatSse(res.body);
141
+ }
142
+ catch (err) {
143
+ if (err instanceof LocalModelError)
144
+ throw err;
145
+ throw signal.aborted ? new LocalModelError('aborted', 'Cancelled') : connectionError(err);
146
+ }
147
+ }
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.startLocalUi = startLocalUi;
37
+ const crypto = __importStar(require("crypto"));
38
+ const http = __importStar(require("http"));
39
+ const SECURITY_HEADERS = {
40
+ 'cache-control': 'no-store',
41
+ 'x-content-type-options': 'nosniff',
42
+ 'x-frame-options': 'DENY',
43
+ 'referrer-policy': 'no-referrer',
44
+ 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
45
+ };
46
+ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Resolflow CLI</title>
47
+ <style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:44rem;color:#222}pre{background:#f4f4f5;padding:1rem;border-radius:6px;overflow:auto}
48
+ button{padding:.4rem .9rem;margin-right:.5rem}</style></head><body>
49
+ <h1>Resolflow CLI</h1><p id="msg">Loading…</p>
50
+ <p><button id="pause">Pause serving</button><button id="resume">Resume</button></p>
51
+ <pre id="out"></pre><script src="/app.js"></script></body></html>`;
52
+ const APP_JS = `(function(){
53
+ var m=location.hash.match(/token=([0-9a-f]+)/);var t=m?m[1]:'';history.replaceState(null,'',location.pathname);
54
+ function call(method,path){return fetch(path,{method:method,headers:{'X-Resolflow-Token':t,'Content-Type':'application/json'},body:method==='POST'?'{}':undefined}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();});}
55
+ function load(){call('GET','/api/status').then(function(s){document.getElementById('msg').textContent=s.connected?(s.paused?'Connected, paused':'Connected, serving'):'Not connected';document.getElementById('out').textContent=JSON.stringify(s,null,2);}).catch(function(e){document.getElementById('msg').textContent='Not authorised ('+e.message+'). Open the URL printed by resolflow start.';});}
56
+ document.getElementById('pause').onclick=function(){call('POST','/api/pause').then(load);};
57
+ document.getElementById('resume').onclick=function(){call('POST','/api/resume').then(load);};
58
+ load();setInterval(load,3000);})();`;
59
+ function safeEqual(a, b) {
60
+ const x = Buffer.from(a);
61
+ const y = Buffer.from(b);
62
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
63
+ }
64
+ function startLocalUi(state, opts = {}) {
65
+ const token = opts.token ?? crypto.randomBytes(32).toString('hex');
66
+ let port = 0;
67
+ const allowedHosts = () => new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
68
+ const allowedOrigins = () => new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
69
+ const send = (res, status, body, type = 'application/json') => {
70
+ res.writeHead(status, { ...SECURITY_HEADERS, 'content-type': `${type}; charset=utf-8` });
71
+ res.end(body);
72
+ };
73
+ const server = http.createServer((req, res) => {
74
+ const host = String(req.headers.host ?? '').toLowerCase();
75
+ if (!allowedHosts().has(host))
76
+ return send(res, 421, '{"error":"wrong host"}');
77
+ const origin = req.headers.origin;
78
+ if (origin !== undefined && !allowedOrigins().has(String(origin)))
79
+ return send(res, 403, '{"error":"origin not allowed"}');
80
+ if (req.method === 'OPTIONS')
81
+ return send(res, 405, '{"error":"no CORS"}');
82
+ const url = (req.url ?? '/').split('?')[0];
83
+ if (req.method === 'GET' && url === '/')
84
+ return send(res, 200, PAGE, 'text/html');
85
+ if (req.method === 'GET' && url === '/app.js')
86
+ return send(res, 200, APP_JS, 'text/javascript');
87
+ if (!url.startsWith('/api/'))
88
+ return send(res, 404, '{"error":"not found"}');
89
+ const given = String(req.headers['x-resolflow-token'] ?? '');
90
+ if (!given || !safeEqual(given, token))
91
+ return send(res, 401, '{"error":"token required"}');
92
+ if (req.method === 'GET' && url === '/api/status')
93
+ return send(res, 200, JSON.stringify(state.status()));
94
+ if (req.method === 'POST' && (url === '/api/pause' || url === '/api/resume')) {
95
+ // State changes: same-origin page only (browsers always send Origin on POST) and a JSON body type
96
+ if (origin === undefined)
97
+ return send(res, 403, '{"error":"origin required"}');
98
+ if (!String(req.headers['content-type'] ?? '').startsWith('application/json'))
99
+ return send(res, 415, '{"error":"json only"}');
100
+ req.resume();
101
+ state.setPaused(url === '/api/pause');
102
+ return send(res, 200, JSON.stringify({ paused: url === '/api/pause' }));
103
+ }
104
+ return send(res, 405, '{"error":"method not allowed"}');
105
+ });
106
+ server.headersTimeout = 10_000;
107
+ server.requestTimeout = 10_000;
108
+ return new Promise((resolve, reject) => {
109
+ server.once('error', reject);
110
+ server.listen(opts.port ?? 0, '127.0.0.1', () => {
111
+ port = server.address().port;
112
+ resolve({
113
+ url: `http://127.0.0.1:${port}/#token=${token}`,
114
+ port,
115
+ token,
116
+ close: () => new Promise((r) => server.close(() => r())),
117
+ });
118
+ });
119
+ });
120
+ }