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 CHANGED
@@ -51,7 +51,7 @@ Install it globally rather than running it through `npx`: agents launch the inst
51
51
 
52
52
  For agents and scripts, `outsrc init --json --repo <path> [--repo <path>] [--local claude,codex,grok] [--max-jobs <n|unlimited>] [--max-run-minutes <n|unlimited>]` prints one JSON event per line and exits with code 3 when a person has to act, such as logging in to a CLI. Rerun it after that step. Without `--local`, nothing is registered. A `settings` event lists the limits, repositories and targets so the agent can review them with the owner.
53
53
 
54
- `outsrc doctor` rechecks the configuration. `OUTSRC_HOME` moves the state directory away from `~/.outsrc`, which outsrc keeps readable by you only.
54
+ `outsrc doctor` rechecks the configuration and asks the npm registry for the latest `outsrc`. Its JSON adds `update_available` (true only when the installed version is behind npm) and `version`: `{status, installed, latest}`, where `status` is `current`, `outdated`, `ahead` or `unknown`. Without `--json` it also prints one line to stderr, such as `outsrc 0.2.0 is installed; 0.3.0 is available. Upgrade with: npm install -g outsrc@latest`. Doctor never upgrades. If the registry cannot be reached within 3 seconds, `status` is `unknown` with an `error`, `update_available` is false and the exit code is unaffected. `OUTSRC_HOME` moves the state directory away from `~/.outsrc`, which outsrc keeps readable by you only.
55
55
 
56
56
  ## Settings
57
57
 
@@ -101,6 +101,7 @@ The MCP tools and the CLI commands return the same JSON.
101
101
  | `threads` | This caller's threads and their status |
102
102
  | `settings` | Every setting with its value, default and meaning (read-only) |
103
103
  | `history` | Every run in a thread, with its message and result |
104
+ | `usage` | Local usage for today and the last 7 days, including totals by target |
104
105
  | `log` | A slice of a run's log |
105
106
  | `diff` | Changes since the thread started, committed or not (256 KiB by default) |
106
107
  | `stop` | Cancel a running thread |
@@ -108,6 +109,47 @@ The MCP tools and the CLI commands return the same JSON.
108
109
 
109
110
  CLI only: `outsrc config set` and `unset` change settings, `outsrc prune` deletes finished worktrees past their retention period, `outsrc migrate` imports threads from earlier versions, and `outsrc plugins` checks the plugin engines.
110
111
 
112
+ ### Local usage and spend
113
+
114
+ Run `outsrc usage --caller grok --json` or call the MCP `usage` tool. Omit `--caller` on the CLI to include all callers. The command reads saved runs on this computer. It does not contact providers, scrape dashboards, or send usage to a cloud service.
115
+
116
+ Every successful `inbox` response and every run in `history` includes these fields, including working, waiting, failed, and cancelled runs:
117
+
118
+ ```json
119
+ {
120
+ "target": "grok-plugin",
121
+ "effort": null,
122
+ "model": "grok-4.7-build",
123
+ "usage": {
124
+ "tokens_in": 30655,
125
+ "tokens_out": 369,
126
+ "estimated_cost_usd": 0.021794,
127
+ "wall_minutes": 2
128
+ }
129
+ }
130
+ ```
131
+
132
+ The token and cost values above come from the sanitized Grok review fixture; wall minutes illustrate the shape. `tokens_in` and `tokens_out` are the vendor's reported input/output counters. Separate cache and reasoning counters are not added. `estimated_cost_usd` is the vendor-reported USD cost estimate; outsrc does not apply a price table or infer a subscription charge. `model` is the vendor-reported model, or the sole model named in its `modelUsage` metadata, falling back to the explicit model passed in the invocation. It is null when neither is known. `effort` is the explicit setting passed in that run's invocation, not a guessed vendor default. Paths that ignore model or effort, including Codex plugin branch reviews, report null for those fields.
133
+
134
+ Claude and Grok native JSON result envelopes expose counters and cost when provided. Codex native JSONL `turn.completed.usage` events expose counters; cost stays null. Grok plugin review/critique envelopes expose usage through `result` or `grok.stdout`, counted once even when both contain it. The pinned Grok plugin 0.2.0 task path invokes the CLI with plain output, so task tokens and cost are null. Codex plugin 1.0.5 discards usage notifications, so its task and review tokens and cost are null. Their saved job results repeat the emitted payload and contain no additional usage to recover. Custom adapters have no supported usage contract, so their tokens and cost are null. Model-generated answer text is never interpreted as usage metadata. The engine pins remain unchanged.
135
+
136
+ `wall_minutes` measures elapsed wrapper time, including setup, agent execution, plugin teardown, and result collection. It excludes time before the wrapper starts. Working runs, old records, and interrupted runs without a recorded duration have null wall minutes. Old records require no migration. Missing or invalid vendor metadata produces explicit nulls, never zero or invented numbers. A reported zero remains zero. Each run saves reported usage in `usage.json` as stdout arrives, then includes it in `result.json`. Deadline failures retain stdout usage. Cancellation and wrapper interruption recover the saved usage without reading mixed stdout/stderr logs. A later commit or result collection failure also retains it.
137
+
138
+ The usage report has `as_of`, `today`, and `last_7_days`. Today starts at midnight in the local process time zone; the 7-day window is the preceding 168 hours. Runs are assigned by their submission time (`created_at`), including continuations, working runs, and retained discarded threads. Each window includes `since`, `runs`, the four numeric metrics, and a sorted `by_target` array with the same counts and metrics. Each metric is `{ "total": number | null, "missing_runs": number }`. A total is null if any included run lacks that metric, so known partial spend cannot appear as a complete total. Empty windows have zero runs and zero totals. Each saved run is counted once.
139
+
140
+ `npm run verify` reruns the recorded-envelope finished-run checks through source and built CLI/MCP surfaces. `npm run smoke:native` requires non-null input/output counters on each succeeded continuation. `npm run smoke:plugins` requires Grok review counters and explicit nulls on pinned plugin paths that cannot report usage. The live smoke commands use provider accounts.
141
+
142
+ ## Watching jobs live
143
+
144
+ `outsrc streams` (or `outsrc watch`) opens a grid of live agent logs in your browser, served from `127.0.0.1` on the machine that runs the jobs.
145
+
146
+ - One pane per working thread. The header shows the short thread id, target, status, branch and run age. The body follows the tail of the latest run's `run.log`. Scroll up to pause; click "paused" to follow again.
147
+ - New threads appear without a restart. With no working threads the page says so and keeps watching.
148
+ - Finished threads stop growing and move to a muted strip for 15 minutes (`--recent-minutes`). Click one to show its log.
149
+ - `--thread <id>` shows one thread only, finished or not. `--port <n>` fixes the port. `--no-open` prints the URL without opening a browser.
150
+
151
+ The viewer reads `$OUTSRC_HOME/threads` (default `~/.outsrc`) about once a second, and only while a page is open. It never calls a vendor CLI or a model, writes nothing, and does not change what `send` or `inbox` report. The URL carries a random token. Stopping the viewer (Ctrl-C) leaves jobs running. It keeps no state of its own, so it outlives bot and outsrc upgrades and shows nothing once `~/.outsrc` is removed.
152
+
111
153
  ## Repository options
112
154
 
113
155
  ```toml
@@ -138,6 +180,24 @@ A custom target prints one JSON object with `kind` (`completed`, `needs_input` o
138
180
 
139
181
  Each finding has `priority` (`P0` to `P3`), `title`, `body`, `path` (or null) and `line` (or null).
140
182
 
183
+ ### Model cache
184
+
185
+ A target with `models` in its config uses those. For every other target outsrc asks the vendor CLI for its models (a model-list command, not a model run, so it spends no tokens) and keeps the answer in `~/.outsrc/models.json` (`$OUTSRC_HOME/models.json`). `list_targets` reads that file, so a warm cache starts no vendor CLI. Each target shows `models_refreshed_at`, the time its entry was written, or null when its models come from config.
186
+
187
+ - Miss: a target with no entry, or whose `adapter` or `command` changed since the entry was written, is discovered once and written back. On a first install the first `list_targets` (or `init`) fills the cache.
188
+ - Stale: entries do not expire. outsrc serves an old entry until something refreshes it, so schedule a refresh.
189
+ - Failed listing: a missing or logged-out CLI is cached as `models: null` until the next refresh.
190
+
191
+ Refresh with `outsrc models refresh` (all targets) or `outsrc models refresh --target <name>`. `outsrc models` prints the cache. `outsrc list_targets --refresh`, or `list_targets` with `{"refresh": true}` over MCP, rediscovers before answering. To refresh twice a day with cron:
192
+
193
+ ```
194
+ 0 7,19 * * * /usr/bin/env outsrc models refresh >/dev/null 2>&1
195
+ ```
196
+
197
+ On macOS a launchd agent with `StartCalendarInterval` at the same hours works the same way. Both need `PATH` to include the vendor CLIs.
198
+
199
+ `send` refuses a `model` that is not in the target's configured or cached list, and the error lists the allowed models. When the cache has no list for the target (`models: null`) outsrc cannot check, and passes the model to the vendor CLI unchanged.
200
+
141
201
  ## Vendor plugin engines
142
202
 
143
203
  The `codex-plugin` and `grok-plugin` adapters drive the vendors' own Claude Code plugins, [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) and [xai-org/grok-build-plugin-cc](https://github.com/xai-org/grok-build-plugin-cc), both Apache-2.0. The plugin launches the CLI and supplies the review prompts, output schema and, for Codex, the built-in reviewer. outsrc keeps the mailbox, worktrees, repository list and filtered environment. It runs the engine scripts directly; no Claude Code session is involved.
@@ -167,6 +227,48 @@ The plugin scripts are the vendors' internal CLIs, not a documented API. outsrc
167
227
 
168
228
  To move a pin, run `outsrc engines pin <codex-plugin|grok-plugin> <commit|branch|tag>`. It updates `engines.lock.json` only if the contract check passes. Then run `npm run smoke:plugins` and keep the change only if that passes too.
169
229
 
230
+ ## Update checks
231
+
232
+ `outsrc updates refresh` checks whether Claude Code, Codex, Grok Build and outsrc itself are behind their latest release. It runs `claude --version`, `codex --version`, `grok --version`, `grok update --check --json` and reads the npm registry. It calls no model and costs no tokens. It never installs anything: you upgrade by hand, or ask the bot to after you say yes.
233
+
234
+ `outsrc updates` prints the last result without checking again, and `outsrc doctor` includes the same summary under `updates`.
235
+
236
+ State lives in two files under `~/.outsrc` (or `OUTSRC_HOME`), both readable by you only:
237
+
238
+ - `updates.json`: when the last check ran, each product's installed and latest version (or the error), and `pending`, the products that are behind. Only `refresh` writes it. A pending entry keeps its `first_seen_at` until a newer release appears. A failed check keeps the previous entry.
239
+ - `updates-ack.json`: the notifications already shown to you. Only `outsrc updates ack` writes it.
240
+
241
+ Each pending update is a notification with the id `<product>@<latest>`, for example `claude@2.1.286`. `refresh` lists the ones it found for the first time under `new`, and `unread` lists every pending notification not yet acknowledged. A rerun that finds nothing newer announces nothing, and an acknowledged version stays quiet until a newer one ships.
242
+
243
+ Run it a few times a day. cron and launchd start jobs with a short `PATH` that usually lacks `node` and the CLIs, and a CLI that cannot be found counts as not installed. Give the job your shell's `PATH` (the output of `echo $PATH`). With cron:
244
+
245
+ ```
246
+ PATH=<your PATH>
247
+ 17 */6 * * * outsrc updates refresh >/dev/null 2>&1
248
+ ```
249
+
250
+ On macOS with launchd, save this as `~/Library/LaunchAgents/ing.outsrc.updates.plist`, fill in `PATH` and the `outsrc` path (the output of `command -v outsrc`), then run `launchctl load` on the file:
251
+
252
+ ```xml
253
+ <?xml version="1.0" encoding="UTF-8"?>
254
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
255
+ <plist version="1.0">
256
+ <dict>
257
+ <key>Label</key><string>ing.outsrc.updates</string>
258
+ <key>ProgramArguments</key>
259
+ <array><string>/usr/local/bin/outsrc</string><string>updates</string><string>refresh</string></array>
260
+ <key>EnvironmentVariables</key>
261
+ <dict><key>PATH</key><string>your PATH</string></dict>
262
+ <key>StartInterval</key><integer>21600</integer>
263
+ <key>RunAtLoad</key><true/>
264
+ </dict>
265
+ </plist>
266
+ ```
267
+
268
+ ### For bot authors
269
+
270
+ When the bot next talks to its owner, it runs `outsrc updates --json` and, for each `unread` entry, tells the owner the product, installed version and latest version, and asks whether to upgrade. If the bot also reports to another agent, it forwards the same fields (`product`, `installed`, `latest`) there. After the owner has seen them, the bot runs `outsrc updates ack <id>...` (or `--all`) so they are not repeated. The bot upgrades only after the owner says yes.
271
+
170
272
  ## Limits
171
273
 
172
274
  outsrc checks that thread and run paths stay inside its state directory, and it signals a process only if it is still the job outsrc started. By default at most 4 threads work at once and a run is stopped after 2 hours (both settings). A run log stops growing at 1 MiB, and `log` returns at most 8 KiB.
package/dist/cli.js CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
2
3
  import { defaultConfigPath, defaultHome, loadConfigFile } from "./config.js";
3
4
  import { migrateLegacy } from "./migrate.js";
5
+ import { modelCachePath, readModelCache } from "./model-cache.js";
4
6
  import { changeSetting, listSettings } from "./settings.js";
5
7
  import { defaultInitEnv, parseInitArgs, runInit } from "./init.js";
6
8
  import { createMailbox } from "./mailbox.js";
7
9
  import { installEngine, managedScript, readLock, resolveCommit, writeLockEntry } from "./engines.js";
8
10
  import { checkPluginContract, pluginVersion } from "./plugin-contract.js";
9
11
  import { isPluginAdapter, resolvePluginScript } from "./plugins.js";
10
- import { DEFAULT_CALLER, parseCaller } from "./types.js";
12
+ import { startStreamServer } from "./streams.js";
13
+ import { DEFAULT_CALLER, parseCaller, parseThreadId } from "./types.js";
14
+ import { checkForUpdate, describeUpdate, installedVersion, registryFetchLatest, updateAvailable } from "./update-check.js";
15
+ import { ack, ackFile, defaultProbe, readUpdates, refresh, unread, updatesFile } from "./updates.js";
11
16
  function pluginReports(explicit) {
12
17
  return ["codex-plugin", "grok-plugin"].flatMap((adapter) => {
13
18
  const script = explicit[adapter] ?? managedScript(defaultHome(), adapter) ?? resolvePluginScript(adapter);
@@ -36,6 +41,8 @@ function parseMailboxArgs(argv) {
36
41
  rest = callerFlag.rest;
37
42
  const json = hasFlag(rest, "--json");
38
43
  rest = rest.filter((arg) => arg !== "--json");
44
+ const refresh = hasFlag(rest, "--refresh");
45
+ rest = rest.filter((arg) => arg !== "--refresh");
39
46
  const names = ["--repo", "--target", "--message", "--thread", "--thread-id", "--request-id", "--model", "--effort", "--kind", "--base", "--ref", "--run-id", "--offset", "--limit"];
40
47
  const flags = {};
41
48
  for (const name of names) {
@@ -49,6 +56,7 @@ function parseMailboxArgs(argv) {
49
56
  return {
50
57
  caller: callerFlag.value !== undefined ? parseCaller(callerFlag.value) : undefined,
51
58
  json,
59
+ refresh,
52
60
  flags,
53
61
  positionals: rest,
54
62
  };
@@ -59,30 +67,98 @@ function writeJson(body, pretty = true) {
59
67
  const HELP = `outsrc init [--repo <path>]... [--yes] [--json] [--plugins|--no-plugins] [--local claude,codex,grok]
60
68
  [--max-jobs <n|unlimited>] [--max-run-minutes <n|unlimited>]
61
69
  config [list] | config set <key> <value> | config unset <key>
62
- doctor | list_repos | list_targets | targets | threads | prune | migrate
70
+ doctor [--json] | list_repos | list_targets [--refresh] | targets | threads | prune | migrate
71
+ models [list] | models refresh [--target <name>]
63
72
  plugins [--codex <script>] [--grok <script>]
73
+ updates | updates refresh | updates ack <product@version>... | updates ack --all
64
74
  engines pin <codex-plugin|grok-plugin> <commit|branch|tag>
65
75
  send --repo <alias> --target <name> --message <text> [--caller <id>] [--json]
66
76
  [--thread-id <id>] [--request-id <id>] [--model <m>] [--effort <e>]
67
77
  [--kind task|review|adversarial_review] [--base <ref>] [--ref <ref>]
68
78
  inbox <thread_id> [--caller <id>] [--json]
69
79
  history <thread_id> [--caller <id>] [--json]
80
+ usage [--caller <id>] [--json]
70
81
  log <thread_id> [--run-id <id>] [--offset <n>] [--limit <n>] [--caller <id>] [--json]
71
82
  diff <thread_id> [--limit <n>] [--caller <id>] [--json]
72
83
  stop <thread_id> [--caller <id>] [--json]
73
84
  discard <thread_id> [--caller <id>] [--json]
85
+ streams [--thread <id>] [--port <n>] [--recent-minutes <n>] [--no-open] (alias: watch)
74
86
 
75
87
  init sets up config and optional local MCP registration for local CLIs.
76
88
  Public install is the Outsrc bot from Bot Exchange; this CLI is for bot authors and advanced operators.
77
89
 
90
+ streams opens a local browser grid of live run.log tails, one pane per working thread. It only reads
91
+ files under the outsrc home; it never calls a vendor CLI or a model. See outsrc streams --help.
92
+
78
93
  Mailbox commands print JSON (same shapes as the stdio MCP tools). Pass --caller so each
79
- bot only sees its own threads; omit it on doctor/list_*/threads to act as the owner.
94
+ bot only sees its own threads; omit it on doctor/list_*/threads/usage to act as the owner.
80
95
  `;
96
+ const STREAMS_HELP = `outsrc streams [--thread <id>] [--port <n>] [--recent-minutes <n>] [--no-open]
97
+ outsrc watch (same command)
98
+
99
+ Serves a grid of live run logs on http://127.0.0.1 and opens it in your browser.
100
+ One pane per working thread: short id, target, status, branch and age, then the tail of the
101
+ latest run's run.log. Panes follow new output; scroll up to pause, click "paused" to follow again.
102
+ New threads appear without a restart. Finished threads stop growing and move to a muted strip
103
+ (click one to show its log). Threads finished more than --recent-minutes ago (default 15) are hidden.
104
+
105
+ --thread <id> Show only this thread, finished or not; waits for it if it does not exist yet
106
+ --port <n> Listen on this port (default: a free one)
107
+ --recent-minutes <n> How long finished threads stay in the strip (default 15)
108
+ --no-open Print the URL without opening a browser
109
+
110
+ The viewer reads $OUTSRC_HOME/threads (default ~/.outsrc) about once a second, only while a page is
111
+ open. It never calls a vendor CLI or a model and writes nothing. The URL carries a random token and
112
+ the server listens on 127.0.0.1 only. Ctrl-C stops the viewer; jobs keep running.
113
+ `;
114
+ function openBrowser(url) {
115
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
116
+ try {
117
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
118
+ }
119
+ catch { /* the URL is printed anyway */ }
120
+ }
121
+ async function runStreams(argv) {
122
+ if (hasFlag(argv, "--help") || hasFlag(argv, "-h")) {
123
+ process.stdout.write(STREAMS_HELP);
124
+ return;
125
+ }
126
+ let rest = argv.filter((arg) => arg !== "--no-open");
127
+ const thread = takeFlag(rest, "--thread");
128
+ rest = thread.rest;
129
+ const port = takeFlag(rest, "--port");
130
+ rest = port.rest;
131
+ const recent = takeFlag(rest, "--recent-minutes");
132
+ rest = recent.rest;
133
+ if (rest.length)
134
+ throw new Error(`unknown argument: ${rest[0]}\n\n${STREAMS_HELP}`);
135
+ const portNumber = port.value === undefined ? 0 : Number(port.value);
136
+ if (!Number.isInteger(portNumber) || portNumber < 0 || portNumber > 65535)
137
+ throw new Error("--port must be 0-65535");
138
+ const recentMinutes = recent.value === undefined ? 15 : Number(recent.value);
139
+ if (!Number.isFinite(recentMinutes) || recentMinutes < 0)
140
+ throw new Error("--recent-minutes must be a number of minutes");
141
+ const server = await startStreamServer({
142
+ home: defaultHome(), port: portNumber, recentMs: recentMinutes * 60_000,
143
+ ...(thread.value !== undefined ? { thread: parseThreadId(thread.value) } : {}),
144
+ });
145
+ process.stdout.write(`outsrc streams: ${server.url}\nWatching ${defaultHome()}/threads. Ctrl-C stops the viewer; jobs keep running.\n`);
146
+ if (!hasFlag(argv, "--no-open"))
147
+ openBrowser(server.url);
148
+ await new Promise((resolve) => {
149
+ const stop = () => { void server.close().then(resolve); };
150
+ process.once("SIGINT", stop);
151
+ process.once("SIGTERM", stop);
152
+ });
153
+ }
81
154
  const command = process.argv[2] ?? "help";
82
155
  try {
83
156
  if (command === "help" || command === "--help" || command === "-h") {
84
157
  process.stdout.write(HELP);
85
158
  }
159
+ else if (command === "streams" || command === "watch") {
160
+ await runStreams(process.argv.slice(3));
161
+ }
86
162
  else if (command === "init") {
87
163
  const options = parseInitArgs(process.argv.slice(3));
88
164
  const env = defaultInitEnv(defaultConfigPath(defaultHome()), options.json);
@@ -113,6 +189,25 @@ try {
113
189
  writeJson({ pinned: true, entry, next: "Run npm run smoke:plugins. Commit engines.lock.json if it passes; revert it if not." });
114
190
  }
115
191
  }
192
+ else if (command === "updates") {
193
+ const [sub, ...rest] = process.argv.slice(3).filter((arg) => arg !== "--json");
194
+ const home = defaultHome();
195
+ const files = { state: updatesFile(home), acks: ackFile(home) };
196
+ if (sub === undefined || sub === "list") {
197
+ const state = readUpdates(home);
198
+ writeJson({ ...state, unread: unread(home, state), files });
199
+ }
200
+ else if (sub === "refresh" && rest.length === 0) {
201
+ const { state, fresh } = await refresh(home, defaultProbe);
202
+ writeJson({ ...state, new: fresh, unread: unread(home, state), files });
203
+ }
204
+ else if (sub === "ack" && rest.length > 0) {
205
+ const all = rest.includes("--all");
206
+ writeJson({ acked: ack(home, all ? "all" : rest.filter((arg) => arg !== "--all")) });
207
+ }
208
+ else
209
+ throw new Error("usage: outsrc updates [list] | updates refresh | updates ack <product@version>... | updates ack --all");
210
+ }
116
211
  else if (command === "config") {
117
212
  const [sub, key, value, ...extra] = process.argv.slice(3).filter((arg) => arg !== "--json");
118
213
  const configPath = defaultConfigPath(defaultHome());
@@ -144,22 +239,30 @@ try {
144
239
  const parsed = parseMailboxArgs(process.argv.slice(3));
145
240
  // Owner CLI (no --caller) sees every thread for doctor/list/threads/prune.
146
241
  // Mailbox mutations and per-thread reads default to "local" when --caller is omitted.
147
- const ownerCommands = new Set(["doctor", "list_repos", "repos", "list_targets", "targets", "threads", "prune", "migrate"]);
242
+ const ownerCommands = new Set(["doctor", "list_repos", "repos", "list_targets", "targets", "models", "threads", "usage", "prune", "migrate"]);
148
243
  const caller = parsed.caller ?? (ownerCommands.has(command) ? undefined : DEFAULT_CALLER);
149
244
  const box = createMailbox({ home, config, ...(caller !== undefined ? { caller } : {}) });
150
245
  switch (command) {
151
246
  case "doctor": {
152
247
  const targets = box.listTargets().targets;
153
248
  const plugins = Object.values(config.targets).flatMap((target) => target.adapter && isPluginAdapter(target.adapter) && target.command ? [checkPluginContract(target.adapter, target.command)] : []);
249
+ const version = await checkForUpdate(installedVersion(), registryFetchLatest());
250
+ // Reads the last `outsrc updates refresh`; the only network call doctor makes is the npm probe above.
251
+ const updatesState = readUpdates(home);
154
252
  const report = {
253
+ update_available: updateAvailable(version),
254
+ version,
155
255
  node: process.version,
156
256
  config: defaultConfigPath(home),
157
257
  repositories: box.listRepos().repos,
158
258
  targets,
159
259
  plugins,
160
- note: "Checks configuration, executable discovery and the vendor plugin CLI contract. Does not authenticate providers or establish an execution security boundary.",
260
+ updates: { checked_at: updatesState.checked_at, pending: updatesState.pending, unread: unread(home, updatesState) },
261
+ note: "Checks configuration, executable discovery, the vendor plugin CLI contract and whether npm has a newer outsrc. Does not upgrade, authenticate providers or establish an execution security boundary.",
161
262
  };
162
263
  writeJson(report);
264
+ if (!parsed.json)
265
+ process.stderr.write(`${describeUpdate(version)}\n`);
163
266
  if (targets.some((target) => !target.available) || plugins.some((plugin) => plugin.status === "broken"))
164
267
  process.exitCode = 1;
165
268
  break;
@@ -170,11 +273,29 @@ try {
170
273
  break;
171
274
  case "list_targets":
172
275
  case "targets":
173
- writeJson(box.listTargets());
276
+ writeJson(box.listTargets({ refresh: parsed.refresh }));
277
+ break;
278
+ case "models": {
279
+ const [sub, ...extra] = parsed.positionals;
280
+ if (extra.length || (sub !== undefined && sub !== "list" && sub !== "refresh"))
281
+ throw new Error("usage: outsrc models [list] | models refresh [--target <name>]");
282
+ const target = parsed.flags["--target"];
283
+ if (sub === "refresh")
284
+ writeJson({ path: modelCachePath(home), ...box.refreshModels(target) });
285
+ else
286
+ writeJson({ path: modelCachePath(home), targets: readModelCache(home) });
174
287
  break;
288
+ }
175
289
  case "threads":
176
290
  writeJson(box.threads());
177
291
  break;
292
+ case "usage": {
293
+ const result = box.usage();
294
+ writeJson(result);
295
+ if (!result.ok)
296
+ process.exitCode = 1;
297
+ break;
298
+ }
178
299
  case "migrate": {
179
300
  const migrated = migrateLegacy({ home, config });
180
301
  writeJson(migrated);
@@ -294,7 +415,7 @@ try {
294
415
  catch (error) {
295
416
  const message = error instanceof Error ? error.message : String(error);
296
417
  process.exitCode = 1;
297
- const mailbox = new Set(["send", "inbox", "history", "log", "diff", "stop", "discard"]);
418
+ const mailbox = new Set(["send", "inbox", "history", "usage", "log", "diff", "stop", "discard"]);
298
419
  if (mailbox.has(command))
299
420
  process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
300
421
  else
package/dist/mailbox.d.ts CHANGED
@@ -7,7 +7,9 @@ export declare function createMailbox(ctx: MailboxContext): {
7
7
  path: string;
8
8
  }[];
9
9
  };
10
- listTargets: () => {
10
+ listTargets: (options?: {
11
+ refresh?: boolean;
12
+ }) => {
11
13
  targets: {
12
14
  name: string;
13
15
  adapter: import("./adapters.js").Adapter;
@@ -16,6 +18,7 @@ export declare function createMailbox(ctx: MailboxContext): {
16
18
  default: string;
17
19
  allowed: string[];
18
20
  } | null;
21
+ models_refreshed_at: string | null;
19
22
  effort: {
20
23
  default: string;
21
24
  allowed: string[];
@@ -25,6 +28,17 @@ export declare function createMailbox(ctx: MailboxContext): {
25
28
  resume: boolean;
26
29
  }[];
27
30
  };
31
+ refreshModels: (name?: string) => {
32
+ refreshed: Record<string, {
33
+ adapter: string;
34
+ command: string;
35
+ refreshedAt: string;
36
+ models: {
37
+ default: string;
38
+ allowed: string[];
39
+ } | null;
40
+ }>;
41
+ };
28
42
  send: (input: SendInput) => Promise<SendResult>;
29
43
  inbox: (threadId: string) => InboxResult;
30
44
  threads: () => {
@@ -52,11 +66,102 @@ export declare function createMailbox(ctx: MailboxContext): {
52
66
  ok: boolean;
53
67
  error: string;
54
68
  };
69
+ usage(): {
70
+ ok: boolean;
71
+ as_of: string;
72
+ today: {
73
+ runs: number;
74
+ tokens_in: {
75
+ total: number | null;
76
+ missing_runs: number;
77
+ };
78
+ tokens_out: {
79
+ total: number | null;
80
+ missing_runs: number;
81
+ };
82
+ estimated_cost_usd: {
83
+ total: number | null;
84
+ missing_runs: number;
85
+ };
86
+ wall_minutes: {
87
+ total: number | null;
88
+ missing_runs: number;
89
+ };
90
+ since: string;
91
+ by_target: {
92
+ runs: number;
93
+ tokens_in: {
94
+ total: number | null;
95
+ missing_runs: number;
96
+ };
97
+ tokens_out: {
98
+ total: number | null;
99
+ missing_runs: number;
100
+ };
101
+ estimated_cost_usd: {
102
+ total: number | null;
103
+ missing_runs: number;
104
+ };
105
+ wall_minutes: {
106
+ total: number | null;
107
+ missing_runs: number;
108
+ };
109
+ target: string;
110
+ }[];
111
+ };
112
+ last_7_days: {
113
+ runs: number;
114
+ tokens_in: {
115
+ total: number | null;
116
+ missing_runs: number;
117
+ };
118
+ tokens_out: {
119
+ total: number | null;
120
+ missing_runs: number;
121
+ };
122
+ estimated_cost_usd: {
123
+ total: number | null;
124
+ missing_runs: number;
125
+ };
126
+ wall_minutes: {
127
+ total: number | null;
128
+ missing_runs: number;
129
+ };
130
+ since: string;
131
+ by_target: {
132
+ runs: number;
133
+ tokens_in: {
134
+ total: number | null;
135
+ missing_runs: number;
136
+ };
137
+ tokens_out: {
138
+ total: number | null;
139
+ missing_runs: number;
140
+ };
141
+ estimated_cost_usd: {
142
+ total: number | null;
143
+ missing_runs: number;
144
+ };
145
+ wall_minutes: {
146
+ total: number | null;
147
+ missing_runs: number;
148
+ };
149
+ target: string;
150
+ }[];
151
+ };
152
+ } | {
153
+ ok: boolean;
154
+ error: string;
155
+ };
55
156
  history(threadId: string): {
56
157
  error?: never;
57
158
  ok: boolean;
58
159
  thread_id: ThreadId;
59
160
  runs: {
161
+ target: string;
162
+ effort: string | null;
163
+ model: string | null;
164
+ usage: Omit<import("./usage.js").RunUsage, "model" | "effort">;
60
165
  run_id: import("./types.js").RunId;
61
166
  request_id: string | null;
62
167
  created_at: string;