conductor-remote 1.81.0 → 1.83.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/README.md +15 -3
- package/dist/assets/index-CZNedm5p.js +47 -0
- package/dist/assets/index-D4APjdqV.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor-settings.js +177 -0
- package/dist-node/src/mcp-tools.js +22 -1
- package/dist-node/src/plan-usage.js +430 -0
- package/dist-node/src/routes.js +6 -0
- package/dist-node/src/server.js +54 -9
- package/dist-node/src/transcript.js +15 -0
- package/package.json +1 -1
- package/dist/assets/index-DBkjooUE.js +0 -47
- package/dist/assets/index-r0GKAzkH.css +0 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function object(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
function text(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
10
|
+
}
|
|
11
|
+
function number(value) {
|
|
12
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function percent(value) {
|
|
15
|
+
const parsed = number(value);
|
|
16
|
+
return parsed === null ? null : Math.max(0, Math.min(100, parsed));
|
|
17
|
+
}
|
|
18
|
+
function timestamp(value) {
|
|
19
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
20
|
+
// Codex reports seconds; tolerate milliseconds if the protocol changes.
|
|
21
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
const parsed = Date.parse(value);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
27
|
+
}
|
|
28
|
+
function codexWindowLabel(duration, slot) {
|
|
29
|
+
if (duration === 300)
|
|
30
|
+
return '5-hour limit';
|
|
31
|
+
if (duration === 1_440)
|
|
32
|
+
return 'Daily limit';
|
|
33
|
+
if (duration === 10_080)
|
|
34
|
+
return 'Weekly limit';
|
|
35
|
+
if (duration && duration % 1_440 === 0)
|
|
36
|
+
return `${duration / 1_440}-day limit`;
|
|
37
|
+
if (duration && duration % 60 === 0)
|
|
38
|
+
return `${duration / 60}-hour limit`;
|
|
39
|
+
return slot === 'primary' ? 'Primary limit' : 'Secondary limit';
|
|
40
|
+
}
|
|
41
|
+
function codexWindow(bucketId, slot, raw) {
|
|
42
|
+
const value = object(raw);
|
|
43
|
+
const usedPercent = percent(value?.usedPercent);
|
|
44
|
+
if (!value || usedPercent === null)
|
|
45
|
+
return null;
|
|
46
|
+
const duration = number(value.windowDurationMins);
|
|
47
|
+
return {
|
|
48
|
+
id: `${bucketId}:${slot}`,
|
|
49
|
+
label: codexWindowLabel(duration, slot),
|
|
50
|
+
usedPercent,
|
|
51
|
+
resetsAt: timestamp(value.resetsAt),
|
|
52
|
+
windowDurationMins: duration
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Reduce Codex's app-server response to the stable, provider-neutral wire shape. */
|
|
56
|
+
export function parseCodexPlanUsage(raw) {
|
|
57
|
+
const envelope = object(raw);
|
|
58
|
+
const payload = object(envelope?.result) ?? envelope;
|
|
59
|
+
const legacy = object(payload?.rateLimits);
|
|
60
|
+
const byLimit = object(payload?.rateLimitsByLimitId);
|
|
61
|
+
const entries = byLimit && Object.keys(byLimit).length ? Object.entries(byLimit) : legacy ? [['codex', legacy]] : [];
|
|
62
|
+
const buckets = [];
|
|
63
|
+
let plan = null;
|
|
64
|
+
for (const [key, candidate] of entries) {
|
|
65
|
+
const snapshot = object(candidate);
|
|
66
|
+
if (!snapshot)
|
|
67
|
+
continue;
|
|
68
|
+
plan ??= text(snapshot.planType);
|
|
69
|
+
const id = text(snapshot.limitId) ?? key;
|
|
70
|
+
const windows = [
|
|
71
|
+
codexWindow(id, 'primary', snapshot.primary),
|
|
72
|
+
codexWindow(id, 'secondary', snapshot.secondary)
|
|
73
|
+
].filter((window) => window !== null);
|
|
74
|
+
if (!windows.length)
|
|
75
|
+
continue;
|
|
76
|
+
buckets.push({ id, label: text(snapshot.limitName) ?? (id === 'codex' ? 'Codex' : id), windows });
|
|
77
|
+
}
|
|
78
|
+
if (!buckets.length) {
|
|
79
|
+
return {
|
|
80
|
+
provider: 'codex',
|
|
81
|
+
label: 'Codex',
|
|
82
|
+
status: 'unavailable',
|
|
83
|
+
plan,
|
|
84
|
+
buckets: [],
|
|
85
|
+
message: 'Codex returned no rolling plan limits for this account.'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
buckets.sort((a, b) => Number(b.id === 'codex') - Number(a.id === 'codex') || a.label.localeCompare(b.label));
|
|
89
|
+
return { provider: 'codex', label: 'Codex', status: 'available', plan, buckets };
|
|
90
|
+
}
|
|
91
|
+
function claudeWindowLabel(limit) {
|
|
92
|
+
const kind = text(limit.kind);
|
|
93
|
+
const model = text(object(object(limit.scope)?.model)?.display_name);
|
|
94
|
+
if (kind === 'session')
|
|
95
|
+
return 'Current session';
|
|
96
|
+
if (kind === 'weekly_all')
|
|
97
|
+
return 'Current week';
|
|
98
|
+
if (kind === 'weekly_scoped' && model)
|
|
99
|
+
return `Current week (${model})`;
|
|
100
|
+
if (model)
|
|
101
|
+
return model;
|
|
102
|
+
return kind?.replaceAll('_', ' ') ?? 'Plan limit';
|
|
103
|
+
}
|
|
104
|
+
function claudeWindow(id, label, raw, active) {
|
|
105
|
+
const value = object(raw);
|
|
106
|
+
const usedPercent = percent(value?.utilization ?? value?.percent);
|
|
107
|
+
if (!value || usedPercent === null)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
label,
|
|
112
|
+
usedPercent,
|
|
113
|
+
resetsAt: timestamp(value.resets_at),
|
|
114
|
+
...(active === undefined ? {} : { active })
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Reduce Claude's experimental `get_usage` control response; tolerate its older named-window shape. */
|
|
118
|
+
export function parseClaudePlanUsage(raw) {
|
|
119
|
+
const envelope = object(raw);
|
|
120
|
+
const control = object(envelope?.response);
|
|
121
|
+
const payload = object(control?.response) ?? envelope;
|
|
122
|
+
const plan = text(payload?.subscription_type);
|
|
123
|
+
if (payload?.rate_limits_available === false) {
|
|
124
|
+
return {
|
|
125
|
+
provider: 'claude',
|
|
126
|
+
label: 'Claude Code',
|
|
127
|
+
status: 'unavailable',
|
|
128
|
+
plan,
|
|
129
|
+
buckets: [],
|
|
130
|
+
message: 'Plan limits are not available for API-key or third-party-provider sessions.'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const rateLimits = object(payload?.rate_limits);
|
|
134
|
+
const windows = [];
|
|
135
|
+
const limits = Array.isArray(rateLimits?.limits) ? rateLimits.limits : [];
|
|
136
|
+
for (const [index, candidate] of limits.entries()) {
|
|
137
|
+
const limit = object(candidate);
|
|
138
|
+
if (!limit)
|
|
139
|
+
continue;
|
|
140
|
+
const kind = text(limit.kind);
|
|
141
|
+
// These are the rolling plan allowances. Spend/credit records have a different
|
|
142
|
+
// unit and belong in a future money-shaped control rather than a percentage bar.
|
|
143
|
+
if (!kind || !['session', 'weekly_all', 'weekly_scoped'].includes(kind))
|
|
144
|
+
continue;
|
|
145
|
+
const model = text(object(object(limit.scope)?.model)?.display_name);
|
|
146
|
+
const parsed = claudeWindow(`claude:${kind}:${model ?? index}`, claudeWindowLabel(limit), limit, limit.is_active === true);
|
|
147
|
+
if (parsed)
|
|
148
|
+
windows.push(parsed);
|
|
149
|
+
}
|
|
150
|
+
// Claude 2.1 first exposed the same data as named fields. Keep this fallback so an
|
|
151
|
+
// app update that removes the additive `limits` array does not blank the whole card.
|
|
152
|
+
if (!windows.length && rateLimits) {
|
|
153
|
+
const named = [
|
|
154
|
+
['five_hour', 'Current session'],
|
|
155
|
+
['seven_day', 'Current week'],
|
|
156
|
+
['seven_day_opus', 'Current week (Opus)'],
|
|
157
|
+
['seven_day_sonnet', 'Current week (Sonnet)']
|
|
158
|
+
];
|
|
159
|
+
for (const [key, label] of named) {
|
|
160
|
+
const parsed = claudeWindow(`claude:${key}`, label, rateLimits[key]);
|
|
161
|
+
if (parsed)
|
|
162
|
+
windows.push(parsed);
|
|
163
|
+
}
|
|
164
|
+
const scoped = Array.isArray(rateLimits.model_scoped) ? rateLimits.model_scoped : [];
|
|
165
|
+
for (const [index, candidate] of scoped.entries()) {
|
|
166
|
+
const value = object(candidate);
|
|
167
|
+
const model = text(value?.display_name);
|
|
168
|
+
const parsed = claudeWindow(`claude:model:${model ?? index}:${index}`, `Current week (${model ?? 'model'})`, value);
|
|
169
|
+
if (parsed)
|
|
170
|
+
windows.push(parsed);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!windows.length) {
|
|
174
|
+
return {
|
|
175
|
+
provider: 'claude',
|
|
176
|
+
label: 'Claude Code',
|
|
177
|
+
status: 'unavailable',
|
|
178
|
+
plan,
|
|
179
|
+
buckets: [],
|
|
180
|
+
message: 'Claude Code returned no rolling plan limits for this account.'
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
provider: 'claude',
|
|
185
|
+
label: 'Claude Code',
|
|
186
|
+
status: 'available',
|
|
187
|
+
plan,
|
|
188
|
+
buckets: [{ id: 'claude', label: 'Claude Code', windows }]
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const BUNDLED_BINARIES = path.join(os.homedir(), 'Library', 'Application Support', 'com.conductor.app', 'agent-binaries');
|
|
192
|
+
/** Prefer the CLI Conductor runs, falling back to the user's PATH for older app installs. */
|
|
193
|
+
function agentBinary(provider) {
|
|
194
|
+
const root = path.join(BUNDLED_BINARIES, provider);
|
|
195
|
+
try {
|
|
196
|
+
const versions = fs
|
|
197
|
+
.readdirSync(root, { withFileTypes: true })
|
|
198
|
+
.filter(entry => entry.isDirectory())
|
|
199
|
+
.map(entry => entry.name)
|
|
200
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
201
|
+
for (const version of versions) {
|
|
202
|
+
const candidate = path.join(root, version, provider);
|
|
203
|
+
try {
|
|
204
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
205
|
+
return candidate;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// A half-downloaded version is not a CLI; try the next one.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Conductor did not bundle this harness yet; spawn can still resolve the user's CLI.
|
|
214
|
+
}
|
|
215
|
+
return provider;
|
|
216
|
+
}
|
|
217
|
+
const MAX_CLI_OUTPUT = 4 * 1024 * 1024;
|
|
218
|
+
function failureMessage(name, stderr, code) {
|
|
219
|
+
const detail = stderr.trim().slice(-2_000);
|
|
220
|
+
return new Error(`${name} exited before returning usage${code === null ? '' : ` (${code})`}${detail ? `: ${detail}` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
function codexResponse(binary = agentBinary('codex')) {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
const child = spawn(binary, ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
225
|
+
let stdout = '';
|
|
226
|
+
let stderr = '';
|
|
227
|
+
let requested = false;
|
|
228
|
+
let settled = false;
|
|
229
|
+
const finish = (error, result) => {
|
|
230
|
+
if (settled)
|
|
231
|
+
return;
|
|
232
|
+
settled = true;
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
child.kill();
|
|
235
|
+
if (error)
|
|
236
|
+
reject(error);
|
|
237
|
+
else
|
|
238
|
+
resolve(result);
|
|
239
|
+
};
|
|
240
|
+
const timer = setTimeout(() => finish(new Error('Codex plan-usage read timed out')), 8_000);
|
|
241
|
+
child.stdout.setEncoding('utf8');
|
|
242
|
+
child.stderr.setEncoding('utf8');
|
|
243
|
+
child.stderr.on('data', chunk => {
|
|
244
|
+
stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
|
|
245
|
+
});
|
|
246
|
+
child.stdout.on('data', chunk => {
|
|
247
|
+
stdout += chunk;
|
|
248
|
+
if (stdout.length > MAX_CLI_OUTPUT)
|
|
249
|
+
return finish(new Error('Codex plan-usage response was too large'));
|
|
250
|
+
let newline = stdout.indexOf('\n');
|
|
251
|
+
while (newline >= 0) {
|
|
252
|
+
const line = stdout.slice(0, newline).trim();
|
|
253
|
+
stdout = stdout.slice(newline + 1);
|
|
254
|
+
newline = stdout.indexOf('\n');
|
|
255
|
+
if (!line)
|
|
256
|
+
continue;
|
|
257
|
+
let message;
|
|
258
|
+
try {
|
|
259
|
+
message = JSON.parse(line);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (message.id === 1 && !requested) {
|
|
265
|
+
requested = true;
|
|
266
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
|
|
267
|
+
child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: null })}\n`);
|
|
268
|
+
}
|
|
269
|
+
else if (message.id === 2) {
|
|
270
|
+
if (message.error)
|
|
271
|
+
return finish(new Error('Codex rejected the plan-usage request'));
|
|
272
|
+
return finish(null, message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
child.on('error', error => finish(error));
|
|
277
|
+
child.on('close', code => finish(failureMessage('Codex', stderr, code)));
|
|
278
|
+
child.stdin.on('error', error => finish(error));
|
|
279
|
+
child.stdin.write(`${JSON.stringify({
|
|
280
|
+
id: 1,
|
|
281
|
+
method: 'initialize',
|
|
282
|
+
params: { clientInfo: { name: 'conductor-remote', version: '1' } }
|
|
283
|
+
})}\n`);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function claudeResponse(binary = agentBinary('claude')) {
|
|
287
|
+
return new Promise((resolve, reject) => {
|
|
288
|
+
const child = spawn(binary, [
|
|
289
|
+
'-p',
|
|
290
|
+
'--input-format',
|
|
291
|
+
'stream-json',
|
|
292
|
+
'--output-format',
|
|
293
|
+
'stream-json',
|
|
294
|
+
'--verbose',
|
|
295
|
+
'--no-session-persistence',
|
|
296
|
+
'--safe-mode'
|
|
297
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
298
|
+
let stdout = '';
|
|
299
|
+
let stderr = '';
|
|
300
|
+
let settled = false;
|
|
301
|
+
const finish = (error, result) => {
|
|
302
|
+
if (settled)
|
|
303
|
+
return;
|
|
304
|
+
settled = true;
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
child.kill();
|
|
307
|
+
if (error)
|
|
308
|
+
reject(error);
|
|
309
|
+
else
|
|
310
|
+
resolve(result);
|
|
311
|
+
};
|
|
312
|
+
const timer = setTimeout(() => finish(new Error('Claude plan-usage read timed out')), 10_000);
|
|
313
|
+
child.stdout.setEncoding('utf8');
|
|
314
|
+
child.stderr.setEncoding('utf8');
|
|
315
|
+
child.stderr.on('data', chunk => {
|
|
316
|
+
stderr = (stderr + chunk).slice(-MAX_CLI_OUTPUT);
|
|
317
|
+
});
|
|
318
|
+
child.stdout.on('data', chunk => {
|
|
319
|
+
stdout += chunk;
|
|
320
|
+
if (stdout.length > MAX_CLI_OUTPUT)
|
|
321
|
+
return finish(new Error('Claude plan-usage response was too large'));
|
|
322
|
+
let newline = stdout.indexOf('\n');
|
|
323
|
+
while (newline >= 0) {
|
|
324
|
+
const line = stdout.slice(0, newline).trim();
|
|
325
|
+
stdout = stdout.slice(newline + 1);
|
|
326
|
+
newline = stdout.indexOf('\n');
|
|
327
|
+
if (!line)
|
|
328
|
+
continue;
|
|
329
|
+
let message;
|
|
330
|
+
try {
|
|
331
|
+
message = JSON.parse(line);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const response = object(message.response);
|
|
337
|
+
if (message.type !== 'control_response' || response?.request_id !== 'plan-usage')
|
|
338
|
+
continue;
|
|
339
|
+
if (response.subtype !== 'success')
|
|
340
|
+
return finish(new Error('Claude rejected the structured plan-usage request'));
|
|
341
|
+
return finish(null, message);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
child.on('error', error => finish(error));
|
|
345
|
+
child.on('close', code => finish(failureMessage('Claude', stderr, code)));
|
|
346
|
+
child.stdin.on('error', error => finish(error));
|
|
347
|
+
child.stdin.end(`${JSON.stringify({
|
|
348
|
+
type: 'control_request',
|
|
349
|
+
request_id: 'plan-usage',
|
|
350
|
+
request: { subtype: 'get_usage' }
|
|
351
|
+
})}\n`);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function unavailable(provider, label, message) {
|
|
355
|
+
return { provider, label, status: 'unavailable', plan: null, buckets: [], message };
|
|
356
|
+
}
|
|
357
|
+
function failed(provider, label, error) {
|
|
358
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
359
|
+
console.warn(`[relay] ${label} plan usage failed: ${detail}`);
|
|
360
|
+
const missing = error?.code === 'ENOENT';
|
|
361
|
+
return {
|
|
362
|
+
provider,
|
|
363
|
+
label,
|
|
364
|
+
status: missing ? 'unavailable' : 'error',
|
|
365
|
+
plan: null,
|
|
366
|
+
buckets: [],
|
|
367
|
+
message: missing ? `${label} is not installed on this Mac.` : `Could not read plan usage from ${label}.`
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export async function readCodexPlanUsage() {
|
|
371
|
+
try {
|
|
372
|
+
return parseCodexPlanUsage(await codexResponse());
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return failed('codex', 'Codex', error);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
export async function readClaudePlanUsage() {
|
|
379
|
+
try {
|
|
380
|
+
return parseClaudePlanUsage(await claudeResponse());
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
return failed('claude', 'Claude Code', error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const DEFAULT_READERS = [
|
|
387
|
+
{ provider: 'claude', label: 'Claude Code', read: readClaudePlanUsage },
|
|
388
|
+
{ provider: 'codex', label: 'Codex', read: readCodexPlanUsage }
|
|
389
|
+
];
|
|
390
|
+
const UNSUPPORTED = [
|
|
391
|
+
unavailable('cursor', 'Cursor Agent', 'Cursor Agent does not expose plan limits through its CLI.'),
|
|
392
|
+
unavailable('opencode', 'OpenCode', 'OpenCode reports local token and cost totals, not provider plan limits.')
|
|
393
|
+
];
|
|
394
|
+
/** Coalesced provider reads. `/api/usage` may be opened from several phones at once. */
|
|
395
|
+
export class PlanUsageService {
|
|
396
|
+
readers;
|
|
397
|
+
cacheMs;
|
|
398
|
+
now;
|
|
399
|
+
cached = null;
|
|
400
|
+
inFlight = null;
|
|
401
|
+
constructor(options = {}) {
|
|
402
|
+
this.readers = options.readers ?? DEFAULT_READERS;
|
|
403
|
+
this.cacheMs = options.cacheMs ?? 60_000;
|
|
404
|
+
this.now = options.now ?? Date.now;
|
|
405
|
+
}
|
|
406
|
+
read(force = false) {
|
|
407
|
+
if (!force && this.cached && this.now() - this.cached.fetchedAt < this.cacheMs)
|
|
408
|
+
return Promise.resolve(this.cached);
|
|
409
|
+
if (this.inFlight)
|
|
410
|
+
return this.inFlight;
|
|
411
|
+
const pending = Promise.all(this.readers.map(async (reader) => {
|
|
412
|
+
try {
|
|
413
|
+
return await reader.read();
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return failed(reader.provider, reader.label, error);
|
|
417
|
+
}
|
|
418
|
+
})).then(providers => {
|
|
419
|
+
const snapshot = { providers: [...providers, ...UNSUPPORTED], fetchedAt: this.now() };
|
|
420
|
+
this.cached = snapshot;
|
|
421
|
+
return snapshot;
|
|
422
|
+
});
|
|
423
|
+
this.inFlight = pending;
|
|
424
|
+
void pending.finally(() => {
|
|
425
|
+
if (this.inFlight === pending)
|
|
426
|
+
this.inFlight = null;
|
|
427
|
+
});
|
|
428
|
+
return pending;
|
|
429
|
+
}
|
|
430
|
+
}
|
package/dist-node/src/routes.js
CHANGED
|
@@ -46,6 +46,12 @@ export const routes = {
|
|
|
46
46
|
repos: flat('GET', '/api/repos'),
|
|
47
47
|
/** Picker labels the relay has previously read from Conductor, grouped by harness. */
|
|
48
48
|
modelCatalog: flat('GET', '/api/models'),
|
|
49
|
+
/** Provider-specific defaults from Conductor's user settings TOML. */
|
|
50
|
+
modelDefaults: flat('GET', '/api/models/defaults'),
|
|
51
|
+
/** Update one or both provider defaults in Conductor's user settings TOML. */
|
|
52
|
+
updateModelDefaults: flat('PATCH', '/api/models/defaults'),
|
|
53
|
+
/** Rolling subscription limits, read without a model request from each provider CLI. */
|
|
54
|
+
planUsage: flat('GET', '/api/usage'),
|
|
49
55
|
repoIcon: param('GET', '/api/repos/:repo/icon'),
|
|
50
56
|
/** A temporary image emitted in a chat message, fetched with the phone's auth header. */
|
|
51
57
|
localImage: param('GET', '/api/local-images/:path'),
|
package/dist-node/src/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import zlib from 'node:zlib';
|
|
7
7
|
import { attachmentPrompt, writeAttachment } from "./attachments.js";
|
|
8
8
|
import { startAutoUpdate, updateStatus } from "./autoupdate.js";
|
|
9
|
+
import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
|
|
9
10
|
import { loadConfig, stateDir } from "./config.js";
|
|
10
11
|
import { ConductorDb } from "./db.js";
|
|
11
12
|
import { DevServerController } from "./dev-server.js";
|
|
@@ -20,6 +21,7 @@ import { ModelCache } from "./model-cache.js";
|
|
|
20
21
|
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
|
|
21
22
|
import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
22
23
|
import { ParkedPromptQueue } from "./parked.js";
|
|
24
|
+
import { PlanUsageService } from "./plan-usage.js";
|
|
23
25
|
import { attachPrStatus } from "./pr.js";
|
|
24
26
|
import { readPrefs, writePrefs } from "./prefs.js";
|
|
25
27
|
import { Reads } from "./reads.js";
|
|
@@ -30,7 +32,7 @@ import { readSettings, writeSettings } from "./settings.js";
|
|
|
30
32
|
import { VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
|
|
31
33
|
import { discardStagedAttachment, materializeStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
|
|
32
34
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
33
|
-
import { renderTranscript, transcriptThrough } from "./transcript.js";
|
|
35
|
+
import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
|
|
34
36
|
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
|
|
35
37
|
import { archiveWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
36
38
|
// Before anything that logs: from here on every console line is also kept in memory for
|
|
@@ -46,6 +48,7 @@ const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
|
|
|
46
48
|
// relay state alongside the prompt queues. This lets a brand-new workspace choose
|
|
47
49
|
// from a list before Conductor has created its first chat.
|
|
48
50
|
const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
|
|
51
|
+
const planUsage = new PlanUsageService();
|
|
49
52
|
// Full-text index over the chat prose, in the relay's own sidecar DB — never in
|
|
50
53
|
// Conductor's (see src/search.ts). It backfills in the background and is disposable:
|
|
51
54
|
// deleting the file rebuilds it on the next start.
|
|
@@ -880,6 +883,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
880
883
|
if (isRoute(routes.modelCatalog, req.method, pathname)) {
|
|
881
884
|
return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
|
|
882
885
|
}
|
|
886
|
+
// GET/PATCH /api/models/defaults — the live user-wide effort defaults.
|
|
887
|
+
// These are file-backed settings, not the stale rows conductor.db still carries.
|
|
888
|
+
if (isRoute(routes.modelDefaults, req.method, pathname)) {
|
|
889
|
+
return json(req, res, 200, { defaultEfforts: readDefaultEfforts() });
|
|
890
|
+
}
|
|
891
|
+
if (isRoute(routes.updateModelDefaults, req.method, pathname)) {
|
|
892
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
893
|
+
const patch = {};
|
|
894
|
+
if (body.claude !== undefined) {
|
|
895
|
+
if (!isDefaultEffortLevel(body.claude))
|
|
896
|
+
return json(req, res, 400, { error: 'unknown Claude effort level' });
|
|
897
|
+
patch.claude = body.claude;
|
|
898
|
+
}
|
|
899
|
+
if (body.codex !== undefined) {
|
|
900
|
+
if (!isDefaultEffortLevel(body.codex))
|
|
901
|
+
return json(req, res, 400, { error: 'unknown Codex effort level' });
|
|
902
|
+
patch.codex = body.codex;
|
|
903
|
+
}
|
|
904
|
+
if (Object.keys(patch).length === 0)
|
|
905
|
+
return json(req, res, 400, { error: 'nothing to change' });
|
|
906
|
+
return json(req, res, 200, { defaultEfforts: writeDefaultEfforts(patch) });
|
|
907
|
+
}
|
|
908
|
+
// GET /api/usage — structured subscription limits from the CLIs Conductor
|
|
909
|
+
// itself bundles. Both reads are prompt-free and cached; `refresh=1` is the
|
|
910
|
+
// explicit user action in the sheet, never a background poll.
|
|
911
|
+
if (isRoute(routes.planUsage, req.method, pathname)) {
|
|
912
|
+
return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
|
|
913
|
+
}
|
|
883
914
|
// GET /api/settings — relay preferences plus what the phone needs to edit them:
|
|
884
915
|
// the SSIDs this Mac already holds credentials for, so the picker offers a choice
|
|
885
916
|
// instead of asking someone to type a network name from memory on a phone keyboard.
|
|
@@ -1619,7 +1650,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1619
1650
|
});
|
|
1620
1651
|
return json(req, res, answer.status, answer.body);
|
|
1621
1652
|
}
|
|
1622
|
-
// POST /api/sessions/:id/split
|
|
1653
|
+
// POST /api/sessions/:id/split
|
|
1654
|
+
// { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid? }
|
|
1623
1655
|
//
|
|
1624
1656
|
// Conductor's own "Fork to new tab" resumes the agent's real session. This copies
|
|
1625
1657
|
// the conversation instead, as a Conductor attachment, which is the cut that
|
|
@@ -1653,14 +1685,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
1653
1685
|
const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
|
|
1654
1686
|
const { entries } = reads.getMessages(sessionId);
|
|
1655
1687
|
const through = body.throughRowid;
|
|
1688
|
+
const only = body.onlyRowid;
|
|
1656
1689
|
if (through !== undefined && (!Number.isSafeInteger(through) || through < 1)) {
|
|
1657
1690
|
return json(req, res, 400, { error: 'throughRowid must be a positive integer' });
|
|
1658
1691
|
}
|
|
1659
|
-
|
|
1692
|
+
if (only !== undefined && (!Number.isSafeInteger(only) || only < 1)) {
|
|
1693
|
+
return json(req, res, 400, { error: 'onlyRowid must be a positive integer' });
|
|
1694
|
+
}
|
|
1695
|
+
if (through !== undefined && only !== undefined) {
|
|
1696
|
+
return json(req, res, 400, { error: 'throughRowid and onlyRowid cannot be combined' });
|
|
1697
|
+
}
|
|
1698
|
+
const cut = only !== undefined
|
|
1699
|
+
? transcriptMessage(entries, only)
|
|
1700
|
+
: through === undefined
|
|
1701
|
+
? { entries, earlier: 0, later: 0 }
|
|
1702
|
+
: transcriptThrough(entries, through);
|
|
1660
1703
|
if (!cut)
|
|
1661
1704
|
return json(req, res, 409, { error: 'that message is not in this chat' });
|
|
1662
1705
|
const rendered = renderTranscript(cut.entries, format);
|
|
1663
|
-
const elided = { ...rendered.elided, later: cut.later };
|
|
1706
|
+
const elided = { ...rendered.elided, earlier: 'earlier' in cut ? cut.earlier : 0, later: cut.later };
|
|
1664
1707
|
if (!rendered.kept)
|
|
1665
1708
|
return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
|
|
1666
1709
|
// Conductor's own name for a copied transcript, so the chip reads the same as one
|
|
@@ -1669,11 +1712,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1669
1712
|
const title = source.title?.trim() || 'chat';
|
|
1670
1713
|
const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
|
|
1671
1714
|
carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
|
|
1672
|
-
const stops =
|
|
1673
|
-
? [
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1715
|
+
const stops = only !== undefined
|
|
1716
|
+
? ['The copy contains only the selected source message; all earlier and later messages are omitted.']
|
|
1717
|
+
: cut.later
|
|
1718
|
+
? [
|
|
1719
|
+
`The copy stops partway through: ${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} not in it.`
|
|
1720
|
+
]
|
|
1721
|
+
: [];
|
|
1677
1722
|
const header = [
|
|
1678
1723
|
`# Transcript of ${title}`,
|
|
1679
1724
|
'',
|
|
@@ -278,6 +278,21 @@ export function transcriptThrough(entries, rowid) {
|
|
|
278
278
|
const kept = entries.filter(entry => entry.rowid <= rowid);
|
|
279
279
|
return { entries: kept, later: entries.length - kept.length };
|
|
280
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Keep one source message and nothing around it.
|
|
283
|
+
*
|
|
284
|
+
* One `session_messages` row can produce several transcript entries — prose split by
|
|
285
|
+
* reasoning or tool calls — so selecting by rowid keeps them together. Treating the
|
|
286
|
+
* last rendered bubble as the message would silently lose the other prose fragments.
|
|
287
|
+
*/
|
|
288
|
+
export function transcriptMessage(entries, rowid) {
|
|
289
|
+
const first = entries.findIndex(entry => entry.rowid === rowid);
|
|
290
|
+
if (first < 0)
|
|
291
|
+
return null;
|
|
292
|
+
const kept = entries.filter(entry => entry.rowid === rowid);
|
|
293
|
+
const last = entries.findLastIndex(entry => entry.rowid === rowid);
|
|
294
|
+
return { entries: kept, earlier: first, later: entries.length - last - 1 };
|
|
295
|
+
}
|
|
281
296
|
/**
|
|
282
297
|
* A chat as markdown, in Conductor's own transcript layout.
|
|
283
298
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|