codiedev 0.10.0-beta.2 → 0.10.0-beta.4

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.
@@ -4,11 +4,16 @@
4
4
  // over the product name, with a "powered by opencode" credit line beneath.
5
5
  // Installed and kept current by `codie`; deleting this file and its tui.json
6
6
  // entry restores the stock logo.
7
+ // Also paints the governed status line beside the prompt: who is spending,
8
+ // how much of which budget, and what is in flight. The numbers and the
9
+ // wording come from the codiedev package's tested usage module, whose
10
+ // absolute path `codie` bakes in when it stages this file.
7
11
  // Mark ported from the retired Grok-fork TUI (assets/logo/logo07.txt), itself
8
12
  // derived from vm-demo/public/codiedev-icon.png — keep aligned to that
9
13
  // intertwined c/d silhouette when refreshing brand assets.
10
14
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
11
15
  import { TextAttributes } from "@opentui/core"
16
+ import { createSignal } from "solid-js"
12
17
 
13
18
  const MARK = [
14
19
  "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿",
@@ -20,6 +25,56 @@ const MARK = [
20
25
  "⠀⠈⠻⢿⣿⣿⣿⠿⠋⠀⠀⠙⠿⣿⣿⣿⡿⠟⠁",
21
26
  ]
22
27
 
28
+ // One poller per TUI process, shared by every slot that shows the line. A
29
+ // failure anywhere here costs the status line and nothing else: the home
30
+ // screen still brands, and the developer still codes.
31
+ const REFRESH_MS = 30_000
32
+
33
+ const [usageLine, setUsageLine] = createSignal("")
34
+
35
+ async function startUsagePolling() {
36
+ let usage: any
37
+ try {
38
+ // Imported here, not at the top of the file. This plugin is copied
39
+ // outside the package, so its resolution is the engine loader's business:
40
+ // a top-level import that fails takes the whole module with it, and the
41
+ // developer silently loses the brand mark that worked before. solid-js
42
+ // stays a static import because @opentui/solid is already this file's JSX
43
+ // runtime — if Solid were missing the logo would be gone regardless.
44
+ const { createRequire } = await import("module")
45
+ usage = createRequire(import.meta.url)(__CODIEDEV_USAGE_MODULE__)
46
+ } catch {
47
+ return
48
+ }
49
+ const key = process.env.CODIEDEV_GATEWAY_KEY
50
+ if (!key || typeof usage?.fetchCodieUsage !== "function") return
51
+ const poll = async () => {
52
+ try {
53
+ const snapshot = await usage.fetchCodieUsage({
54
+ baseUrl: process.env.CODIEDEV_GATEWAY_URL,
55
+ key,
56
+ })
57
+ // A failed poll leaves the last good line up rather than blanking it:
58
+ // a number that stopped updating beats a number that vanished.
59
+ if (snapshot) setUsageLine(usage.formatUsageLine(snapshot))
60
+ } catch {}
61
+ }
62
+ void poll()
63
+ const timer = setInterval(poll, REFRESH_MS)
64
+ // Never hold the process open for a status poll.
65
+ ;(timer as any).unref?.()
66
+ }
67
+
68
+ void startUsagePolling().catch(() => {})
69
+
70
+ function StatusLine(props: { theme: any }) {
71
+ return (
72
+ <text fg={props.theme.textMuted} selectable={false}>
73
+ {usageLine()}
74
+ </text>
75
+ )
76
+ }
77
+
23
78
  const tui: TuiPlugin = async (api) => {
24
79
  api.slots.register({
25
80
  slots: {
@@ -51,6 +106,12 @@ const tui: TuiPlugin = async (api) => {
51
106
  </box>
52
107
  )
53
108
  },
109
+ home_prompt_right(ctx) {
110
+ return <StatusLine theme={ctx.theme.current} />
111
+ },
112
+ session_prompt_right(ctx) {
113
+ return <StatusLine theme={ctx.theme.current} />
114
+ },
54
115
  },
55
116
  })
56
117
  }
@@ -45,6 +45,8 @@ export interface SweepArgs {
45
45
  scanOptions?: ScanOptions;
46
46
  /** How many rollouts may actually be READ in one sweep. */
47
47
  maxScans?: number;
48
+ /** Stop starting new scans once this many ms have elapsed. */
49
+ deadlineMs?: number;
48
50
  }
49
51
  /**
50
52
  * Report every Codex block this machine has not reported yet. Returns how many
@@ -382,8 +382,20 @@ function recordCodexLimitReported(filePath, at, statePath = exports.CODEX_LIMIT_
382
382
  // Candidates are listed generously and settled by one statSync each; only
383
383
  // files that actually changed cost a read. The budget therefore bounds work
384
384
  // without letting idle sessions bury an unreported block.
385
+ //
386
+ // The budget binds only on a COLD START, where nothing has been seen before,
387
+ // so it must be big enough to finish that first pass. A budget of 8 silently
388
+ // starved the older blocked rollouts on a real machine: 49 candidates in the
389
+ // window, the newest 8 all unblocked, nothing reported. A full pass over
390
+ // those 49 costs ~205ms — the backward scan stops at the marker rather than
391
+ // reading each file to its cap — so the budget can afford to cover them.
385
392
  const DEFAULT_CANDIDATES = 200;
386
- const DEFAULT_MAX_SCANS = 8;
393
+ const DEFAULT_MAX_SCANS = 64;
394
+ // The budget bounds how MANY files are read; this bounds how LONG. A count is
395
+ // not enough on a slow or network-mounted disk, and this runs on the user's
396
+ // prompt clock. Whatever is not reached stays unrecorded, so the next prompt
397
+ // picks it up rather than skipping it.
398
+ const DEFAULT_DEADLINE_MS = 1_500;
387
399
  /**
388
400
  * Report every Codex block this machine has not reported yet. Returns how many
389
401
  * events were posted. Never throws — it runs inside a hook.
@@ -407,6 +419,7 @@ async function sweepCodexLimits(args) {
407
419
  const now = Date.now();
408
420
  const touched = {};
409
421
  let budget = args.maxScans ?? DEFAULT_MAX_SCANS;
422
+ const deadline = now + (args.deadlineMs ?? DEFAULT_DEADLINE_MS);
410
423
  const pending = [];
411
424
  for (const file of files) {
412
425
  const print = fingerprint(file);
@@ -417,7 +430,7 @@ async function sweepCodexLimits(args) {
417
430
  // it holds nothing new and does not spend the read budget.
418
431
  if (record && record.size === print.size && record.mtime === print.mtime)
419
432
  continue;
420
- if (budget <= 0)
433
+ if (budget <= 0 || Date.now() >= deadline)
421
434
  break;
422
435
  budget -= 1;
423
436
  const hit = scanCodexRolloutForLimit(file, args.scanOptions);
package/dist/codie.js CHANGED
@@ -51,10 +51,15 @@ const child_process_1 = require("child_process");
51
51
  const url_1 = require("url");
52
52
  const configDir_1 = require("./configDir");
53
53
  const codieLauncher_1 = require("./codieLauncher");
54
+ const codieBrandInstall_1 = require("./codieBrandInstall");
55
+ const codieUsage_1 = require("./codieUsage");
54
56
  const codieRuntime_1 = require("./codieRuntime");
55
57
  const codieRuntimeInstall_1 = require("./codieRuntimeInstall");
56
58
  const codieStartup_1 = require("./codieStartup");
57
59
  const upgradeNudge_1 = require("./upgradeNudge");
60
+ // How long to let the gateway finish booking the last request before asking
61
+ // what the session cost.
62
+ const USAGE_SETTLE_MS = 1200;
58
63
  function git(args) {
59
64
  const result = (0, child_process_1.spawnSync)("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
60
65
  return result.status === 0 ? result.stdout.trim() : "";
@@ -97,32 +102,6 @@ function assetPath(name) {
97
102
  // dist/codie.js → ../assets/<name>; src/codie.ts → ../assets/<name>.
98
103
  return path.join(__dirname, "..", "assets", name);
99
104
  }
100
- // Stage the home-screen branding plugin. TUI plugins only load from tui.json
101
- // in the OpenCode config dir, so the packaged file is copied next to the
102
- // capture plugin and referenced with a relative spec. Best-effort: any
103
- // failure (e.g. a hand-edited tui.json with comments) leaves the stock logo.
104
- function installBrandPlugin() {
105
- try {
106
- // OpenCode resolves its config dir per the XDG spec, so a customized
107
- // XDG_CONFIG_HOME must be honored or tui.json lands where it never reads.
108
- const configDir = path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"), "opencode");
109
- const pluginDir = path.join(configDir, "plugins");
110
- fs.mkdirSync(pluginDir, { recursive: true });
111
- const source = fs.readFileSync(assetPath("tui-brand-plugin.tsx"), "utf8");
112
- const destination = path.join(pluginDir, "codiedev-brand.tsx");
113
- if (!fs.existsSync(destination) || fs.readFileSync(destination, "utf8") !== source) {
114
- fs.writeFileSync(destination, source);
115
- }
116
- const tuiConfigPath = path.join(configDir, "tui.json");
117
- const existing = fs.existsSync(tuiConfigPath) ? JSON.parse(fs.readFileSync(tuiConfigPath, "utf8")) : {};
118
- const { config, changed } = (0, codieLauncher_1.withBrandPlugin)(existing);
119
- if (changed)
120
- fs.writeFileSync(tuiConfigPath, `${JSON.stringify(config, null, 2)}\n`);
121
- }
122
- catch (error) {
123
- console.error(`codie: continuing without Codiedev branding (${error instanceof Error ? error.message : error})`);
124
- }
125
- }
126
105
  // Remove the engine's boot/exit banner wordmark in place (see
127
106
  // patchBrandWordmark). Best-effort like the TUI plugin: any failure rolls the
128
107
  // bytes back and the stock banner keeps working. Runs once per install; an
@@ -352,7 +331,7 @@ async function main() {
352
331
  });
353
332
  const metadataPlugin = (0, url_1.pathToFileURL)(assetPath("opencode-plugin.mjs")).href;
354
333
  const capturePlugin = (0, codieLauncher_1.resolveCapturePlugin)({ homeDir: os.homedir(), existsSync: fs.existsSync });
355
- installBrandPlugin();
334
+ (0, codieBrandInstall_1.installTuiPlugin)();
356
335
  installBrandWordmark(runtime);
357
336
  const config = (0, codieLauncher_1.buildCodieConfig)({
358
337
  existing: existingInlineConfig(),
@@ -374,7 +353,26 @@ async function main() {
374
353
  if (nonInteractiveRun)
375
354
  env.CODIEDEV_SECURITY_BLOCK_OUTPUT = "marker";
376
355
  if (nonInteractiveRun) {
356
+ // The TUI paints its own live status line from the same endpoint. A
357
+ // non-interactive run has no TUI, so the budget arrives as two printed
358
+ // lines instead — and only for a human at a terminal, so a scripted run
359
+ // is neither slowed nor polluted.
360
+ const reportCost = Boolean(process.stderr.isTTY);
361
+ const startedAt = Date.now();
362
+ if (reportCost) {
363
+ const line = (0, codieUsage_1.formatUsageLine)(await (0, codieUsage_1.fetchCodieUsage)({ baseUrl: gatewayUrl, key }));
364
+ if (line)
365
+ console.error(line);
366
+ }
377
367
  process.exitCode = (await runNonInteractive(runtime, runtimeArgs, env)) ?? 1;
368
+ if (reportCost) {
369
+ // The gateway books a request's cost after it has answered, so a poll
370
+ // fired the instant the run closes can miss the last one.
371
+ await new Promise((resolve) => setTimeout(resolve, USAGE_SETTLE_MS));
372
+ const cost = (0, codieUsage_1.formatSessionCostLine)(await (0, codieUsage_1.fetchCodieUsage)({ baseUrl: gatewayUrl, key, sinceMs: startedAt }));
373
+ if (cost)
374
+ console.error(cost);
375
+ }
378
376
  return;
379
377
  }
380
378
  const result = (0, child_process_1.spawnSync)(runtime, runtimeArgs, { stdio: "inherit", env });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The usage module this install should load. The shipped package has the
3
+ * compiled file; a source checkout (and the test run) has the TypeScript one.
4
+ * Naming a path that does not exist would cost the status line silently, so a
5
+ * miss raises instead and the caller falls back to the plain plugin.
6
+ */
7
+ export declare function usageModulePath(): string;
8
+ /** The engine resolves its config dir per the XDG spec, so a customized
9
+ * XDG_CONFIG_HOME must be honored or tui.json lands where it never reads. */
10
+ export declare function openCodeConfigDir(): string;
11
+ export declare function installTuiPlugin(): void;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ // Stages the Codiedev TUI plugin into the engine's config directory.
3
+ //
4
+ // TUI plugins load only from tui.json in the OpenCode config dir, so the
5
+ // packaged template is copied there and referenced by a relative spec. The
6
+ // copy also carries the status line, which needs an absolute path back into
7
+ // this package — the plugin sits outside it and cannot reach it any other way.
8
+ //
9
+ // Every step is best-effort: a developer who cannot write this directory still
10
+ // gets a working harness, just the stock logo and no status line.
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.usageModulePath = usageModulePath;
46
+ exports.openCodeConfigDir = openCodeConfigDir;
47
+ exports.installTuiPlugin = installTuiPlugin;
48
+ const fs = __importStar(require("fs"));
49
+ const os = __importStar(require("os"));
50
+ const path = __importStar(require("path"));
51
+ const codieLauncher_1 = require("./codieLauncher");
52
+ function assetPath(name) {
53
+ // dist/codieBrandInstall.js → ../assets/<name>; src/… → ../assets/<name>.
54
+ return path.join(__dirname, "..", "assets", name);
55
+ }
56
+ /**
57
+ * The usage module this install should load. The shipped package has the
58
+ * compiled file; a source checkout (and the test run) has the TypeScript one.
59
+ * Naming a path that does not exist would cost the status line silently, so a
60
+ * miss raises instead and the caller falls back to the plain plugin.
61
+ */
62
+ function usageModulePath() {
63
+ const candidates = [
64
+ path.join(__dirname, "codieUsage.js"),
65
+ path.join(__dirname, "codieUsage.ts"),
66
+ ];
67
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
68
+ if (!found)
69
+ throw new Error("this install has no Codiedev usage module");
70
+ return found;
71
+ }
72
+ /** The engine resolves its config dir per the XDG spec, so a customized
73
+ * XDG_CONFIG_HOME must be honored or tui.json lands where it never reads. */
74
+ function openCodeConfigDir() {
75
+ return path.join(process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config"), "opencode");
76
+ }
77
+ function installTuiPlugin() {
78
+ try {
79
+ const configDir = openCodeConfigDir();
80
+ const pluginDir = path.join(configDir, "plugins");
81
+ fs.mkdirSync(pluginDir, { recursive: true });
82
+ const source = (0, codieLauncher_1.stageUsageModule)(fs.readFileSync(assetPath("tui-brand-plugin.tsx"), "utf8"), usageModulePath());
83
+ const destination = path.join(pluginDir, path.basename(codieLauncher_1.BRAND_PLUGIN_SPEC));
84
+ if (!fs.existsSync(destination) || fs.readFileSync(destination, "utf8") !== source) {
85
+ fs.writeFileSync(destination, source);
86
+ }
87
+ const tuiConfigPath = path.join(configDir, "tui.json");
88
+ const existing = fs.existsSync(tuiConfigPath)
89
+ ? JSON.parse(fs.readFileSync(tuiConfigPath, "utf8"))
90
+ : {};
91
+ const { config, changed } = (0, codieLauncher_1.withBrandPlugin)(existing);
92
+ if (changed)
93
+ fs.writeFileSync(tuiConfigPath, `${JSON.stringify(config, null, 2)}\n`);
94
+ }
95
+ catch (error) {
96
+ console.error(`codie: continuing without Codiedev branding (${error instanceof Error ? error.message : error})`);
97
+ }
98
+ }
@@ -1,7 +1,30 @@
1
+ export interface ModelCost {
2
+ /** Dollars per million prompt tokens. */
3
+ input: number;
4
+ /** Dollars per million completion tokens. */
5
+ output: number;
6
+ }
7
+ export interface ModelLimit {
8
+ /** Maximum prompt tokens the model accepts. */
9
+ context: number;
10
+ /** Maximum completion tokens the model emits. */
11
+ output: number;
12
+ }
1
13
  export interface CatalogModel {
2
14
  id: string;
3
15
  name?: string;
16
+ cost?: ModelCost;
17
+ limit?: ModelLimit;
4
18
  }
19
+ /** A rate card is usable only when both sides are real, finite and not
20
+ * negative. Half a card prices a session wrong in a way nobody can see, so a
21
+ * partial one is dropped and the model stays unpriced. */
22
+ export declare function usableModelCost(value: unknown): ModelCost | undefined;
23
+ /** A context/output window is usable only when both sides are real, finite and
24
+ * positive. A partial window makes the engine's "% of context used" read wrong
25
+ * in a way nobody can see, so a partial one is dropped and the model keeps the
26
+ * engine's default. */
27
+ export declare function usableModelLimit(value: unknown): ModelLimit | undefined;
5
28
  export declare const CODIE_SECURITY_BLOCK_MARKER = "__CODIEDEV_SECURITY_BLOCK__";
6
29
  /** Extract the private adapter marker used to carry a local block reason
7
30
  * across OpenCode's generic 500 boundary. The marker never contains prompt or
@@ -22,6 +45,16 @@ export declare function buildCodieConfig({ existing, catalog, env, plugin, plugi
22
45
  plugin?: string;
23
46
  plugins?: string[];
24
47
  }): Record<string, any>;
48
+ export declare const USAGE_MODULE_PLACEHOLDER = "__CODIEDEV_USAGE_MODULE__";
49
+ /**
50
+ * Point the staged TUI plugin at this install's usage module.
51
+ *
52
+ * The plugin is copied into the engine's own config directory, so it cannot
53
+ * reach the package by a relative path — the absolute path is baked in at
54
+ * stage time. It goes in as a JSON literal so a Windows path keeps its
55
+ * backslashes instead of turning into escape sequences.
56
+ */
57
+ export declare function stageUsageModule(source: string, modulePath: string): string;
25
58
  export declare const BRAND_PLUGIN_SPEC = "./plugins/codiedev-brand.tsx";
26
59
  export declare function withBrandPlugin(existing: Record<string, any> | undefined): {
27
60
  config: Record<string, any>;
@@ -42,11 +42,14 @@ var __importStar = (this && this.__importStar) || (function () {
42
42
  };
43
43
  })();
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.HIDDEN_WORDMARK = exports.CODIEDEV_WORDMARK = exports.LEGACY_CODIEDEV_WORDMARK = exports.STOCK_WORDMARK = exports.BRAND_PLUGIN_SPEC = exports.CODIE_SECURITY_BLOCK_MARKER = void 0;
45
+ exports.HIDDEN_WORDMARK = exports.CODIEDEV_WORDMARK = exports.LEGACY_CODIEDEV_WORDMARK = exports.STOCK_WORDMARK = exports.BRAND_PLUGIN_SPEC = exports.USAGE_MODULE_PLACEHOLDER = exports.CODIE_SECURITY_BLOCK_MARKER = void 0;
46
+ exports.usableModelCost = usableModelCost;
47
+ exports.usableModelLimit = usableModelLimit;
46
48
  exports.extractCodieSecurityBlock = extractCodieSecurityBlock;
47
49
  exports.withCodieWorkingDirectory = withCodieWorkingDirectory;
48
50
  exports.bareModel = bareModel;
49
51
  exports.buildCodieConfig = buildCodieConfig;
52
+ exports.stageUsageModule = stageUsageModule;
50
53
  exports.withBrandPlugin = withBrandPlugin;
51
54
  exports.patchBrandWordmark = patchBrandWordmark;
52
55
  exports.resolveCapturePlugin = resolveCapturePlugin;
@@ -64,6 +67,37 @@ const DEFAULT_NAMES = {
64
67
  "z-ai/glm-5.2": "GLM 5.2 · Fireworks ZDR",
65
68
  "moonshotai/kimi-k2.7-code": "Kimi K2.7 Code · Fireworks ZDR",
66
69
  };
70
+ /** A rate card is usable only when both sides are real, finite and not
71
+ * negative. Half a card prices a session wrong in a way nobody can see, so a
72
+ * partial one is dropped and the model stays unpriced. */
73
+ function usableModelCost(value) {
74
+ if (!value || typeof value !== "object")
75
+ return undefined;
76
+ const { input, output } = value;
77
+ if (typeof input !== "number" || typeof output !== "number")
78
+ return undefined;
79
+ if (!Number.isFinite(input) || !Number.isFinite(output))
80
+ return undefined;
81
+ if (input < 0 || output < 0)
82
+ return undefined;
83
+ return { input, output };
84
+ }
85
+ /** A context/output window is usable only when both sides are real, finite and
86
+ * positive. A partial window makes the engine's "% of context used" read wrong
87
+ * in a way nobody can see, so a partial one is dropped and the model keeps the
88
+ * engine's default. */
89
+ function usableModelLimit(value) {
90
+ if (!value || typeof value !== "object")
91
+ return undefined;
92
+ const { context, output } = value;
93
+ if (typeof context !== "number" || typeof output !== "number")
94
+ return undefined;
95
+ if (!Number.isFinite(context) || !Number.isFinite(output))
96
+ return undefined;
97
+ if (context <= 0 || output <= 0)
98
+ return undefined;
99
+ return { context, output };
100
+ }
67
101
  exports.CODIE_SECURITY_BLOCK_MARKER = "__CODIEDEV_SECURITY_BLOCK__";
68
102
  /** Extract the private adapter marker used to carry a local block reason
69
103
  * across OpenCode's generic 500 boundary. The marker never contains prompt or
@@ -143,10 +177,23 @@ function buildCodieConfig({ existing = {}, catalog, env = {}, plugin, plugins =
143
177
  const planModel = governedModel(requestedPlan, approved, fast, "Plan agent");
144
178
  const existingPlugins = Array.isArray(existing.plugin) ? existing.plugin : [];
145
179
  const requiredPlugins = [...plugins, plugin].filter(Boolean);
146
- const models = Object.fromEntries(catalog.map((model) => [
147
- bareModel(model.id),
148
- { name: model.name || DEFAULT_NAMES[bareModel(model.id)] || bareModel(model.id) },
149
- ]));
180
+ const models = Object.fromEntries(catalog.map((model) => {
181
+ // The engine prices a session from this rate card. Without it every
182
+ // model reads $0.00, which is how the harness came to show no spend at
183
+ // all. The gateway publishes prompt and completion prices only, so
184
+ // cached tokens are priced at zero here and the engine's session figure
185
+ // stays an estimate — the budget line from /me/usage is the true spend.
186
+ const cost = usableModelCost(model.cost);
187
+ const limit = usableModelLimit(model.limit);
188
+ return [
189
+ bareModel(model.id),
190
+ {
191
+ name: model.name || DEFAULT_NAMES[bareModel(model.id)] || bareModel(model.id),
192
+ ...(cost ? { cost: { ...cost, cache: { read: 0, write: 0 } } } : {}),
193
+ ...(limit ? { limit } : {}),
194
+ },
195
+ ];
196
+ }));
150
197
  return {
151
198
  ...existing,
152
199
  model: governedModel(primary, approved, first, "Primary agent"),
@@ -174,6 +221,21 @@ function buildCodieConfig({ existing = {}, catalog, env = {}, plugin, plugins =
174
221
  plugin: [...existingPlugins.filter((item) => !requiredPlugins.includes(item)), ...requiredPlugins],
175
222
  };
176
223
  }
224
+ exports.USAGE_MODULE_PLACEHOLDER = "__CODIEDEV_USAGE_MODULE__";
225
+ /**
226
+ * Point the staged TUI plugin at this install's usage module.
227
+ *
228
+ * The plugin is copied into the engine's own config directory, so it cannot
229
+ * reach the package by a relative path — the absolute path is baked in at
230
+ * stage time. It goes in as a JSON literal so a Windows path keeps its
231
+ * backslashes instead of turning into escape sequences.
232
+ */
233
+ function stageUsageModule(source, modulePath) {
234
+ if (!source.includes(exports.USAGE_MODULE_PLACEHOLDER)) {
235
+ throw new Error(`plugin template is missing the ${exports.USAGE_MODULE_PLACEHOLDER} placeholder`);
236
+ }
237
+ return source.split(exports.USAGE_MODULE_PLACEHOLDER).join(JSON.stringify(modulePath));
238
+ }
177
239
  exports.BRAND_PLUGIN_SPEC = "./plugins/codiedev-brand.tsx";
178
240
  // Pure merge for ~/.config/opencode/tui.json: TUI plugins load from tui.json,
179
241
  // not the opencode.json we inject via OPENCODE_CONFIG_CONTENT.
@@ -332,10 +394,16 @@ async function fetchApprovedCatalog({ baseUrl, key, fetchImpl = fetch, }) {
332
394
  if (!Array.isArray(payload?.data) || payload.data.length === 0) {
333
395
  throw new Error("model catalog is empty");
334
396
  }
335
- return payload.data.map((model) => ({
336
- id: bareModel(model.id),
337
- name: typeof model.name === "string" && model.name ? model.name : DEFAULT_NAMES[bareModel(model.id)],
338
- }));
397
+ return payload.data.map((model) => {
398
+ const cost = usableModelCost(model.cost);
399
+ const limit = usableModelLimit(model.limit);
400
+ return {
401
+ id: bareModel(model.id),
402
+ name: typeof model.name === "string" && model.name ? model.name : DEFAULT_NAMES[bareModel(model.id)],
403
+ ...(cost ? { cost } : {}),
404
+ ...(limit ? { limit } : {}),
405
+ };
406
+ });
339
407
  }
340
408
  finally {
341
409
  clearTimeout(timeout);
@@ -0,0 +1,57 @@
1
+ export interface CodieUsageBudget {
2
+ companyBudgetUsd: number | null;
3
+ companySpentUsd: number;
4
+ companyHeldUsd: number;
5
+ userBudgetUsd: number | null;
6
+ userSpentUsd: number;
7
+ userHeldUsd: number;
8
+ keyBudgetUsd: number | null;
9
+ keySpentUsd: number;
10
+ keyHeldUsd: number | null;
11
+ }
12
+ export interface CodieUsageInFlight {
13
+ /** Reservations open inside the gateway's hold TTL: real work running now. */
14
+ requests: number;
15
+ heldUsd: number;
16
+ /** Reservations past the TTL, waiting for the sweep. They hold budget
17
+ * without doing work, so they are never counted as live work. */
18
+ staleRequests: number;
19
+ staleHeldUsd: number;
20
+ }
21
+ export interface CodieUsage {
22
+ enabled: boolean;
23
+ user: {
24
+ name: string;
25
+ email: string;
26
+ role: string;
27
+ } | null;
28
+ budget: CodieUsageBudget;
29
+ inFlight: CodieUsageInFlight;
30
+ since?: {
31
+ costUsd: number;
32
+ promptTokens: number;
33
+ completionTokens: number;
34
+ requests: number;
35
+ };
36
+ }
37
+ /**
38
+ * One line: who is spending, how much, against what, and what is running.
39
+ * Returns "" when there is nothing honest to say.
40
+ */
41
+ export declare function formatUsageLine(usage: CodieUsage | null | undefined): string;
42
+ /**
43
+ * What this session just cost. Returns "" when the session billed nothing,
44
+ * because a developer who spent nothing does not need to be told a number.
45
+ */
46
+ export declare function formatSessionCostLine(usage: CodieUsage | null | undefined): string;
47
+ /**
48
+ * Read the developer's budget snapshot. Returns null on any refusal, network
49
+ * failure, timeout, or malformed answer — a status poll is never worth an
50
+ * error in front of a developer who asked for none.
51
+ */
52
+ export declare function fetchCodieUsage({ baseUrl, key, sinceMs, fetchImpl, }: {
53
+ baseUrl?: string;
54
+ key: string;
55
+ sinceMs?: number;
56
+ fetchImpl?: typeof fetch;
57
+ }): Promise<CodieUsage | null>;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ // The Codie harness status line.
3
+ //
4
+ // The gateway already knows what a developer has spent, what caps them, and
5
+ // what they have in flight. Nothing ever asked it. This module asks, and turns
6
+ // the answer into one line the harness can paint next to the prompt.
7
+ //
8
+ // Every failure here is silent by design: a status poll must never interrupt,
9
+ // delay, or break a coding session. No snapshot means no line.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.formatUsageLine = formatUsageLine;
12
+ exports.formatSessionCostLine = formatSessionCostLine;
13
+ exports.fetchCodieUsage = fetchCodieUsage;
14
+ const USAGE_TIMEOUT_MS = 4000;
15
+ function cleanBaseUrl(value) {
16
+ return String(value || "https://gateway.codiedev.com/api/v1").replace(/\/+$/, "");
17
+ }
18
+ function money(value) {
19
+ return `$${value.toFixed(2)}`;
20
+ }
21
+ /** Budgets are set in whole dollars far more often than not, so $150 reads
22
+ * better than $150.00 next to a spend figure that does need its cents. */
23
+ function budgetMoney(value) {
24
+ return Number.isInteger(value) ? `$${value}` : money(value);
25
+ }
26
+ function percent(spent, budget) {
27
+ if (!(budget > 0))
28
+ return null;
29
+ const share = (spent / budget) * 100;
30
+ // A first request against a large budget rounds to 0%, which reads as
31
+ // "nothing spent". Say "under one percent" instead.
32
+ if (share > 0 && share < 1)
33
+ return "<1%";
34
+ return `${Math.round(share)}%`;
35
+ }
36
+ /**
37
+ * One line: who is spending, how much, against what, and what is running.
38
+ * Returns "" when there is nothing honest to say.
39
+ */
40
+ function formatUsageLine(usage) {
41
+ if (!usage || !usage.enabled)
42
+ return "";
43
+ const { budget, inFlight } = usage;
44
+ const segments = [];
45
+ if (usage.user?.name)
46
+ segments.push(usage.user.name);
47
+ // The developer's own month-to-date spend is always the headline. Only the
48
+ // budget that actually caps them earns a percentage next to it.
49
+ if (typeof budget.userBudgetUsd === "number") {
50
+ segments.push(`${money(budget.userSpentUsd)} / ${budgetMoney(budget.userBudgetUsd)}`);
51
+ const share = percent(budget.userSpentUsd, budget.userBudgetUsd);
52
+ if (share)
53
+ segments.push(share);
54
+ }
55
+ else {
56
+ segments.push(money(budget.userSpentUsd));
57
+ if (typeof budget.companyBudgetUsd === "number") {
58
+ const share = percent(budget.companySpentUsd, budget.companyBudgetUsd);
59
+ // Labelled, because a team percentage beside a personal dollar figure
60
+ // otherwise reads as the developer's own share.
61
+ if (share)
62
+ segments.push(`team ${share}`);
63
+ }
64
+ }
65
+ if (inFlight.requests > 0)
66
+ segments.push(`${inFlight.requests} in flight`);
67
+ // Stale holds cap a budget while doing no work. Naming them is the
68
+ // difference between "I am blocked" and "I am blocked by nothing".
69
+ if (inFlight.staleHeldUsd > 0)
70
+ segments.push(`${money(inFlight.staleHeldUsd)} held stale`);
71
+ return segments.join(" · ");
72
+ }
73
+ function tokens(total) {
74
+ // Rounded by arithmetic, not toFixed: 2.55 is not exactly representable, so
75
+ // toFixed(1) rounds it down and the same count can read two ways.
76
+ if (total >= 1_000_000)
77
+ return `${Math.round(total / 100_000) / 10}M`;
78
+ if (total >= 1_000)
79
+ return `${Math.round(total / 1_000)}k`;
80
+ return String(total);
81
+ }
82
+ /**
83
+ * What this session just cost. Returns "" when the session billed nothing,
84
+ * because a developer who spent nothing does not need to be told a number.
85
+ */
86
+ function formatSessionCostLine(usage) {
87
+ const since = readSince(usage?.since);
88
+ if (!since || since.requests <= 0)
89
+ return "";
90
+ // A real charge that rounds to $0.00 reads as free. Say it is under a cent
91
+ // instead — the month-to-date figure is where it really lands.
92
+ const cost = since.costUsd > 0 && since.costUsd < 0.005 ? "<$0.01" : money(since.costUsd);
93
+ const requests = `${since.requests} ${since.requests === 1 ? "request" : "requests"}`;
94
+ return `This session: ${cost} · ${requests} · ${tokens(since.promptTokens + since.completionTokens)} tokens`;
95
+ }
96
+ /**
97
+ * A usage window is reported only when every field of it is a real number.
98
+ * A partial block is worse than none: it reads as a cheap session, and the
99
+ * arithmetic on a missing field throws out of a call site whose whole
100
+ * contract is that it cannot break the run that just finished.
101
+ */
102
+ function readSince(value) {
103
+ if (!value || typeof value !== "object" || Array.isArray(value))
104
+ return undefined;
105
+ const { costUsd, promptTokens, completionTokens, requests } = value;
106
+ const finite = (candidate) => typeof candidate === "number" && Number.isFinite(candidate);
107
+ if (!finite(costUsd) || !finite(promptTokens) || !finite(completionTokens) || !finite(requests)) {
108
+ return undefined;
109
+ }
110
+ return { costUsd, promptTokens, completionTokens, requests };
111
+ }
112
+ function readNumber(value, fallback = 0) {
113
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
114
+ }
115
+ function readBudgetCap(value) {
116
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
117
+ }
118
+ function readUser(value) {
119
+ if (!value || typeof value !== "object")
120
+ return null;
121
+ const { name, email, role } = value;
122
+ if (typeof name !== "string" || !name)
123
+ return null;
124
+ return {
125
+ name,
126
+ email: typeof email === "string" ? email : "",
127
+ role: typeof role === "string" ? role : "",
128
+ };
129
+ }
130
+ /**
131
+ * Read the developer's budget snapshot. Returns null on any refusal, network
132
+ * failure, timeout, or malformed answer — a status poll is never worth an
133
+ * error in front of a developer who asked for none.
134
+ */
135
+ async function fetchCodieUsage({ baseUrl, key, sinceMs, fetchImpl = fetch, }) {
136
+ const controller = new AbortController();
137
+ const timeout = setTimeout(() => controller.abort(), USAGE_TIMEOUT_MS);
138
+ try {
139
+ const query = typeof sinceMs === "number" && sinceMs > 0 ? `?since=${sinceMs}` : "";
140
+ const response = await fetchImpl(`${cleanBaseUrl(baseUrl)}/me/usage${query}`, {
141
+ headers: { Authorization: `Bearer ${key}` },
142
+ signal: controller.signal,
143
+ });
144
+ if (!response.ok)
145
+ return null;
146
+ const payload = (await response.json());
147
+ const budget = payload?.budget;
148
+ if (!budget || typeof budget !== "object")
149
+ return null;
150
+ const raw = budget;
151
+ const flight = (payload?.inFlight ?? {});
152
+ return {
153
+ enabled: payload?.enabled === true,
154
+ user: readUser(payload?.user),
155
+ budget: {
156
+ companyBudgetUsd: readBudgetCap(raw.companyBudgetUsd),
157
+ companySpentUsd: readNumber(raw.companySpentUsd),
158
+ companyHeldUsd: readNumber(raw.companyHeldUsd),
159
+ userBudgetUsd: readBudgetCap(raw.userBudgetUsd),
160
+ userSpentUsd: readNumber(raw.userSpentUsd),
161
+ userHeldUsd: readNumber(raw.userHeldUsd),
162
+ keyBudgetUsd: readBudgetCap(raw.keyBudgetUsd),
163
+ keySpentUsd: readNumber(raw.keySpentUsd),
164
+ keyHeldUsd: typeof raw.keyHeldUsd === "number" ? raw.keyHeldUsd : null,
165
+ },
166
+ inFlight: {
167
+ requests: readNumber(flight.requests),
168
+ heldUsd: readNumber(flight.heldUsd),
169
+ staleRequests: readNumber(flight.staleRequests),
170
+ staleHeldUsd: readNumber(flight.staleHeldUsd),
171
+ },
172
+ ...(() => {
173
+ const since = readSince(payload?.since);
174
+ return since ? { since } : {};
175
+ })(),
176
+ };
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ finally {
182
+ clearTimeout(timeout);
183
+ }
184
+ }
package/dist/hook.js CHANGED
@@ -217,14 +217,10 @@ async function runCodexLimitSweep(sessionId) {
217
217
  // A shorter timeout than the default: this is on the user's prompt
218
218
  // clock, and the next prompt retries anything that does not land.
219
219
  post: (payload) => (0, limitEventReporter_1.postLimitEvent)(config, payload, PROMPT_LIMIT_POST_TIMEOUT_MS),
220
- // This runs on the prompt path, so keep it bounded. Unchanged rollouts
221
- // cost one statSync and do not spend the read budget, so a busy machine
222
- // cannot bury an unreported block behind newer idle sessions. When a
223
- // block is still unreported it sits at the END of its rollout, so a
224
- // small scan window is enough; the Stop path keeps the larger default
225
- // for a session that kept going after the window reset.
220
+ // Unchanged rollouts cost one statSync and do not spend the read
221
+ // budget, so in steady state this is nearly free. The module default
222
+ // covers a cold start; see DEFAULT_MAX_SCANS for why it is not small.
226
223
  findOptions: { withinMs: 24 * 3600_000 },
227
- maxScans: 8,
228
224
  scanOptions: { maxScanBytes: 4 * 1024 * 1024 },
229
225
  });
230
226
  if (posted > 0) {
@@ -51,11 +51,36 @@ function parseLimitEventFromTranscript(transcript) {
51
51
  }
52
52
  return found;
53
53
  }
54
+ /**
55
+ * Codex's `rate_limit_reached_type` is exactly five values (read off the 0.154
56
+ * binary): `rate_limit_reached`, `workspace_{owner,member}_credits_depleted`
57
+ * and `workspace_{owner,member}_usage_limit_reached`.
58
+ *
59
+ * The split that matters to a reader of the dashboard is "someone must act on
60
+ * money" vs "this dev is throttled and it will reset" — so map on what the
61
+ * snapshot actually asserts, not on the word "credits":
62
+ *
63
+ * - A member out of their OWN allowance is a rate limit. Real snapshots carry
64
+ * `spend_control_reached: null` and `individual_limit: null` — nobody
65
+ * configured a budget, the window is simply used up. Calling that personal
66
+ * spend tells a manager the dev overspent, which is not what happened.
67
+ *
68
+ * Deliberately NOT gated on `individual_limit`: it has only ever been
69
+ * observed null, so its populated shape and meaning are unknown, and a
70
+ * wrong guess reinstates the mislabel this exists to remove. The reason
71
+ * string is the causal signal; `individual_limit` is at best correlative.
72
+ * - A member whose ADMIN capped them (`usage_limit_reached`) is personal spend:
73
+ * there really is a cap, and someone can raise it.
74
+ * - Anything owner-scoped is org spend — the org's allowance or credits are
75
+ * gone and somebody has to act.
76
+ */
54
77
  function codexLimitKind(reason, spendControlReached) {
55
78
  if (spendControlReached || reason.startsWith("workspace_owner_"))
56
79
  return "org_spend";
57
- if (reason.startsWith("workspace_member_"))
80
+ if (reason === "workspace_member_usage_limit_reached")
58
81
  return "user_spend";
82
+ if (reason === "workspace_member_credits_depleted")
83
+ return "session";
59
84
  return reason === "rate_limit_reached" ? "session" : "other";
60
85
  }
61
86
  function codexResetText(rateLimits) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.10.0-beta.2",
3
+ "version": "0.10.0-beta.4",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",
@@ -57,7 +57,7 @@
57
57
  "devDependencies": {
58
58
  "@types/node": "^20.0.0",
59
59
  "typescript": "^5.4.0",
60
- "vitest": "^3.2.6"
60
+ "vitest": "^4.1.11"
61
61
  },
62
62
  "dependencies": {
63
63
  "@modelcontextprotocol/sdk": "^1.30.0"