@bitkyc08/opencodex 2.77.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -0,0 +1,123 @@
1
+ import { JEV_DECISION_GATES } from "../usage/jev-stats";
2
+ import { runCatalogAction } from "./catalog-command-result";
3
+ import { CliUsageError, printData, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
4
+
5
+ const USAGE = "Usage: ocx combo stats <stored-id> [--range <7d|30d|all>] [--json]";
6
+ const SUMMARY_COUNTS = [
7
+ "decisions", "appliedDecisions", "failOpenDecisions", "successfulRequests", "requestsWithModelFallback",
8
+ "modelAttempts", "measuredModelAttempts", "modelInputTokens", "modelOutputTokens", "modelReasoningTokens",
9
+ "modelCacheReadTokens", "modelCacheWriteTokens", "modelTotalTokens", "decisionUsageReported",
10
+ "decisionInputTokens", "decisionOutputTokens", "decisionTotalTokens",
11
+ ] as const;
12
+ const MODEL_COUNTS = [
13
+ "picks", "appliedPicks", "failOpenPicks", "attempts", "measuredAttempts", "inputTokens", "outputTokens",
14
+ "reasoningTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens",
15
+ ] as const;
16
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
17
+
18
+ function invalid(): never { throw new Error("Invalid combo statistics response"); }
19
+ function record(value: unknown): Record<string, unknown> {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
21
+ return value as Record<string, unknown>;
22
+ }
23
+ function number(value: unknown): number {
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) return invalid();
25
+ return value;
26
+ }
27
+ function nullable(value: unknown, max = Number.MAX_SAFE_INTEGER): number | null {
28
+ if (value === null) return null;
29
+ const result = number(value);
30
+ return result <= max ? result : invalid();
31
+ }
32
+ function boolean(value: unknown): boolean {
33
+ return typeof value === "boolean" ? value : invalid();
34
+ }
35
+ function identity(value: unknown, empty = false): string {
36
+ if (typeof value !== "string" || (!empty && !value.trim()) || value.length > 512
37
+ || /[\u0000-\u001f\u007f]/u.test(value)) return invalid();
38
+ return value;
39
+ }
40
+ function counts<K extends string>(value: Record<string, unknown>, keys: readonly K[]): Record<K, number> {
41
+ // Every requested key is assigned only after numeric validation. fromEntries
42
+ // loses the literal-key set in its library type, so retain that set here.
43
+ return Object.fromEntries(keys.map(key => [key, number(value[key])])) as Record<K, number>;
44
+ }
45
+ function rows(value: unknown): Array<Record<string, unknown>> {
46
+ return Array.isArray(value) ? value.map(record) : invalid();
47
+ }
48
+
49
+ /** Rebuild only the real JEV wire contract; never expose unknown server fields. */
50
+ function projectStats(value: unknown, id: string, range: string) {
51
+ const dto = record(value);
52
+ if (dto.comboId !== id || dto.range !== range) return invalid();
53
+ const rawSummary = record(dto.summary);
54
+ const summary = {
55
+ ...counts(rawSummary, SUMMARY_COUNTS),
56
+ averageLatencyMs: nullable(rawSummary.averageLatencyMs),
57
+ averageConfidence: nullable(rawSummary.averageConfidence, 1),
58
+ averageChosenProbability: nullable(rawSummary.averageChosenProbability, 1),
59
+ };
60
+ const gates = rows(dto.gates).map(row => {
61
+ if (!(JEV_DECISION_GATES as readonly unknown[]).includes(row.gate)) return invalid();
62
+ return { gate: row.gate as string, decisions: number(row.decisions) };
63
+ });
64
+ const backends = rows(dto.backends).map(row => {
65
+ if (!["typesafe", "systemone", "model", "unknown"].includes(String(row.backend))) return invalid();
66
+ return { backend: row.backend as string, decisions: number(row.decisions), applied: number(row.applied), averageLatencyMs: nullable(row.averageLatencyMs) };
67
+ });
68
+ const models = rows(dto.models).map(row => {
69
+ const overflow = boolean(row.overflow);
70
+ const efforts = rows(row.efforts).map(item => {
71
+ if (item.effort !== null && (typeof item.effort !== "string" || !EFFORTS.includes(item.effort))) return invalid();
72
+ return { effort: item.effort as string | null, picks: number(item.picks) };
73
+ });
74
+ return { provider: identity(row.provider, overflow), model: identity(row.model, overflow), overflow, ...counts(row, MODEL_COUNTS), efforts };
75
+ });
76
+ const incomplete: { usageIncomplete?: boolean; usageIncompleteReason?: "oversized_rows" } = {};
77
+ if (Object.hasOwn(dto, "usageIncomplete")) incomplete.usageIncomplete = boolean(dto.usageIncomplete);
78
+ if (Object.hasOwn(dto, "usageIncompleteReason")) {
79
+ if (dto.usageIncompleteReason !== "oversized_rows" || dto.usageIncomplete !== true) return invalid();
80
+ incomplete.usageIncompleteReason = "oversized_rows";
81
+ }
82
+ if (dto.usageIncomplete === true && incomplete.usageIncompleteReason === undefined) return invalid();
83
+ return {
84
+ range, comboId: id, since: nullable(dto.since),
85
+ ...(Object.hasOwn(dto, "until") ? { until: number(dto.until) } : {}),
86
+ generatedAt: number(dto.generatedAt), summary, gates, backends, models,
87
+ snapshotWindowStart: nullable(dto.snapshotWindowStart), snapshotWindowEnd: nullable(dto.snapshotWindowEnd),
88
+ ...incomplete, historyTruncated: boolean(dto.historyTruncated), truncatedPrefixBytes: number(dto.truncatedPrefixBytes),
89
+ entriesTruncated: boolean(dto.entriesTruncated), entriesDropped: number(dto.entriesDropped),
90
+ };
91
+ }
92
+
93
+ export async function handleComboStatsCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
94
+ return runCatalogAction(async () => {
95
+ const args = [...argv];
96
+ const id = args.shift();
97
+ const wantsJson = takeFlag(args, "--json");
98
+ const range = takeOptionWithSyntax(args, "--range")?.value ?? "30d";
99
+ // Do not normalize, truncate or resolve a public alias to another stored ID.
100
+ if (!id || id.length > 128 || id.trim() !== id || id.startsWith("--") || /[\u0000-\u001f\u007f]/u.test(id)) {
101
+ throw new CliUsageError("A nonblank exact stored combo ID of at most 128 characters is required", USAGE);
102
+ }
103
+ if (!["7d", "30d", "all"].includes(range)) throw new CliUsageError("--range must be 7d, 30d or all", USAGE);
104
+ if (args.length) throw new CliUsageError("Unexpected combo stats argument(s)", USAGE);
105
+ const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
106
+ const query = new URLSearchParams({ jev: "1", comboId: id, range });
107
+ const data = projectStats(await runtimeRequest(`/api/usage?${query}`, { redirect: "error" }, pinned), id, range);
108
+ const summary = data.summary;
109
+ const incomplete = data.usageIncomplete || data.historyTruncated || data.entriesTruncated;
110
+ printData(data, wantsJson, [
111
+ `Combo ${id} — JEV observations (${range})`,
112
+ ...(summary.decisions === 0 ? ["No recorded JEV decisions in this window."] : []),
113
+ `Decisions: ${summary.decisions}; applied: ${summary.appliedDecisions}; fail-open: ${summary.failOpenDecisions}`,
114
+ `Successful requests: ${summary.successfulRequests}; requests with model fallback: ${summary.requestsWithModelFallback}`,
115
+ `Model tokens: ${summary.modelTotalTokens}; measured attempts: ${summary.measuredModelAttempts}/${summary.modelAttempts}`,
116
+ `Decision tokens: ${summary.decisionTotalTokens}; decisions reporting usage: ${summary.decisionUsageReported}/${summary.decisions}`,
117
+ `Average decision latency: ${summary.averageLatencyMs === null ? "unavailable" : `${summary.averageLatencyMs} ms`}`,
118
+ `Average confidence: ${summary.averageConfidence ?? "unavailable"}; chosen probability: ${summary.averageChosenProbability ?? "unavailable"}`,
119
+ ...(incomplete ? ["History is incomplete; totals cover only observed records."] : []),
120
+ ]);
121
+ return 0;
122
+ });
123
+ }
package/src/cli/combo.ts CHANGED
@@ -5,22 +5,30 @@ import {
5
5
  rejectArgs,
6
6
  runCliAction,
7
7
  runtimeRequest,
8
+ runtimeBaseUrl,
8
9
  takeFlag,
9
10
  takeOption,
10
11
  type RuntimeApiDeps,
11
12
  } from "./runtime-api";
12
13
 
14
+ import { prepareComboInput } from "./combo-input";
15
+ import { printComboWriteResult } from "./combo-result";
16
+ import { runCatalogAction } from "./catalog-command-result";
17
+ import { serializeManagementJson } from "./json-input";
18
+
13
19
  const USAGE = `Usage:
14
20
  ocx combo [list] [--json]
15
21
  ocx combo show <id> [--json]
16
- ocx combo set <id> [--targets <provider/model[:weight],...>]
22
+ ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->]
17
23
  [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->]
18
24
  [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->]
19
25
  (force overrides valid client effort and can increase cost/latency) [--alias <name|->]
20
- [--native-alias] [--display-name <label|->]
26
+ [--native-alias [on|off]] [--display-name <label|->]
27
+ [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>]
21
28
  [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->]
22
29
  (jev only; the provider must be a configured jev-decision row)
23
30
  [--rename-from <id>] [--json]
31
+ ocx combo stats <id> [--range <7d|30d|all>] [--json]
24
32
  ocx combo remove <id> --yes [--json]
25
33
  ocx combo test [--combo <id>] [--decision-provider <provider|jev> | --decision-model <route>]
26
34
  [--decision-timeout <ms>] [--json]
@@ -72,18 +80,22 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
80
  printData(combo, wantsJson);
73
81
  }
74
82
 
75
- async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
83
+ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
76
84
  const args = [...argv];
77
85
  const id = args.shift()?.trim();
78
86
  const wantsJson = takeFlag(args, "--json");
79
87
  if (!id) throw new CliUsageError("combo id is required", USAGE);
88
+ const additional = await prepareComboInput(args, deps);
80
89
  const targetsRaw = takeOption(args, "--targets");
81
90
  const renameFrom = takeOption(args, "--rename-from");
91
+ const replacingTargets = targetsRaw !== undefined || additional.targets !== undefined;
92
+ deps = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
93
+ // A structured target file replaces the target list, not unrelated combo settings.
82
94
  const partialCurrent = targetsRaw === undefined
83
- ? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps)
95
+ ? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps)
84
96
  : undefined;
85
97
  const partialExisting = partialCurrent?.combos?.find(row => row.id === (renameFrom ?? id));
86
- if (targetsRaw === undefined && !partialExisting) {
98
+ if (!replacingTargets && !partialExisting) {
87
99
  throw new CliUsageError("--targets is required when creating a combo", USAGE);
88
100
  }
89
101
  const strategyRaw = takeOption(args, "--strategy");
@@ -104,7 +116,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
104
116
  throw new CliUsageError("--effort-mode must be fallback, force, or -", USAGE);
105
117
  }
106
118
  const alias = takeOption(args, "--alias");
107
- const nativeAlias = takeFlag(args, "--native-alias");
119
+
108
120
  const displayName = takeOption(args, "--display-name");
109
121
  const decisionProvider = takeOption(args, "--decision-provider");
110
122
  const decisionModel = takeOption(args, "--decision-model");
@@ -138,15 +150,18 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
138
150
  // Listing-only nulls must not be resent as explicit clears (alias rejects null).
139
151
  const combo: Record<string, unknown> = partialExisting
140
152
  ? Object.fromEntries(Object.entries(partialExisting).filter(([, value]) => value !== null))
141
- : { strategy, stickyLimit: stickyLimit ?? 1, targets: parseTargets(targetsRaw!) };
153
+ : { strategy, stickyLimit: stickyLimit ?? 1, targets: additional.targets ?? parseTargets(targetsRaw!) };
142
154
  delete combo.id;
143
155
  delete combo.model;
156
+ if (additional.targets !== undefined) combo.targets = additional.targets;
144
157
  if (strategyRaw !== undefined) combo.strategy = strategy;
145
158
  if (stickyLimit !== undefined) combo.stickyLimit = stickyLimit;
146
159
  if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
147
160
  if (effortMode !== undefined) combo.defaultEffortMode = effortMode === "-" ? "fallback" : effortMode;
148
161
  if (alias !== undefined) combo.alias = alias === "-" ? "" : alias;
149
- if (nativeAlias) combo.nativeAlias = true;
162
+ if (additional.nativeAlias !== undefined) combo.nativeAlias = additional.nativeAlias;
163
+ if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput;
164
+ if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode;
150
165
  if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
151
166
  if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider;
152
167
  if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel;
@@ -158,17 +173,20 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
158
173
  delete combo.decisionModel;
159
174
  delete combo.decisionTimeoutMs;
160
175
  }
161
- const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps);
176
+ const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
162
177
  const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
163
- if (existing?.imageInput === "disabled") combo.imageInput = "disabled";
178
+ if (additional.imageInput === undefined && existing?.imageInput === "disabled") combo.imageInput = "disabled";
179
+ if (effort === undefined && existing?.defaultEffort != null && combo.defaultEffort === undefined) {
180
+ combo.defaultEffort = existing.defaultEffort;
181
+ }
164
182
  if (effortMode === undefined && existing?.defaultEffortMode === "force") {
165
183
  combo.defaultEffortMode = effort === "-" ? "fallback" : "force";
166
184
  }
167
185
  const result = await runtimeRequest("/api/combos", {
168
- method: "PUT",
169
- body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
186
+ method: "PUT", redirect: "error",
187
+ body: serializeManagementJson({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
170
188
  }, deps);
171
- printData(result, wantsJson, [`Saved combo ${id}.`]);
189
+ return printComboWriteResult(result, id, wantsJson);
172
190
  }
173
191
 
174
192
  async function remove(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -249,11 +267,16 @@ async function discover(argv: string[], deps: RuntimeApiDeps): Promise<void> {
249
267
  }
250
268
 
251
269
  export async function handleComboCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
270
+ const [subcommand, ...writeArgs] = argv;
271
+ if (subcommand === "set" || subcommand === "create" || subcommand === "update") return runCatalogAction(() => set(writeArgs, deps));
272
+ if (subcommand === "stats") {
273
+ const { handleComboStatsCommand } = await import("./combo-stats");
274
+ return handleComboStatsCommand(writeArgs, deps);
275
+ }
252
276
  return runCliAction(async () => {
253
277
  const [sub = "list", ...rest] = argv;
254
278
  if (sub === "list") await list(rest, deps);
255
279
  else if (sub === "show") await show(rest, deps);
256
- else if (sub === "set" || sub === "create" || sub === "update") await set(rest, deps);
257
280
  else if (sub === "remove" || sub === "delete") await remove(rest, deps);
258
281
  else if (sub === "test") await testDecision(rest, deps);
259
282
  else if (sub === "discover") await discover(rest, deps);
@@ -0,0 +1,142 @@
1
+ /** Read the existing usage timeline without changing companion settings. */
2
+ import { isTimelineModelId, normalizeTimelineModelId, parseTimelineQuery, type TimelineQuery, type TimelineSeries, type UsageTimeline } from "../usage/timeline";
3
+ import { runCatalogAction } from "./catalog-command-result";
4
+ import { CliUsageError, printData, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
5
+
6
+ const USAGE = `Usage: ocx companion timeline [--hours <6|24|72|168>] [--bucket-minutes <1..1440>]
7
+ [--metric <total|input|output|cached>] [--aggregation <sum|average|max>]
8
+ [--grouping <model|modelAccount>] [--model <provider/model>]... [--hide-provider <name>]... [--json]
9
+ Providers are excluded with --hide-provider; --provider is not supported.`;
10
+
11
+ function repeatedOption(args: string[], flag: string): string[] {
12
+ const values: string[] = [];
13
+ for (let index = 0; index < args.length;) {
14
+ const token = args[index]!;
15
+ if (token === flag) {
16
+ const next = args[index + 1];
17
+ if (!next || next.startsWith("--")) throw new CliUsageError(`${flag} requires a value`, USAGE);
18
+ values.push(next); args.splice(index, 2);
19
+ } else if (token.startsWith(`${flag}=`)) {
20
+ const value = token.slice(flag.length + 1);
21
+ if (!value) throw new CliUsageError(`${flag} requires a value`, USAGE);
22
+ values.push(value); args.splice(index, 1);
23
+ } else index++;
24
+ }
25
+ return values;
26
+ }
27
+
28
+ function timelineInput(argv: string[]): { query: TimelineQuery; params: URLSearchParams; wantsJson: boolean } {
29
+ const args = [...argv], wantsJson = takeFlag(args, "--json"), params = new URLSearchParams();
30
+ for (const [flag, name] of [
31
+ ["--hours", "hours"], ["--bucket-minutes", "bucketMinutes"], ["--metric", "metric"],
32
+ ["--aggregation", "aggregation"], ["--grouping", "grouping"],
33
+ ] as const) {
34
+ const option = takeOptionWithSyntax(args, flag);
35
+ if (option) params.set(name, option.value);
36
+ }
37
+ const models = repeatedOption(args, "--model");
38
+ // Each occurrence is one identifier, not an implicit CSV escape hatch.
39
+ if (models.some(model => !isTimelineModelId(model) || model.includes(","))) {
40
+ throw new CliUsageError("Each --model must be one provider/model identifier", USAGE);
41
+ }
42
+ if (models.length) params.set("models", models.join(","));
43
+ for (const provider of repeatedOption(args, "--hide-provider")) params.append("hiddenProvider", provider);
44
+ if (args.length) throw new CliUsageError("Unsupported timeline arguments; use --hide-provider to exclude providers", USAGE);
45
+ const query = parseTimelineQuery(params, Date.now());
46
+ if ("error" in query) throw new CliUsageError("Invalid timeline options; check window, bucket count, enums and the 100-item filter limits", USAGE);
47
+ return { query, params, wantsJson };
48
+ }
49
+
50
+ function record(value: unknown): Record<string, unknown> {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid timeline object");
52
+ return value as Record<string, unknown>;
53
+ }
54
+ function number(value: unknown): number {
55
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Invalid timeline measurement");
56
+ return value;
57
+ }
58
+ function integer(value: unknown): number {
59
+ const result = number(value);
60
+ if (!Number.isSafeInteger(result)) throw new Error("Invalid timeline integer");
61
+ return result;
62
+ }
63
+ function text(value: unknown): string {
64
+ if (typeof value !== "string") throw new Error("Invalid timeline text");
65
+ return value;
66
+ }
67
+ function strings(value: unknown): string[] {
68
+ if (!Array.isArray(value)) throw new Error("Invalid timeline list");
69
+ return value.map(text);
70
+ }
71
+ function sameStrings(left: string[], right: string[]): boolean {
72
+ return left.length === right.length && left.every((value, index) => value === right[index]);
73
+ }
74
+
75
+ function timelineDto(raw: unknown, query: TimelineQuery): UsageTimeline {
76
+ const value = record(raw), filters = record(value.appliedFilters);
77
+ const models = filters.models === null ? null : strings(filters.models);
78
+ const hiddenProviders = strings(filters.hiddenProviders), expectedModels = query.models === null ? null : [...query.models].sort();
79
+ if ((models === null) !== (expectedModels === null)
80
+ || (models !== null && expectedModels !== null && !sameStrings(models, expectedModels))
81
+ || !sameStrings(hiddenProviders, query.hiddenProviders)) throw new Error("Timeline filters were not acknowledged");
82
+ const start = integer(value.start), end = integer(value.end), buckets = integer(value.buckets), bucketSeconds = integer(value.bucketSeconds);
83
+ const expectedBucketSeconds = query.bucketMinutes * 60;
84
+ const requestedEnd = (Math.floor(query.now / 1000 / expectedBucketSeconds) + 1) * expectedBucketSeconds;
85
+ const observedNow = Math.floor(Date.now() / 1000);
86
+ const currentWindow = end === requestedEnd
87
+ || (observedNow >= requestedEnd && end === requestedEnd + expectedBucketSeconds);
88
+ if (bucketSeconds !== query.bucketMinutes * 60 || buckets !== Math.ceil(query.hours * 60 / query.bucketMinutes)
89
+ || end <= start || end - start !== buckets * bucketSeconds || start % bucketSeconds !== 0 || end % bucketSeconds !== 0
90
+ // Match the requested current bucket, or its immediate successor after an observed rollover.
91
+ || !currentWindow || end > observedNow + bucketSeconds
92
+ || value.metric !== query.metric || value.aggregation !== query.aggregation || value.grouping !== query.grouping
93
+ || typeof value.truncated !== "boolean" || !Array.isArray(value.series) || value.series.length > 24) {
94
+ throw new Error("Invalid timeline bounds or metadata");
95
+ }
96
+ const availableModels = strings(value.availableModels);
97
+ if (availableModels.some(model => !isTimelineModelId(model)) || new Set(availableModels).size !== availableModels.length) {
98
+ throw new Error("Invalid available timeline models");
99
+ }
100
+ const selectedModels = models === null ? null : new Set(models.map(normalizeTimelineModelId));
101
+ const available = new Set(availableModels);
102
+ const series: TimelineSeries[] = value.series.map(rawSeries => {
103
+ const row = record(rawSeries), id = text(row.id), provider = text(row.provider), model = text(row.model);
104
+ const accountLogLabel = row.accountLogLabel === undefined ? undefined : text(row.accountLogLabel);
105
+ const total = number(row.total);
106
+ if (!Array.isArray(row.points) || row.points.length !== buckets) throw new Error("Invalid timeline points");
107
+ const points = row.points.map(number), pointTotal = points.reduce((sum, point) => sum + point, 0);
108
+ if (!Number.isFinite(pointTotal) || Math.abs(total - pointTotal) > 1e-9 * Math.max(1, total, pointTotal)) throw new Error("Invalid timeline total");
109
+ const other = id === "other" && provider === "" && model === "other" && accountLogLabel === undefined;
110
+ if (!other && (!isTimelineModelId(`${provider}/${model}`) || hiddenProviders.includes(provider)
111
+ || !available.has(`${provider}/${model}`) || (selectedModels !== null && !selectedModels.has(`${provider}/${model}`))
112
+ || id !== (query.grouping === "model" ? `${provider}/${model}` : `${provider}/${model} · ${accountLogLabel}`)
113
+ || (query.grouping === "modelAccount" ? accountLogLabel === undefined : accountLogLabel !== undefined))) {
114
+ throw new Error("Invalid timeline series identity");
115
+ }
116
+ return { id, provider, model, ...(accountLogLabel !== undefined ? { accountLogLabel } : {}), total, points };
117
+ });
118
+ if (new Set(series.map(row => row.id)).size !== series.length) throw new Error("Duplicate timeline series");
119
+ return { appliedFilters: { models, hiddenProviders }, start, end, bucketSeconds, buckets,
120
+ metric: query.metric, aggregation: query.aggregation, grouping: query.grouping, series, availableModels,
121
+ missingMeasurements: integer(value.missingMeasurements), truncated: value.truncated };
122
+ }
123
+
124
+ /** argv starts after `companion timeline`; numeric errors propagate through the public companion dispatcher. */
125
+ export async function handleCompanionTimelineCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
126
+ return runCatalogAction(async () => {
127
+ const { params, query, wantsJson } = timelineInput(argv);
128
+ const result = timelineDto(await runtimeRequest(`/api/usage/timeline${params.size ? `?${params}` : ""}`,
129
+ { method: "GET", redirect: "error" }, deps), query);
130
+ printData(result, wantsJson, [
131
+ `Usage timeline: ${result.metric}, ${result.aggregation}, grouped by ${result.grouping}`,
132
+ `Window: ${new Date(result.start * 1000).toISOString()} to ${new Date(result.end * 1000).toISOString()} (end exclusive)`,
133
+ `Buckets: ${result.buckets} × ${result.bucketSeconds} seconds`,
134
+ `Models: ${result.appliedFilters.models?.join(", ") ?? "all"}; excluded providers: ${result.appliedFilters.hiddenProviders.join(", ") || "none"}`,
135
+ `Missing measurements: ${result.missingMeasurements}; truncated: ${result.truncated}`,
136
+ ...(result.missingMeasurements || result.truncated ? ["Incomplete evidence: plotted zeros do not establish zero usage."] : []),
137
+ ...(result.series.length ? result.series.map(row => `${row.id}: ${row.total} (${row.points.join(", ")})`)
138
+ : ["No matching series in the available timeline. Try a wider --hours window or fewer filters."]),
139
+ `Available models: ${result.availableModels.join(", ") || "none"}`,
140
+ ]);
141
+ });
142
+ }
@@ -0,0 +1,146 @@
1
+ /** Saved companion display filters applied to two bounded management reads. */
2
+ import { ObservationError, withObserveStream, type ObserveStreamDeps } from "./observe-stream";
3
+ import { printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
4
+
5
+ const USAGE = "Usage: ocx companion usage [--json]";
6
+ const TOTAL_FIELDS = ["requests", "totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "cacheReadInputTokens", "estimatedCostUsd", "measuredRequests", "pricedRequests", "coverageRatio"] as const;
7
+ type Totals = Partial<Record<typeof TOTAL_FIELDS[number], number>>;
8
+ type Model = Totals & { model: string; provider: string };
9
+ interface Usage {
10
+ summary: Totals;
11
+ models: Model[];
12
+ customWindow?: boolean;
13
+ since?: number | null;
14
+ until?: number;
15
+ usageIncomplete?: boolean;
16
+ historyTruncated?: boolean;
17
+ entriesTruncated?: boolean;
18
+ }
19
+ interface Filters { models: string[] | null; hiddenProviders: string[] }
20
+ interface Settings { filters: Filters; updatedAt: number | null; corrupt: boolean }
21
+ type RangeResult = { status: "available"; data: Usage } | { status: "unavailable" };
22
+
23
+ function object(value: unknown): Record<string, unknown> {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ObservationError("Invalid companion response. Check the runtime and retry the command.");
25
+ return value as Record<string, unknown>;
26
+ }
27
+
28
+ function stringList(value: unknown): value is string[] {
29
+ return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/[\x00-\x1f\x7f-\x9f]/.test(item));
30
+ }
31
+
32
+ function parseSettings(value: unknown): Settings {
33
+ const body = object(value), settings = object(body.settings);
34
+ if ((settings.models !== null && !stringList(settings.models)) || !stringList(settings.hiddenProviders)
35
+ || (body.updatedAt !== null && (typeof body.updatedAt !== "number" || !Number.isFinite(body.updatedAt)))
36
+ || (body.corrupt !== undefined && typeof body.corrupt !== "boolean")) {
37
+ throw new ObservationError("Invalid companion settings. Check the runtime and retry the command.");
38
+ }
39
+ return { filters: { models: settings.models, hiddenProviders: settings.hiddenProviders }, updatedAt: body.updatedAt, corrupt: body.corrupt === true };
40
+ }
41
+
42
+ function parseTotals(value: unknown): Totals {
43
+ const row = object(value), totals: Totals = {};
44
+ for (const key of TOTAL_FIELDS) {
45
+ const metric = row[key];
46
+ if (metric === undefined) continue;
47
+ if (typeof metric !== "number" || !Number.isFinite(metric) || metric < 0) throw new ObservationError("Invalid companion usage metrics. Check the runtime and retry the command.");
48
+ totals[key] = metric;
49
+ }
50
+ return totals;
51
+ }
52
+
53
+ function parseUsage(value: unknown): Usage {
54
+ const body = object(value);
55
+ if (body.error || !Array.isArray(body.models)) throw new ObservationError("Invalid companion usage report. Check the runtime and retry the command.");
56
+ const usage: Usage = {
57
+ summary: parseTotals(body.summary),
58
+ models: body.models.map(raw => {
59
+ const row = object(raw);
60
+ if (typeof row.model !== "string" || typeof row.provider !== "string") throw new ObservationError("Invalid companion model row. Check the runtime and retry the command.");
61
+ return { ...parseTotals(row), model: row.model, provider: row.provider };
62
+ }),
63
+ };
64
+ for (const key of ["customWindow", "usageIncomplete", "historyTruncated", "entriesTruncated"] as const) {
65
+ if (body[key] === undefined) continue;
66
+ if (typeof body[key] !== "boolean") throw new ObservationError("Invalid companion usage metadata. Check the runtime and retry the command.");
67
+ usage[key] = body[key];
68
+ }
69
+ for (const key of ["since", "until"] as const) {
70
+ const timestamp = body[key];
71
+ if (timestamp === undefined) continue;
72
+ if (key === "since" && timestamp === null) { usage.since = null; continue; }
73
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) throw new ObservationError("Invalid companion usage window. Check the runtime and retry the command.");
74
+ usage[key] = timestamp;
75
+ }
76
+ return usage;
77
+ }
78
+
79
+ /** Keep in conformance with tray-data's display rules without a GUI runtime import. */
80
+ function filterUsage(usage: Usage, filters: Filters): Usage {
81
+ const hidden = new Set(filters.hiddenProviders), selected = filters.models === null ? null : new Set(filters.models);
82
+ const models = usage.models.filter(row => !hidden.has(row.provider)
83
+ && (selected === null || selected.has(`${row.provider}/${row.model}`) || selected.has(row.model)));
84
+ if (filters.models === null && filters.hiddenProviders.length === 0) return { ...usage, models };
85
+ if (filters.models?.length !== 0 && usage.models.some(row => !row.provider || !row.model || (row.provider === "other" && row.model === "other"))) {
86
+ return { ...usage, models: models.filter(row => row.provider !== "other" || row.model !== "other"), summary: {}, usageIncomplete: true };
87
+ }
88
+ const summary: Totals = {};
89
+ for (const key of TOTAL_FIELDS) {
90
+ if (key === "coverageRatio") continue;
91
+ if (models.length && models.every(row => row[key] !== undefined)) summary[key] = models.reduce((sum, row) => sum + row[key]!, 0);
92
+ }
93
+ return { ...usage, models, summary };
94
+ }
95
+
96
+ function measuredTotals<T extends Totals>(data: T): T {
97
+ const next = { ...data };
98
+ if ((data.requests ?? 0) > 0 && (data.measuredRequests === 0 || data.coverageRatio === 0)) {
99
+ for (const key of ["totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "cacheReadInputTokens"] as const) delete next[key];
100
+ }
101
+ if ((data.requests ?? 0) > 0 && data.pricedRequests === 0) delete next.estimatedCostUsd;
102
+ return next;
103
+ }
104
+
105
+ function formatRange(label: string, result: RangeResult): string[] {
106
+ if (result.status === "unavailable") return [`${label}: unavailable`];
107
+ const count = (value: number | undefined) => value === undefined ? "unknown" : value.toLocaleString("en-US");
108
+ const cost = (value: number | undefined) => value === undefined ? "unknown" : `~$${value.toFixed(4)}`;
109
+ const data = result.data;
110
+ return [`${label}: ${count(data.summary.requests)} requests; ${count(data.summary.totalTokens)} tokens; cost ${cost(data.summary.estimatedCostUsd)}`,
111
+ ...(data.usageIncomplete || data.historyTruncated || data.entriesTruncated ? [" Incomplete observation; totals include only attributable measured data."] : []),
112
+ ...data.models.map(row => ` ${row.provider}/${row.model}: ${count(row.requests)} requests; ${count(row.totalTokens)} tokens; cost ${cost(row.estimatedCostUsd)}`)];
113
+ }
114
+
115
+ export async function handleCompanionUsageCommand(argv: string[], deps: ObserveStreamDeps = {}): Promise<number> {
116
+ let outcome = 0;
117
+ const parseExit = await runCliAction(async () => {
118
+ const args = [...argv], wantsJson = takeFlag(args, "--json");
119
+ rejectArgs(args, USAGE, { redactValues: true });
120
+ outcome = await withObserveStream(deps, async stream => {
121
+ const settings = parseSettings(await stream.get("/api/companion/settings", new URLSearchParams()));
122
+ const ranges: Record<"today" | "30d", RangeResult> = { today: { status: "unavailable" }, "30d": { status: "unavailable" } };
123
+ for (const range of ["today", "30d"] as const) {
124
+ try {
125
+ const data = filterUsage(parseUsage(await stream.get("/api/usage", new URLSearchParams({ range }))), settings.filters);
126
+ ranges[range] = { status: "available", data: { ...data, summary: measuredTotals(data.summary), models: data.models.map(measuredTotals) } };
127
+ } catch {
128
+ stream.signal.throwIfAborted();
129
+ }
130
+ }
131
+ stream.signal.throwIfAborted();
132
+ const partial = ranges.today.status === "unavailable" || ranges["30d"].status === "unavailable";
133
+ const result = { schemaVersion: 1, filters: settings.filters, settingsUpdatedAt: settings.updatedAt, settingsCorrupt: settings.corrupt,
134
+ settingsFallback: settings.corrupt || settings.updatedAt === null, ranges, partial };
135
+ const lines = ["Companion usage — saved display filters (sequential observations)",
136
+ `Models: ${settings.filters.models === null ? "all" : settings.filters.models.length === 0 ? "none" : settings.filters.models.join(", ")}`,
137
+ `Hidden providers: ${settings.filters.hiddenProviders.join(", ") || "none"}`,
138
+ ...(settings.corrupt ? ["WARNING: Saved settings are corrupt; the runtime supplied fallback settings."] : []),
139
+ ...formatRange("Today", ranges.today), ...formatRange("30 days", ranges["30d"])];
140
+ printData(result, wantsJson, wantsJson ? undefined : lines);
141
+ if (partial) console.error("Error: Some companion usage ranges are unavailable. Check the runtime and retry the command.");
142
+ return partial ? 1 : 0;
143
+ }, { kind: "snapshot" });
144
+ });
145
+ return parseExit || outcome;
146
+ }
@@ -1,9 +1,16 @@
1
+ import { handleCompanionTimelineCommand } from "./companion-timeline";
2
+ import { handleCompanionUsageCommand } from "./companion-usage";
3
+ import type { ObserveStreamDeps } from "./observe-stream";
1
4
  import { CliUsageError, printData, rejectArgs, runCliAction, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api";
2
5
 
3
6
  const USAGE = `Usage:
4
7
  ocx companion [show] [--json]
5
8
  ocx companion set <key>=<value> [...] [--json]
6
- ocx companion reset [--json]`;
9
+ ocx companion reset [--json]
10
+ ocx companion usage [--json]
11
+ ocx companion timeline [--hours <6|24|72|168>] [--bucket-minutes <1..1440>]
12
+ [--metric <total|input|output|cached>] [--aggregation <sum|average|max>]
13
+ [--grouping <model|modelAccount>] [--model <provider/model>]... [--hide-provider <name>]... [--json]`;
7
14
 
8
15
  function parseValue(raw: string): unknown {
9
16
  if (raw === "null") return null;
@@ -43,9 +50,11 @@ async function reset(argv: string[], deps: RuntimeApiDeps): Promise<void> {
43
50
  }, deps), wantsJson, ["Companion settings reset."]);
44
51
  }
45
52
 
46
- export async function handleCompanionCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
53
+ export async function handleCompanionCommand(argv: string[], deps: ObserveStreamDeps = {}): Promise<number> {
54
+ const [sub = "show", ...rest] = argv;
55
+ if (sub === "timeline") return handleCompanionTimelineCommand(rest, deps);
56
+ if (sub === "usage") return handleCompanionUsageCommand(rest, deps);
47
57
  return runCliAction(async () => {
48
- const [sub = "show", ...rest] = argv;
49
58
  if (sub === "show") await show(rest, deps);
50
59
  else if (sub === "set") await set(rest, deps);
51
60
  else if (sub === "reset") await reset(rest, deps);