outsrc 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -1
- package/dist/cli.js +128 -7
- package/dist/mailbox.d.ts +106 -1
- package/dist/mailbox.js +98 -33
- package/dist/model-cache.d.ts +29 -0
- package/dist/model-cache.js +62 -0
- package/dist/models.d.ts +10 -0
- package/dist/models.js +108 -0
- package/dist/server.js +7 -2
- package/dist/state.d.ts +24 -0
- package/dist/state.js +10 -0
- package/dist/streams.d.ts +49 -0
- package/dist/streams.js +388 -0
- package/dist/types.d.ts +12 -2
- package/dist/update-check.d.ts +18 -0
- package/dist/update-check.js +84 -0
- package/dist/updates.d.ts +78 -0
- package/dist/updates.js +136 -0
- package/dist/usage.d.ts +105 -0
- package/dist/usage.js +106 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +45 -0
- package/dist/wrapper.js +53 -9
- package/package.json +4 -4
package/dist/mailbox.js
CHANGED
|
@@ -8,13 +8,15 @@ import { buildInvocation } from "./adapters.js";
|
|
|
8
8
|
import { addWorktree, hideAgentConfig } from "./git.js";
|
|
9
9
|
import { commandExists, JOB_MARKER, jobEnv, pidAlive, processIdentity, sameProcess, startWrapper, stopOwnedProcess } from "./job.js";
|
|
10
10
|
import { MAX_LOG_READ_BYTES, resolveLimits } from "./limits.js";
|
|
11
|
+
import { resolveModels } from "./model-cache.js";
|
|
11
12
|
import { branchName, filesystemPath, resolveInside, threadDir, threadsDir, worktreePath } from "./paths.js";
|
|
12
13
|
import { isVerified, pluginVersion } from "./plugin-contract.js";
|
|
13
14
|
import { cancelInvocation, engineDir, isPluginAdapter, teardownInvocation } from "./plugins.js";
|
|
14
15
|
import { wrapMessage } from "./prompt.js";
|
|
15
|
-
import { ProcessSchema, readJson, readResult, RunSchema, ThreadSchema, writeJson } from "./state.js";
|
|
16
|
+
import { ProcessSchema, readJson, readResult, readRunUsage, RunSchema, ThreadSchema, writeJson } from "./state.js";
|
|
16
17
|
import { collectWorkspaceDiff, getBaseCommit } from "./workspace.js";
|
|
17
|
-
import { DEFAULT_CALLER, parseAlias, parseRunId, parseTargetName, parseThreadId } from "./types.js";
|
|
18
|
+
import { DEFAULT_CALLER, MINTED_THREAD_ID, parseAlias, parseRunId, parseTargetName, parseThreadId } from "./types.js";
|
|
19
|
+
import { aggregateUsage, emptyUsage } from "./usage.js";
|
|
18
20
|
function id() { return randomBytes(8).toString("hex"); }
|
|
19
21
|
function adapter(target, name) {
|
|
20
22
|
const kind = target.adapter ?? (name === "claude" || name === "codex" || name === "grok" ? name : "custom");
|
|
@@ -43,7 +45,7 @@ function pluginStatus(target) {
|
|
|
43
45
|
}
|
|
44
46
|
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
45
47
|
function failedResult(message, sessionId = null) {
|
|
46
|
-
return { kind: "failed", message, sessionId, findings: [], exitCode: null, finishedAt: new Date().toISOString(), diffstat: "", commit: null };
|
|
48
|
+
return { kind: "failed", message, sessionId, findings: [], exitCode: null, finishedAt: new Date().toISOString(), diffstat: "", commit: null, usage: emptyUsage() };
|
|
47
49
|
}
|
|
48
50
|
const RequestSchema = z.object({
|
|
49
51
|
fingerprint: z.string(), threadId: z.string().transform(parseThreadId), runId: z.string().transform(parseRunId),
|
|
@@ -98,14 +100,14 @@ export function createMailbox(ctx) {
|
|
|
98
100
|
if (result)
|
|
99
101
|
return result;
|
|
100
102
|
if (existsSync(join(dir, "cancelled"))) {
|
|
101
|
-
const cancelled = { ...failedResult("Task cancelled", run.sessionId), kind: "cancelled" };
|
|
103
|
+
const cancelled = { ...failedResult("Task cancelled", run.sessionId), kind: "cancelled", usage: readRunUsage(dir) };
|
|
102
104
|
writeJson(join(dir, "result.json"), cancelled);
|
|
103
105
|
return cancelled;
|
|
104
106
|
}
|
|
105
107
|
if (alive)
|
|
106
108
|
return null;
|
|
107
109
|
if (started) {
|
|
108
|
-
const failed = failedResult("Agent wrapper stopped without publishing a result", run.sessionId);
|
|
110
|
+
const failed = { ...failedResult("Agent wrapper stopped without publishing a result", run.sessionId), usage: readRunUsage(dir) };
|
|
109
111
|
writeJson(join(dir, "result.json"), failed);
|
|
110
112
|
return failed;
|
|
111
113
|
}
|
|
@@ -130,18 +132,19 @@ export function createMailbox(ctx) {
|
|
|
130
132
|
const thread = loadThread(threadId);
|
|
131
133
|
const { dir, run } = current(thread);
|
|
132
134
|
const result = resultFor(thread);
|
|
135
|
+
const fields = usageFields(thread, result);
|
|
133
136
|
if (!result) {
|
|
134
137
|
const age = Math.max(0, Date.now() - Date.parse(run.createdAt));
|
|
135
138
|
const retry = ctx.retryAfterSeconds ?? Math.min(300, 30 * 2 ** Math.floor(age / 120_000));
|
|
136
|
-
return { ok: true, status: "working", retry_after_seconds: retry, run_id: run.id, progress: tail(dir).trim().split("\n").slice(-4).join("\n") };
|
|
139
|
+
return { ...fields, ok: true, status: "working", retry_after_seconds: retry, run_id: run.id, progress: tail(dir).trim().split("\n").slice(-4).join("\n") };
|
|
137
140
|
}
|
|
138
141
|
if (result.kind === "needs_input")
|
|
139
142
|
return {
|
|
140
|
-
ok: true, status: "needs_input", retry_after_seconds: 0, run_id: run.id,
|
|
143
|
+
...fields, ok: true, status: "needs_input", retry_after_seconds: 0, run_id: run.id,
|
|
141
144
|
message: result.message, question_id: run.id, session_id: result.sessionId,
|
|
142
145
|
};
|
|
143
146
|
return {
|
|
144
|
-
ok: true, status: result.kind === "completed" ? "succeeded" : result.kind,
|
|
147
|
+
...fields, ok: true, status: result.kind === "completed" ? "succeeded" : result.kind,
|
|
145
148
|
run_id: run.id, message: result.message, branch: thread.branch, worktree: thread.worktree,
|
|
146
149
|
diffstat: { raw: result.diffstat }, exit_code: result.exitCode,
|
|
147
150
|
session_id: result.sessionId, findings: result.findings, commit: result.commit,
|
|
@@ -151,6 +154,24 @@ export function createMailbox(ctx) {
|
|
|
151
154
|
return { ok: false, error: errorMessage(error) };
|
|
152
155
|
}
|
|
153
156
|
}
|
|
157
|
+
function usageFields(thread, result) {
|
|
158
|
+
const { model, effort, ...usage } = result?.usage ?? emptyUsage();
|
|
159
|
+
return { target: thread.target, effort, model, usage };
|
|
160
|
+
}
|
|
161
|
+
function savedRuns(thread) {
|
|
162
|
+
const root = join(threadDir(ctx.home, thread.id), "runs");
|
|
163
|
+
return readdirSync(root).flatMap((runId) => {
|
|
164
|
+
let directory;
|
|
165
|
+
try {
|
|
166
|
+
directory = storedRunDir(thread.id, runId);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const run = RunSchema.parse(readJson(join(directory, "run.json")));
|
|
172
|
+
return [{ run, result: run.id === thread.latestRunId ? resultFor(thread) : readResult(directory) }];
|
|
173
|
+
}).sort((a, b) => a.run.createdAt.localeCompare(b.run.createdAt));
|
|
174
|
+
}
|
|
154
175
|
function replayRequest(file, requestFingerprint) {
|
|
155
176
|
const previous = RequestSchema.parse(readJson(file));
|
|
156
177
|
if (previous.fingerprint !== requestFingerprint)
|
|
@@ -236,8 +257,8 @@ export function createMailbox(ctx) {
|
|
|
236
257
|
const targetConfig = ctx.config.targets[target];
|
|
237
258
|
if (!targetConfig)
|
|
238
259
|
throw new Error(`unknown target: ${target}`);
|
|
239
|
-
if (input.model
|
|
240
|
-
|
|
260
|
+
if (input.model)
|
|
261
|
+
checkModel(target, targetConfig, input.model);
|
|
241
262
|
if (input.effort && targetConfig.effort && !targetConfig.effort.allowed.includes(input.effort))
|
|
242
263
|
throw new Error(`effort not allowed: ${input.effort}`);
|
|
243
264
|
const repoPath = ctx.config.repos.find((item) => item.alias === repo)?.path ?? "";
|
|
@@ -370,6 +391,23 @@ export function createMailbox(ctx) {
|
|
|
370
391
|
}
|
|
371
392
|
}).sort((a, b) => b.created_at.localeCompare(a.created_at)) };
|
|
372
393
|
}
|
|
394
|
+
function cachedModels(targets, refresh = false) {
|
|
395
|
+
const unconfigured = Object.fromEntries(Object.entries(targets).flatMap(([name, target]) => target.models ? [] : [[name, adapter(target, name)]]));
|
|
396
|
+
return resolveModels({ home: ctx.home, targets: unconfigured, refresh, ...(ctx.discoverModels ? { discover: ctx.discoverModels } : {}) });
|
|
397
|
+
}
|
|
398
|
+
// A model outside the configured or cached list is refused rather than passed to the vendor CLI. When discovery
|
|
399
|
+
// could not list the target's models there is nothing to check against, and the vendor CLI decides.
|
|
400
|
+
function checkModel(name, target, model) {
|
|
401
|
+
if (target.models) {
|
|
402
|
+
if (!target.models.allowed.includes(model))
|
|
403
|
+
throw new Error(`model not allowed: ${model}. Allowed for ${name}: ${target.models.allowed.join(", ")} (from config)`);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const entry = cachedModels({ [name]: target })[name];
|
|
407
|
+
if (entry?.models && !entry.models.allowed.includes(model)) {
|
|
408
|
+
throw new Error(`model not allowed: ${model}. Allowed for ${name}: ${entry.models.allowed.join(", ")} (cached ${entry.refreshedAt}; run outsrc models refresh if the list is out of date)`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
373
411
|
function discard(threadId) {
|
|
374
412
|
try {
|
|
375
413
|
const thread = loadThread(threadId);
|
|
@@ -396,33 +434,60 @@ export function createMailbox(ctx) {
|
|
|
396
434
|
}
|
|
397
435
|
return {
|
|
398
436
|
listRepos: () => ({ repos: ctx.config.repos.map(({ alias, path }) => ({ alias, path })) }),
|
|
399
|
-
listTargets: (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
437
|
+
listTargets: (options = {}) => {
|
|
438
|
+
const cached = cachedModels(ctx.config.targets, options.refresh);
|
|
439
|
+
return { targets: Object.entries(ctx.config.targets).map(([name, target]) => ({
|
|
440
|
+
name, adapter: adapter(target, name).adapter, available: commandExists(target.command),
|
|
441
|
+
models: target.models ?? cached[name]?.models ?? null, models_refreshed_at: cached[name]?.refreshedAt ?? null,
|
|
442
|
+
effort: target.effort ?? null,
|
|
443
|
+
description: target.description ?? "", cost_note: target.costNote ?? "Not configured",
|
|
444
|
+
resume: adapter(target, name).adapter !== "custom" || target.args.some((arg) => arg.includes("{session_id}")),
|
|
445
|
+
...pluginStatus(adapter(target, name)),
|
|
446
|
+
})) };
|
|
447
|
+
},
|
|
448
|
+
refreshModels: (name) => {
|
|
449
|
+
if (name === undefined)
|
|
450
|
+
return { refreshed: cachedModels(ctx.config.targets, true) };
|
|
451
|
+
const target = ctx.config.targets[name];
|
|
452
|
+
if (!target)
|
|
453
|
+
throw new Error(`unknown target: ${name}`);
|
|
454
|
+
return { refreshed: cachedModels({ [name]: target }, true) };
|
|
455
|
+
},
|
|
406
456
|
send, inbox, threads, discard,
|
|
457
|
+
usage() {
|
|
458
|
+
try {
|
|
459
|
+
const samples = [];
|
|
460
|
+
for (const name of existsSync(threadsDir(ctx.home)) ? readdirSync(threadsDir(ctx.home)) : []) {
|
|
461
|
+
if (!MINTED_THREAD_ID.test(name))
|
|
462
|
+
continue;
|
|
463
|
+
let thread;
|
|
464
|
+
try {
|
|
465
|
+
thread = loadThread(name);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (errorMessage(error) === `unknown thread_id: ${name}`)
|
|
469
|
+
continue;
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
for (const { run, result } of savedRuns(thread)) {
|
|
473
|
+
samples.push({ target: thread.target, created_at: run.createdAt, usage: result?.usage ?? emptyUsage() });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return aggregateUsage(samples);
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
return { ok: false, error: errorMessage(error) };
|
|
480
|
+
}
|
|
481
|
+
},
|
|
407
482
|
history(threadId) {
|
|
408
483
|
try {
|
|
409
484
|
const thread = loadThread(threadId);
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
catch {
|
|
417
|
-
return [];
|
|
418
|
-
}
|
|
419
|
-
const run = RunSchema.parse(readJson(join(directory, "run.json")));
|
|
420
|
-
const result = readResult(directory);
|
|
421
|
-
return [{ run_id: run.id, request_id: run.requestId, created_at: run.createdAt, message: run.message,
|
|
422
|
-
status: result ? (result.kind === "completed" ? "succeeded" : result.kind) : "working",
|
|
423
|
-
session_id: result?.sessionId ?? run.sessionId, final_message: result?.message ?? null,
|
|
424
|
-
findings: result?.findings ?? [], exit_code: result?.exitCode ?? null }];
|
|
425
|
-
}).sort((a, b) => a.created_at.localeCompare(b.created_at)) };
|
|
485
|
+
return { ok: true, thread_id: thread.id, runs: savedRuns(thread).map(({ run, result }) => {
|
|
486
|
+
return { ...usageFields(thread, result), run_id: run.id, request_id: run.requestId, created_at: run.createdAt, message: run.message,
|
|
487
|
+
status: result ? (result.kind === "completed" ? "succeeded" : result.kind) : "working",
|
|
488
|
+
session_id: result?.sessionId ?? run.sessionId, final_message: result?.message ?? null,
|
|
489
|
+
findings: result?.findings ?? [], exit_code: result?.exitCode ?? null };
|
|
490
|
+
}) };
|
|
426
491
|
}
|
|
427
492
|
catch (error) {
|
|
428
493
|
return { ok: false, error: errorMessage(error) };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Adapter, AdapterTarget } from "./adapters.js";
|
|
3
|
+
import { type ModelOptions } from "./models.js";
|
|
4
|
+
export type Discover = (adapter: Adapter, command: string) => ModelOptions | null;
|
|
5
|
+
declare const EntrySchema: z.ZodObject<{
|
|
6
|
+
adapter: z.ZodString;
|
|
7
|
+
command: z.ZodString;
|
|
8
|
+
refreshedAt: z.ZodString;
|
|
9
|
+
models: z.ZodNullable<z.ZodObject<{
|
|
10
|
+
default: z.ZodString;
|
|
11
|
+
allowed: z.ZodArray<z.ZodString>;
|
|
12
|
+
}, z.core.$strip>>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export type ModelCacheEntry = z.infer<typeof EntrySchema>;
|
|
15
|
+
export declare function modelCachePath(home: string): string;
|
|
16
|
+
/** A missing or unreadable cache reads as empty, so the next lookup rediscovers. */
|
|
17
|
+
export declare function readModelCache(home: string): Record<string, ModelCacheEntry>;
|
|
18
|
+
/**
|
|
19
|
+
* Cached models per target. A target is discovered when it has no entry, when its adapter or command changed
|
|
20
|
+
* since the entry was written, or when `refresh` names it. Everything else is served from the file without
|
|
21
|
+
* spawning a vendor CLI. Discovery only runs each vendor's model-list command; it never runs a model.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveModels(options: {
|
|
24
|
+
home: string;
|
|
25
|
+
targets: Record<string, AdapterTarget>;
|
|
26
|
+
refresh?: boolean;
|
|
27
|
+
discover?: Discover;
|
|
28
|
+
}): Record<string, ModelCacheEntry>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ensureOutsrcHome, writeHomeFile } from "./fs-home.js";
|
|
5
|
+
import { discoverModels } from "./models.js";
|
|
6
|
+
const EntrySchema = z.object({
|
|
7
|
+
adapter: z.string(),
|
|
8
|
+
command: z.string(),
|
|
9
|
+
refreshedAt: z.string(),
|
|
10
|
+
models: z.object({ default: z.string(), allowed: z.array(z.string()) }).nullable(),
|
|
11
|
+
});
|
|
12
|
+
const CacheSchema = z.object({ version: z.literal(1), targets: z.record(z.string(), EntrySchema) });
|
|
13
|
+
export function modelCachePath(home) {
|
|
14
|
+
return join(home, "models.json");
|
|
15
|
+
}
|
|
16
|
+
/** A missing or unreadable cache reads as empty, so the next lookup rediscovers. */
|
|
17
|
+
export function readModelCache(home) {
|
|
18
|
+
const file = modelCachePath(home);
|
|
19
|
+
if (!existsSync(file))
|
|
20
|
+
return {};
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
const parsed = CacheSchema.safeParse(raw);
|
|
29
|
+
return parsed.success ? parsed.data.targets : {};
|
|
30
|
+
}
|
|
31
|
+
function writeModelCache(home, targets) {
|
|
32
|
+
ensureOutsrcHome(home);
|
|
33
|
+
const file = modelCachePath(home);
|
|
34
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
35
|
+
writeHomeFile(temp, `${JSON.stringify({ version: 1, targets }, null, 2)}\n`);
|
|
36
|
+
renameSync(temp, file);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Cached models per target. A target is discovered when it has no entry, when its adapter or command changed
|
|
40
|
+
* since the entry was written, or when `refresh` names it. Everything else is served from the file without
|
|
41
|
+
* spawning a vendor CLI. Discovery only runs each vendor's model-list command; it never runs a model.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveModels(options) {
|
|
44
|
+
const discover = options.discover ?? discoverModels;
|
|
45
|
+
const cache = readModelCache(options.home);
|
|
46
|
+
const result = {};
|
|
47
|
+
let changed = false;
|
|
48
|
+
for (const [name, target] of Object.entries(options.targets)) {
|
|
49
|
+
const cached = cache[name];
|
|
50
|
+
if (!options.refresh && cached && cached.adapter === target.adapter && cached.command === target.command) {
|
|
51
|
+
result[name] = cached;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const entry = { adapter: target.adapter, command: target.command, refreshedAt: new Date().toISOString(), models: discover(target.adapter, target.command) };
|
|
55
|
+
cache[name] = entry;
|
|
56
|
+
result[name] = entry;
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
if (changed)
|
|
60
|
+
writeModelCache(options.home, cache);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Adapter } from "./adapters.js";
|
|
2
|
+
export type ModelOptions = {
|
|
3
|
+
default: string;
|
|
4
|
+
allowed: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function parseClaudeModels(stdout: string): ModelOptions | null;
|
|
7
|
+
export declare function parseCodexModels(stdout: string): ModelOptions | null;
|
|
8
|
+
export declare function parseGrokModels(stdout: string): ModelOptions | null;
|
|
9
|
+
/** Asks the target's CLI which models it can use. Null when the adapter cannot list or the CLI fails. */
|
|
10
|
+
export declare function discoverModels(adapter: Adapter, command: string): ModelOptions | null;
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const ClaudeInit = z.object({
|
|
5
|
+
type: z.literal("control_response"),
|
|
6
|
+
response: z.object({
|
|
7
|
+
subtype: z.literal("success"),
|
|
8
|
+
response: z.object({ models: z.array(z.object({ value: z.string().min(1), resolvedModel: z.string().min(1).optional() })) }),
|
|
9
|
+
}),
|
|
10
|
+
});
|
|
11
|
+
// The Agent SDK's supportedModels(): the initialize control response lists every --model value the account can use.
|
|
12
|
+
// The "default" entry names the account default; a model set in the user's own settings still wins at run time.
|
|
13
|
+
export function parseClaudeModels(stdout) {
|
|
14
|
+
for (const line of stdout.split("\n")) {
|
|
15
|
+
let value;
|
|
16
|
+
try {
|
|
17
|
+
value = JSON.parse(line);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const init = ClaudeInit.safeParse(value);
|
|
23
|
+
if (!init.success)
|
|
24
|
+
continue;
|
|
25
|
+
const models = init.data.response.response.models;
|
|
26
|
+
const fallback = models.find((model) => model.value === "default")?.resolvedModel;
|
|
27
|
+
const allowed = [...new Set(models.map((model) => model.value).filter((name) => name !== "default"))];
|
|
28
|
+
if (!fallback || allowed.length === 0)
|
|
29
|
+
return null;
|
|
30
|
+
return { default: fallback, allowed: allowed.includes(fallback) ? allowed : [fallback, ...allowed] };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const CodexCatalog = z.object({
|
|
35
|
+
models: z.array(z.object({ slug: z.string().min(1), visibility: z.string(), priority: z.number() })),
|
|
36
|
+
});
|
|
37
|
+
// Codex's own picker shows the "list" models and defaults to the highest priority one, as app-server model/list reports.
|
|
38
|
+
export function parseCodexModels(stdout) {
|
|
39
|
+
let value;
|
|
40
|
+
try {
|
|
41
|
+
value = JSON.parse(stdout);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const catalog = CodexCatalog.safeParse(value);
|
|
47
|
+
if (!catalog.success)
|
|
48
|
+
return null;
|
|
49
|
+
const listed = catalog.data.models.filter((model) => model.visibility === "list").sort((a, b) => a.priority - b.priority);
|
|
50
|
+
const first = listed[0];
|
|
51
|
+
return first ? { default: first.slug, allowed: listed.map((model) => model.slug) } : null;
|
|
52
|
+
}
|
|
53
|
+
// `grok models` has no JSON mode. It prints "Available models:" then one "* id (default)" or "- id" line per model.
|
|
54
|
+
export function parseGrokModels(stdout) {
|
|
55
|
+
const lines = stdout.split("\n");
|
|
56
|
+
const start = lines.findIndex((line) => /^available models:/i.test(line.trim()));
|
|
57
|
+
if (start < 0)
|
|
58
|
+
return null;
|
|
59
|
+
const allowed = [];
|
|
60
|
+
let fallback;
|
|
61
|
+
for (const line of lines.slice(start + 1)) {
|
|
62
|
+
const match = /^\s*[*-]\s+(\S+)(\s+\(default\))?\s*$/.exec(line);
|
|
63
|
+
if (!match?.[1])
|
|
64
|
+
continue;
|
|
65
|
+
allowed.push(match[1]);
|
|
66
|
+
if (match[2])
|
|
67
|
+
fallback = match[1];
|
|
68
|
+
}
|
|
69
|
+
fallback ??= /^default model:\s*(\S+)/im.exec(stdout)?.[1];
|
|
70
|
+
return fallback && allowed.includes(fallback) ? { default: fallback, allowed } : null;
|
|
71
|
+
}
|
|
72
|
+
const CLAUDE_INIT = `${JSON.stringify({ type: "control_request", request_id: "outsrc-models", request: { subtype: "initialize" } })}\n`;
|
|
73
|
+
// Plugin engines run the vendor CLI themselves: the Codex companion spawns `codex` from PATH, the Grok bridge
|
|
74
|
+
// `$GROK_BINARY` or `grok`. Neither engine has a model-list subcommand, so outsrc asks the CLI they run.
|
|
75
|
+
function lister(adapter, command) {
|
|
76
|
+
switch (adapter) {
|
|
77
|
+
case "claude":
|
|
78
|
+
return {
|
|
79
|
+
command,
|
|
80
|
+
args: ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--no-session-persistence",
|
|
81
|
+
"--setting-sources", "user", "--strict-mcp-config", "--settings", JSON.stringify({ disableAllHooks: true })],
|
|
82
|
+
input: CLAUDE_INIT,
|
|
83
|
+
parse: parseClaudeModels,
|
|
84
|
+
};
|
|
85
|
+
case "codex": return { command, args: ["debug", "models"], parse: parseCodexModels };
|
|
86
|
+
case "codex-plugin": return { command: "codex", args: ["debug", "models"], parse: parseCodexModels };
|
|
87
|
+
case "grok": return { command, args: ["models"], parse: parseGrokModels };
|
|
88
|
+
case "grok-plugin": return { command: process.env.GROK_BINARY ?? "grok", args: ["models"], parse: parseGrokModels };
|
|
89
|
+
// A custom target's command has no known listing contract.
|
|
90
|
+
case "custom": return null;
|
|
91
|
+
default: {
|
|
92
|
+
const exhaustive = adapter;
|
|
93
|
+
throw new Error(`unknown adapter: ${exhaustive}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Asks the target's CLI which models it can use. Null when the adapter cannot list or the CLI fails. */
|
|
98
|
+
export function discoverModels(adapter, command) {
|
|
99
|
+
const plan = lister(adapter, command);
|
|
100
|
+
if (!plan || !plan.command)
|
|
101
|
+
return null;
|
|
102
|
+
const result = spawnSync(plan.command, plan.args, {
|
|
103
|
+
cwd: tmpdir(), encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "ignore"], input: plan.input ?? "",
|
|
104
|
+
});
|
|
105
|
+
if (result.error || result.status !== 0)
|
|
106
|
+
return null;
|
|
107
|
+
return plan.parse(result.stdout);
|
|
108
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -23,8 +23,9 @@ export async function handleTool(box, name, args) {
|
|
|
23
23
|
try {
|
|
24
24
|
switch (name) {
|
|
25
25
|
case "list_repos": return response(box.listRepos());
|
|
26
|
-
case "list_targets": return response(box.listTargets());
|
|
26
|
+
case "list_targets": return response(box.listTargets({ refresh: args.refresh === true }));
|
|
27
27
|
case "threads": return response(box.threads());
|
|
28
|
+
case "usage": return response(box.usage());
|
|
28
29
|
case "history": return response(box.history(text(args.thread_id)));
|
|
29
30
|
case "send": {
|
|
30
31
|
const input = {
|
|
@@ -74,9 +75,13 @@ export function makeServer(mailbox, configPath) {
|
|
|
74
75
|
return response({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
75
76
|
}
|
|
76
77
|
});
|
|
77
|
-
server.registerTool("list_targets", {
|
|
78
|
+
server.registerTool("list_targets", {
|
|
79
|
+
description: "List available targets, their models and effort, continuation support and owner-provided routing/cost notes. Models not set in config come from a local cache refreshed by outsrc models refresh; models_refreshed_at says when. Pass refresh: true to ask each vendor CLI again, which is slow.",
|
|
80
|
+
inputSchema: { refresh: z.boolean().optional().describe("Rediscover models from each vendor CLI instead of reading the cache") },
|
|
81
|
+
}, async (args) => handleTool(mailbox(), "list_targets", args));
|
|
78
82
|
server.registerTool("threads", { description: "List this caller's saved conversations, current status and recent progress. Recover a lost thread ID here. Other callers' threads are not visible.", inputSchema: {} }, async () => handleTool(mailbox(), "threads", {}));
|
|
79
83
|
server.registerTool("history", { description: "List all runs, submitted messages and saved results in a thread.", inputSchema: { thread_id: z.string() } }, async (args) => handleTool(mailbox(), "history", args));
|
|
84
|
+
server.registerTool("usage", { description: "Read this caller's local usage for today and the last 7 days, including totals by target. Each metric has total and missing_runs. An incomplete total is null. No provider dashboards or network requests are used.", inputSchema: {} }, async () => handleTool(mailbox(), "usage", {}));
|
|
80
85
|
server.registerTool("send", {
|
|
81
86
|
description: "Start a task with repo + target + message, or continue a finished/waiting session with thread_id + message. Supply a stable request_id for retryable delivery. Returns a thread_id and run_id immediately. Poll inbox using its retry_after_seconds. Review kinds request findings without edits. With a plugin target (codex-plugin, grok-plugin), a review with base runs the vendor plugin's own diff review against that base; review ignores message text there, while adversarial_review uses it as focus. Without base, reviews run read-only in the vendor sandbox.",
|
|
82
87
|
inputSchema: {
|
package/dist/state.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export declare const RunSchema: z.ZodObject<{
|
|
|
28
28
|
sessionId: z.ZodNullable<z.ZodString>;
|
|
29
29
|
}, z.core.$strip>;
|
|
30
30
|
export declare const ResultSchema: z.ZodObject<{
|
|
31
|
+
usage: z.ZodDefault<z.ZodObject<{
|
|
32
|
+
tokens_in: z.ZodNullable<z.ZodNumber>;
|
|
33
|
+
tokens_out: z.ZodNullable<z.ZodNumber>;
|
|
34
|
+
estimated_cost_usd: z.ZodNullable<z.ZodNumber>;
|
|
35
|
+
wall_minutes: z.ZodNullable<z.ZodNumber>;
|
|
36
|
+
model: z.ZodNullable<z.ZodString>;
|
|
37
|
+
effort: z.ZodNullable<z.ZodString>;
|
|
38
|
+
}, z.core.$strip>>;
|
|
31
39
|
kind: z.ZodEnum<{
|
|
32
40
|
cancelled: "cancelled";
|
|
33
41
|
completed: "completed";
|
|
@@ -62,6 +70,14 @@ export declare function writeJson(file: string, value: unknown): void;
|
|
|
62
70
|
export declare function readJson(file: string): unknown;
|
|
63
71
|
export declare function runDirectory(threadDirectory: string, runId: string): string;
|
|
64
72
|
export declare function readResult(directory: string): {
|
|
73
|
+
usage: {
|
|
74
|
+
tokens_in: number | null;
|
|
75
|
+
tokens_out: number | null;
|
|
76
|
+
estimated_cost_usd: number | null;
|
|
77
|
+
wall_minutes: number | null;
|
|
78
|
+
model: string | null;
|
|
79
|
+
effort: string | null;
|
|
80
|
+
};
|
|
65
81
|
kind: "cancelled" | "completed" | "failed" | "needs_input";
|
|
66
82
|
message: string;
|
|
67
83
|
sessionId: string | null;
|
|
@@ -77,3 +93,11 @@ export declare function readResult(directory: string): {
|
|
|
77
93
|
diffstat: string;
|
|
78
94
|
commit: string | null;
|
|
79
95
|
} | null;
|
|
96
|
+
export declare function readRunUsage(directory: string): {
|
|
97
|
+
tokens_in: number | null;
|
|
98
|
+
tokens_out: number | null;
|
|
99
|
+
estimated_cost_usd: number | null;
|
|
100
|
+
wall_minutes: number | null;
|
|
101
|
+
model: string | null;
|
|
102
|
+
effort: string | null;
|
|
103
|
+
};
|
package/dist/state.js
CHANGED
|
@@ -4,6 +4,7 @@ import { writeHomeFile } from "./fs-home.js";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { FindingSchema } from "./adapters.js";
|
|
7
|
+
import { emptyUsage, RunUsageSchema } from "./usage.js";
|
|
7
8
|
import { parseAlias, parseRunId, parseTargetName, parseThreadId, STORAGE_ID } from "./types.js";
|
|
8
9
|
export const ThreadSchema = z.object({
|
|
9
10
|
version: z.literal(2),
|
|
@@ -30,6 +31,7 @@ export const RunSchema = z.object({
|
|
|
30
31
|
sessionId: z.string().nullable(),
|
|
31
32
|
});
|
|
32
33
|
export const ResultSchema = z.object({
|
|
34
|
+
usage: RunUsageSchema.default(emptyUsage),
|
|
33
35
|
kind: z.enum(["completed", "needs_input", "failed", "cancelled"]),
|
|
34
36
|
message: z.string(),
|
|
35
37
|
sessionId: z.string().nullable(),
|
|
@@ -59,3 +61,11 @@ export function readResult(directory) {
|
|
|
59
61
|
const file = join(directory, "result.json");
|
|
60
62
|
return existsSync(file) ? ResultSchema.parse(readJson(file)) : null;
|
|
61
63
|
}
|
|
64
|
+
export function readRunUsage(directory) {
|
|
65
|
+
try {
|
|
66
|
+
return RunUsageSchema.parse(readJson(join(directory, "usage.json")));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return emptyUsage();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ThreadId } from "./types.js";
|
|
2
|
+
export type StreamStatus = "working" | "needs_input" | "succeeded" | "failed" | "cancelled" | "stopped";
|
|
3
|
+
export type StreamPane = {
|
|
4
|
+
thread_id: ThreadId;
|
|
5
|
+
repo: string;
|
|
6
|
+
target: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
status: StreamStatus;
|
|
9
|
+
live: boolean;
|
|
10
|
+
created_at: string;
|
|
11
|
+
run_id: string;
|
|
12
|
+
run_started_at: string;
|
|
13
|
+
finished_at: string | null;
|
|
14
|
+
log_size: number;
|
|
15
|
+
};
|
|
16
|
+
export type Scanner = (now?: number) => StreamPane[];
|
|
17
|
+
export declare const INITIAL_TAIL_BYTES: number;
|
|
18
|
+
/**
|
|
19
|
+
* Returns a scanner over `<home>/threads`. Working threads are always listed; finished ones only if they finished
|
|
20
|
+
* within `recentMs`, or if they are the pinned `thread`. A finished thread whose thread.json has not changed is not
|
|
21
|
+
* reread, so each tick costs one stat per old thread.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createScanner(input: {
|
|
24
|
+
home: string;
|
|
25
|
+
thread?: ThreadId;
|
|
26
|
+
recentMs: number;
|
|
27
|
+
}): Scanner;
|
|
28
|
+
/** Reads a run log from `offset`. `reset` is true when the file shrank or the caller asks for the first read. */
|
|
29
|
+
export declare function readLogFrom(home: string, pane: Pick<StreamPane, "thread_id" | "run_id">, offset: number | null): {
|
|
30
|
+
text: string;
|
|
31
|
+
next: number;
|
|
32
|
+
reset: boolean;
|
|
33
|
+
};
|
|
34
|
+
export type StreamServer = {
|
|
35
|
+
url: string;
|
|
36
|
+
port: number;
|
|
37
|
+
token: string;
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
clients(): number;
|
|
40
|
+
};
|
|
41
|
+
export declare function startStreamServer(input: {
|
|
42
|
+
home: string;
|
|
43
|
+
thread?: ThreadId;
|
|
44
|
+
host?: string;
|
|
45
|
+
port?: number;
|
|
46
|
+
pollMs?: number;
|
|
47
|
+
recentMs?: number;
|
|
48
|
+
token?: string;
|
|
49
|
+
}): Promise<StreamServer>;
|