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/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 && targetConfig.models && !targetConfig.models.allowed.includes(input.model))
240
- throw new Error(`model not allowed: ${input.model}`);
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: () => ({ targets: Object.entries(ctx.config.targets).map(([name, target]) => ({
400
- name, adapter: adapter(target, name).adapter, available: commandExists(target.command),
401
- models: target.models ?? null, effort: target.effort ?? null,
402
- description: target.description ?? "", cost_note: target.costNote ?? "Not configured",
403
- resume: adapter(target, name).adapter !== "custom" || target.args.some((arg) => arg.includes("{session_id}")),
404
- ...pluginStatus(adapter(target, name)),
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
- const root = join(threadDir(ctx.home, thread.id), "runs");
411
- return { ok: true, thread_id: thread.id, runs: readdirSync(root).flatMap((runId) => {
412
- let directory;
413
- try {
414
- directory = storedRunDir(thread.id, runId);
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
+ }
@@ -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", { description: "List available targets, configured models/effort, continuation support and owner-provided routing/cost notes.", inputSchema: {} }, async () => handleTool(mailbox(), "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>;