@0dai-dev/cli 4.3.4 → 4.3.5

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
@@ -1,6 +1,6 @@
1
1
  # 0dai
2
2
 
3
- One config layer for <!-- canonical-count:agent_clis_total -->6<!-- /canonical-count --> AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider, Qoder.
3
+ One config layer for <!-- canonical-count:agent_clis_total -->7<!-- /canonical-count --> AI agent CLIs — Claude Code, Codex, OpenCode, Gemini, Aider, Qoder, Cursor.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,22 +8,85 @@ One config layer for <!-- canonical-count:agent_clis_total -->6<!-- /canonical-c
8
8
  npm install -g @0dai-dev/cli
9
9
  ```
10
10
 
11
- ## Usage
11
+ ## Five commands that matter
12
12
 
13
13
  ```bash
14
14
  cd your-project
15
- 0dai init # guides OAuth/device auth + activation, then generates ai/ layer
16
- 0dai init --auth-code <browser-code> --code <TEAM-CODE>
17
- 0dai auth login # sign in separately when needed
18
- 0dai activate code <TEAM-CODE>
19
- 0dai sync # update after changes
20
- 0dai detect # show detected stack
21
- 0dai doctor # check health
22
- 0dai status # maturity, swarm tasks, session
23
- 0dai feedback retry # retry queued feedback after an API failure
24
- 0dai persona-simulate "topic" # run synthetic focus-group simulation
15
+ 0dai init # create ai/ layer, auth, and MCP bootstrap
16
+ 0dai status # maturity, swarm tasks, and session state
17
+ 0dai sync # refresh ai/ after repo or server changes
18
+ 0dai run "…" # split a backlog item into agent tasks
19
+ 0dai doctor # health check for credentials, drift, and env
25
20
  ```
26
21
 
22
+ ## All commands
23
+
24
+ Start (first 5 minutes):
25
+
26
+ ```bash
27
+ 0dai init # create ai/ layer + MCP [--local] [--dry-run] [--minimal]
28
+ 0dai init --auth-code <code> --code <TEAM-CODE> # non-interactive auth + activation
29
+ 0dai doctor # check health, credentials, and drift [--drift]
30
+ 0dai status # maturity, swarm, and session state [--json]
31
+ 0dai quickstart # auth, init, doctor, and status in one pass
32
+ 0dai detect # show detected stack
33
+ 0dai auth login # OAuth/device flow [--device] [--no-browser] [--code CODE] [--mcp]
34
+ 0dai auth mcp # store MCP token from current auth or device code
35
+ 0dai auth status # account and usage
36
+ 0dai activate free # claim free activation license
37
+ ```
38
+
39
+ Daily (regular work):
40
+
41
+ ```bash
42
+ 0dai sync # update ai/ after repo or server changes [--dry-run] [--yes] [--quiet] [--force]
43
+ 0dai run <goal> # split a backlog item into agent tasks [--dry-run] [--dry-cost] [--agent claude|codex|gemini]
44
+ 0dai swarm status # show queued, active, and done tasks
45
+ 0dai swarm add # queue one task [--task '...' --to agent]
46
+ 0dai swarm-run # add, dispatch, and wait for one swarm task as JSON
47
+ 0dai harvest # convert experience events into candidate lessons
48
+ 0dai watch # live task monitor [--interval N]
49
+ 0dai reflect # session reflection: delivered, delegation, blockers
50
+ 0dai standup # morning voice briefing about overnight agent work
51
+ 0dai feedback push # send feedback to 0dai
52
+ 0dai feedback retry # retry queued feedback after a failed push
53
+ 0dai persona-simulate "topic" # focus-group report and optional issue drafts
54
+ ```
55
+
56
+ Pro / advanced:
57
+
58
+ ```bash
59
+ 0dai init-existing # existing-repo setup alias for init
60
+ 0dai project bind # bind repository to your 0dai account [--json]
61
+ 0dai project status # local project binding and health [--json]
62
+ 0dai graph push # upload local graph (Pro: edges, Free: nodes)
63
+ 0dai graph pull # download server graph and merge locally
64
+ 0dai graph status # local graph stats and sync state
65
+ 0dai ci # portable CI pipelines and AI-MQ [list|plan|mq-status]
66
+ 0dai heatmap # repo treemap: LOC × agent-edit intensity
67
+ 0dai session save # save session for roaming
68
+ 0dai provider # local provider profiles and direct invoke
69
+ 0dai models # model ratings (--fast/--balanced/--deep/--available)
70
+ 0dai quota # agent subscription usage [--refresh] [--json]
71
+ 0dai usage # local token, task, and USD ledger [status|daily|monthly]
72
+ 0dai workspace # tmux workspace sessions (init|up|status)
73
+ 0dai runner # runner/host architecture and queues [--json]
74
+ 0dai report # privacy-safe project reports (preview|push|status)
75
+ 0dai compliance # SOC2/ISO evidence and ADR audit-trail export
76
+ 0dai experience # structured experience events (list|stats|sync|warnings|dismiss)
77
+ 0dai receipt # session receipt PNG [--last|--active|--session ID]
78
+ 0dai boneyard # weekly digest of worst agent moves [--week YYYY-WW|current]
79
+ 0dai gh branch-protection # GitHub branch protection [print|apply|install]
80
+ 0dai import claude-code-agents # import .claude/agents/*.md as personas [--dry-run]
81
+ 0dai auth logout # remove credentials
82
+ 0dai activate code <TEAM-CODE> # redeem Pro/Team activation code
83
+ 0dai activate status # activation and bound-project status
84
+ 0dai redeem <CODE> # redeem a plan upgrade code
85
+ 0dai upgrade # open pricing page (browser or printed URL)
86
+ ```
87
+
88
+ Global flags: `--target PATH`, `--version`, `--help`, `--json`, `--quiet`. See `0dai --help` for the full surface.
89
+
27
90
  ## What it does
28
91
 
29
92
  `0dai init` and `0dai sync` are activation-first. `init` can complete auth and plan activation in one run: browser/OAuth exchange code via `--auth-code`, Team/Pro activation code via `--code` or `--activation-code`, or the interactive OAuth/device-code prompt. After that it binds the project and sends only allowlisted project metadata (file names + package/build manifests) to the API and generates:
package/bin/0dai.js CHANGED
@@ -102,6 +102,7 @@ const { cmdAudit } = require("../lib/commands/audit");
102
102
  const { cmdDoctor } = require("../lib/commands/doctor");
103
103
  const { cmdValidate } = require("../lib/commands/validate");
104
104
  const { cmdUpdate } = require("../lib/commands/update");
105
+ const { cmdUpgrade } = require("../lib/commands/upgrade");
105
106
  const { cmdReflect } = require("../lib/commands/reflect");
106
107
  const { cmdMetrics } = require("../lib/commands/metrics");
107
108
  const { cmdHeatmap } = require("../lib/commands/heatmap");
@@ -128,6 +129,7 @@ const { cmdPaste } = require("../lib/commands/paste");
128
129
  const { cmdReceipt } = require("../lib/commands/receipt");
129
130
  const { cmdBoneyard } = require("../lib/commands/boneyard");
130
131
  const { cmdQuota } = require("../lib/commands/quota");
132
+ const { cmdUsage } = require("../lib/commands/usage");
131
133
  const { cmdGh } = require("../lib/commands/gh");
132
134
  const { cmdLoop } = require("../lib/commands/loop");
133
135
  const { cmdImportClaudeCodeAgents } = require("../lib/commands/import_claude_code_agents");
@@ -150,7 +152,7 @@ function printHelp() {
150
152
  console.log("");
151
153
  console.log("Daily (regular work):");
152
154
  console.log(" sync Update ai/ layer after repo or server changes [--dry-run] [--yes] [--quiet] [--force]");
153
- console.log(" run <goal> Split a backlog item into agent tasks [--dry-run] [--agent claude|codex|gemini] [--provider X]");
155
+ console.log(" run <goal> Split a backlog item into agent tasks [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider X]");
154
156
  console.log(" swarm status Show queued, active, and done tasks");
155
157
  console.log(" swarm add Queue one task for an agent [--task '...' --to agent]");
156
158
  console.log(" swarm-run Add, dispatch, and wait for one swarm task as JSON");
@@ -174,6 +176,7 @@ function printHelp() {
174
176
  console.log(" provider Local provider profiles, bindings, and direct invoke");
175
177
  console.log(" models Show model ratings (--fast/--balanced/--deep/--available)");
176
178
  console.log(" quota Agent subscription usage table [--refresh] [--json]");
179
+ console.log(" usage Local token, task, and USD usage ledger [status|daily|monthly]");
177
180
  console.log(" workspace Manage tmux workspace sessions (init|up|status)");
178
181
  console.log(" runner Show runner/project-host architecture, queues, labels, and burst routing [status|plan|queue-status|label-audit|route-dry-run] [--json]");
179
182
  console.log(" report Privacy-safe project reports (preview|push|status)");
@@ -206,6 +209,8 @@ const SYNC_ALLOWED_FLAGS = new Set([
206
209
  "--force",
207
210
  "--no-diff",
208
211
  "--no-mcp-auth",
212
+ "--skip-link-check",
213
+ "--strict-links",
209
214
  ]);
210
215
 
211
216
  function printSyncHelp() {
@@ -220,6 +225,8 @@ function printSyncHelp() {
220
225
  console.log(" --force Also overwrite native config files from managed ai/ sources");
221
226
  console.log(" --no-diff Hide unified diff output in previews/prompts");
222
227
  console.log(" --no-mcp-auth Skip MCP cloud-token bootstrap during sync");
228
+ console.log(" --skip-link-check Skip the SPEC-028 doc cross-link scan after sync");
229
+ console.log(" --strict-links Fail sync if doc_link_check reports broken or closed refs");
223
230
  console.log(" --target PATH Run sync against another project path");
224
231
  console.log("");
225
232
  }
@@ -352,6 +359,7 @@ async function main() {
352
359
  case "validate": cmdValidate(target); break;
353
360
  case "reflect": cmdReflect(target, args); break;
354
361
  case "update": cmdUpdate(args); break;
362
+ case "upgrade": cmdUpgrade(); break;
355
363
  case "metrics": cmdMetrics(target); break;
356
364
  case "heatmap": cmdHeatmap(target, args.slice(1)); break;
357
365
  case "portfolio": cmdPortfolio(); break;
@@ -408,6 +416,7 @@ async function main() {
408
416
  case "receipt": cmdReceipt(target, args.slice(1)); break;
409
417
  case "boneyard": cmdBoneyard(target, args.slice(1)); break;
410
418
  case "quota": case "quotas": cmdQuota(target, args.slice(1)); break;
419
+ case "usage": cmdUsage(target, args.slice(1)); break;
411
420
  case "gh": await cmdGh(target, sub, args); break;
412
421
  case "loop": cmdLoop(target, sub, args); break;
413
422
  case "import": {
@@ -133,16 +133,19 @@ async function loginWithExchangeCode(code) {
133
133
  if (!exchanged || exchanged.error || !exchanged.access_token) {
134
134
  throw new Error(exchanged && exchanged.error ? exchanged.error : "invalid or expired auth code");
135
135
  }
136
+ const status = await fetchAuthStatus(exchanged.access_token);
137
+ if (!status || status.error || !status.email) {
138
+ throw new Error(status && status.error ? status.error : "cloud validation failed after auth exchange");
139
+ }
136
140
  saveAuthState({
137
141
  access_token: exchanged.access_token,
138
- email: exchanged.email || "",
139
- name: exchanged.name || "",
142
+ email: status.email,
143
+ plan: status.plan || "free",
144
+ name: status.name || exchanged.name || "",
145
+ license: status.license || {},
146
+ plan_expires_at: status.plan_expires_at || "",
140
147
  authenticated_at: new Date().toISOString(),
141
148
  });
142
- const status = await fetchAuthStatus();
143
- if (!status || status.error || !status.email) {
144
- throw new Error(status && status.error ? status.error : "cloud validation failed after auth exchange");
145
- }
146
149
  return status;
147
150
  }
148
151
 
@@ -2,6 +2,25 @@
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, spawnSync, findRepoScript, SUPPORTED_CLIS, recordExperienceEvent } = shared;
4
4
 
5
+ function nodePtyProbe() {
6
+ try {
7
+ require.resolve("node-pty");
8
+ return {
9
+ name: "node-pty",
10
+ present: true,
11
+ sev: "ok",
12
+ hint: "node-pty available for terminal session spawn",
13
+ };
14
+ } catch {
15
+ return {
16
+ name: "node-pty",
17
+ present: false,
18
+ sev: "warn",
19
+ hint: "install node-pty for terminal sessions: npm i -g node-pty",
20
+ };
21
+ }
22
+ }
23
+
5
24
  function ghostAuthStatus(home = process.env.HOME || process.env.USERPROFILE || "") {
6
25
  const customApiEnv = home ? path.join(home, ".custom-api", "custom-api.env") : "";
7
26
  const legacyAnthropicEnv = home ? path.join(home, ".claude-api-isolated", "anthropic.env") : "";
@@ -256,4 +275,4 @@ function cmdDoctor(target, options = {}) {
256
275
  }
257
276
  }
258
277
 
259
- module.exports = { cmdDoctor, ghostAuthStatus };
278
+ module.exports = { cmdDoctor, ghostAuthStatus, nodePtyProbe };
@@ -15,6 +15,7 @@ const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = requir
15
15
  const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
16
16
  const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
17
17
  const { bootstrapMcp } = require("../utils/mcp-auth");
18
+ const { recordActivationInit } = require("../utils/activation_telemetry");
18
19
 
19
20
  const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
20
21
  const SYNC_FULL_CONTENT_LIMIT = 10000;
@@ -463,6 +464,8 @@ async function cmdInit(target, args = []) {
463
464
  // First-run proof gate (issue #342). All 4 gates pass once we reach here:
464
465
  // license active (line above), project bound, ai/ layer written, heartbeat sent.
465
466
  // Idempotent — only fires once per project. See docs/first-run.md.
467
+ recordActivationInit(target, boundProject.project_id || identity.project_id);
468
+
466
469
  const firstRun = logFirstRunSuccess(target, {
467
470
  license: true,
468
471
  project_bound: true,
@@ -557,6 +560,60 @@ function normalizeEnvironmentManifest(target) {
557
560
  return { path: filePath, changed: true };
558
561
  }
559
562
 
563
+ // runDocLinkCheck — SPEC-028 Phase 2 (#2689). After the managed layer has been
564
+ // written, run scripts/doc_link_check.py to surface broken/closed cross-refs
565
+ // in ai/ and docs/. Warn-only by default so a flaky reference never blocks an
566
+ // otherwise successful sync; --strict-links promotes failures to a non-zero
567
+ // exit, and --skip-link-check bypasses the hook entirely.
568
+ //
569
+ // The hook intentionally stays silent when the script is missing (Phase 1 may
570
+ // not have shipped to a downstream project) and when python3 is unavailable.
571
+ // Output is streamed inline via stdio: "inherit" so authors see exact paths.
572
+ function runDocLinkCheck(target, args = [], options = {}) {
573
+ if (args.includes("--skip-link-check")) {
574
+ return { skipped: true, reason: "flag" };
575
+ }
576
+ const script = path.join(target, "scripts", "doc_link_check.py");
577
+ if (!fs.existsSync(script)) {
578
+ return { skipped: true, reason: "missing-script" };
579
+ }
580
+ const quiet = !!options.quiet;
581
+ const strict = args.includes("--strict-links");
582
+ const { spawnSync } = require("child_process");
583
+ if (!quiet) log("doc-link-check: scanning ai/**/*.md docs/**/*.md");
584
+ const result = spawnSync(
585
+ "python3",
586
+ [
587
+ script,
588
+ "--repo-root",
589
+ target,
590
+ "--paths",
591
+ "ai/**/*.md",
592
+ "docs/**/*.md",
593
+ "--format",
594
+ "md",
595
+ "--fail-on",
596
+ "broken,closed",
597
+ ],
598
+ { stdio: "inherit", cwd: target },
599
+ );
600
+ if (result.error && result.error.code === "ENOENT") {
601
+ if (!quiet) console.log(` ${D}doc-link-check skipped: python3 not found${R}`);
602
+ return { skipped: true, reason: "no-python" };
603
+ }
604
+ const status = typeof result.status === "number" ? result.status : 0;
605
+ if (status !== 0) {
606
+ if (strict) {
607
+ log(`${W}doc-link-check failed (exit ${status}); --strict-links is set${R}`);
608
+ process.exit(status);
609
+ }
610
+ if (!quiet) log(`${W}doc-link-check found issues (exit ${status}); warn-only (pass --strict-links to fail sync)${R}`);
611
+ return { ran: true, status, strict, broken: true };
612
+ }
613
+ if (!quiet) log("doc-link-check: clean");
614
+ return { ran: true, status: 0, strict, broken: false };
615
+ }
616
+
560
617
  async function cmdSync(target, args = []) {
561
618
  const dryRun = args.includes("--dry-run");
562
619
  const quiet = args.includes("--quiet") || args.includes("-q");
@@ -669,6 +726,12 @@ async function cmdSync(target, args = []) {
669
726
  } else {
670
727
  log("already up to date");
671
728
  }
729
+
730
+ // SPEC-028 Phase 2 (#2689) — scan managed docs for broken cross-refs after
731
+ // the layer is on disk. Warn-only unless --strict-links; --skip-link-check
732
+ // bypasses. Runs even when no files changed so stale local refs surface on
733
+ // every sync.
734
+ runDocLinkCheck(target, args, { quiet });
672
735
  const envManifest = normalizeEnvironmentManifest(target);
673
736
  if (!quiet && envManifest.changed) {
674
737
  log("environment manifest target normalized: ai/manifest/environment.yaml");
@@ -823,5 +886,6 @@ module.exports = {
823
886
  hashFile,
824
887
  detectRegistryDrift,
825
888
  guardRegistryDrift,
889
+ runDocLinkCheck,
826
890
  SYNC_FULL_CONTENT_LIMIT,
827
891
  };
@@ -1,26 +1,43 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, apiCall } = shared;
4
+ const {
5
+ estimateRunCost,
6
+ formatTokens,
7
+ formatUsd,
8
+ formatUsdPer1k,
9
+ } = require("../utils/run_cost");
10
+ const { recordActivationFirstTask } = require("../utils/activation_telemetry");
4
11
 
5
12
  async function cmdRun(goal, target, args = []) {
6
13
  if (!goal) {
7
- console.log(`Usage: 0dai run <goal> [--dry-run] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
14
+ console.log(`Usage: 0dai run <goal> [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
8
15
  console.log(` Example: 0dai run "add dark mode to settings page"`);
16
+ console.log(` Example: 0dai run "fix login bug" --dry-cost`);
17
+ console.log(` Example: 0dai run "refactor auth" --max-cost 0.50`);
9
18
  return;
10
19
  }
11
20
 
12
21
  const dryRun = args.includes("--dry-run");
22
+ const dryCost = args.includes("--dry-cost");
13
23
  const agentIdx = args.indexOf("--agent");
14
24
  const agentOverride = agentIdx >= 0 ? args[agentIdx + 1] : null;
15
25
  const providerIdx = args.indexOf("--provider");
16
26
  const providerOverride = providerIdx >= 0 ? normalizeProvider(args[providerIdx + 1] || "") : null;
17
27
  const providerModelIdx = args.indexOf("--provider-model");
18
28
  const providerModel = providerModelIdx >= 0 ? (args[providerModelIdx + 1] || "") : "";
29
+ const maxCostIdx = args.indexOf("--max-cost");
30
+ const maxCostRaw = maxCostIdx >= 0 ? args[maxCostIdx + 1] : null;
31
+ const maxCost = maxCostRaw != null ? parseFloat(maxCostRaw) : null;
19
32
 
20
33
  if (providerIdx >= 0 && !providerOverride) {
21
34
  log("error: unsupported provider. Supported: deepseek, gemini-direct, codex, claude-opus, claude-sonnet");
22
35
  return;
23
36
  }
37
+ if (maxCostIdx >= 0 && (maxCostRaw == null || Number.isNaN(maxCost) || maxCost < 0)) {
38
+ log("error: --max-cost requires a non-negative number");
39
+ process.exit(1);
40
+ }
24
41
 
25
42
  // Read project context
26
43
  let stack = "generic", agents = ["claude"], commands = {};
@@ -30,9 +47,9 @@ async function cmdRun(goal, target, args = []) {
30
47
  agents = disc.selected_agents || ["claude"];
31
48
  } catch {}
32
49
 
33
- if (!dryRun) process.stdout.write(`${T}[0dai]${R} decomposing goal...`);
50
+ if (!dryRun && !dryCost) process.stdout.write(`${T}[0dai]${R} decomposing goal...`);
34
51
  const result = await apiCall("/v1/run", { goal, context: { stack, agents, commands } });
35
- if (!dryRun) process.stdout.write("\r" + " ".repeat(40) + "\r");
52
+ if (!dryRun && !dryCost) process.stdout.write("\r" + " ".repeat(40) + "\r");
36
53
 
37
54
  if (result.error) { log(`error: ${result.error}`); return; }
38
55
 
@@ -50,6 +67,23 @@ async function cmdRun(goal, target, args = []) {
50
67
  console.log(` ${D}→ ${agent}${providerText} [${t.model_tier}]${t.description ? " " + t.description : ""}${R}`);
51
68
  }
52
69
 
70
+ const costEstimate = estimateRunCost(tasks, {
71
+ providerOverride,
72
+ resolveAgent: (task) => agentOverride || providerAgent(providerOverride) || task.assigned_to,
73
+ });
74
+
75
+ if (dryCost) {
76
+ printCostPreview(costEstimate);
77
+ console.log(`\n ${D}[dry-cost] estimate only — no tasks queued${R}\n`);
78
+ return;
79
+ }
80
+
81
+ if (maxCost != null && costEstimate.totalUsd > maxCost) {
82
+ log(`error: estimated cost ${formatUsd(costEstimate.totalUsd)} exceeds --max-cost ${formatUsd(maxCost)}`);
83
+ console.log(` ${D}Re-run with --dry-cost to inspect the breakdown, or raise --max-cost${R}`);
84
+ process.exit(1);
85
+ }
86
+
53
87
  if (dryRun) {
54
88
  console.log(`\n ${D}[dry-run] would create ${tasks.length} task(s) in swarm queue${R}\n`);
55
89
  return;
@@ -88,6 +122,29 @@ async function cmdRun(goal, target, args = []) {
88
122
 
89
123
  console.log(`\n ${T}✓${R} ${created.length} task${created.length === 1 ? "" : "s"} added to swarm queue`);
90
124
  console.log(` ${D}Monitor: 0dai watch | Queue: 0dai swarm status${R}\n`);
125
+
126
+ if (created.length > 0) {
127
+ const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
128
+ recordActivationFirstTask(target, identity.project_id);
129
+ }
130
+ }
131
+
132
+ function printCostPreview(costEstimate) {
133
+ console.log(`\n ${T}Cost preview${R} (estimate):\n`);
134
+ costEstimate.tasks.forEach((row, index) => {
135
+ console.log(
136
+ ` ${T}${index + 1}.${R} ${row.title || "task"}`
137
+ + `\n ${D}→ ${row.agent} [${row.tier}]`
138
+ + ` ~${formatTokens(row.tokens)} tokens`
139
+ + ` ~${formatUsd(row.estimatedUsd)}`
140
+ + ` (${formatUsdPer1k(row.usdPer1k)})${R}`,
141
+ );
142
+ });
143
+ console.log(
144
+ `\n ${T}Total:${R} ~${formatTokens(costEstimate.totalTokens)} tokens`
145
+ + ` ~${formatUsd(costEstimate.totalUsd)}`
146
+ + ` (blended ${formatUsdPer1k(costEstimate.blendedUsdPer1k)})`,
147
+ );
91
148
  }
92
149
 
93
150
  function normalizeProvider(provider) {
@@ -114,4 +171,4 @@ function providerAgent(provider) {
114
171
  return null;
115
172
  }
116
173
 
117
- module.exports = { cmdRun };
174
+ module.exports = { cmdRun, printCostPreview };
@@ -4,6 +4,7 @@ const {
4
4
  log, T, R, D, fs, path, spawnSync, findRepoScript,
5
5
  getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
6
6
  } = shared;
7
+ const { getActivationDurationStats, printActivationStats } = require("../utils/activation_telemetry");
7
8
 
8
9
  function countJson(dir) {
9
10
  try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
@@ -116,6 +117,7 @@ function collectStatusPayload(target) {
116
117
  detected: driftDetected,
117
118
  },
118
119
  warnings: warningCount,
120
+ activation_ttfv: getActivationDurationStats(target),
119
121
  };
120
122
  }
121
123
 
@@ -158,6 +160,8 @@ function cmdStatus(target, options = {}) {
158
160
  console.log(` drift: config changes detected — run: 0dai doctor --drift`);
159
161
  }
160
162
 
163
+ printActivationStats(target);
164
+
161
165
  return payload;
162
166
  }
163
167
 
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("child_process");
4
+ const { buildUpgradeUrl } = require("../utils/plan");
5
+
6
+ function isInteractive() {
7
+ return !!(process.stdin && process.stdin.isTTY && process.stdout && process.stdout.isTTY);
8
+ }
9
+
10
+ function isBrowserDisabled(env = process.env) {
11
+ const browser = env.BROWSER;
12
+ if (browser == null) return false;
13
+ const normalized = String(browser).trim().toLowerCase();
14
+ return normalized === "" || normalized === "0" || normalized === "false" || normalized === "none";
15
+ }
16
+
17
+ function openBrowser(url, deps = {}) {
18
+ const spawn = deps.spawnSync || spawnSync;
19
+ const platform = deps.platform || process.platform;
20
+ let command;
21
+ let args;
22
+ if (platform === "darwin") {
23
+ command = "open";
24
+ args = [url];
25
+ } else if (platform === "win32") {
26
+ command = "cmd";
27
+ args = ["/c", "start", "", url];
28
+ } else {
29
+ command = "xdg-open";
30
+ args = [url];
31
+ }
32
+ const result = spawn(command, args, { stdio: "ignore", timeout: 5000 });
33
+ return result.status === 0;
34
+ }
35
+
36
+ function cmdUpgrade(options = {}) {
37
+ const writeLine = typeof options.writeLine === "function" ? options.writeLine : (msg) => console.log(msg);
38
+ const url = buildUpgradeUrl(options.params);
39
+ const interactive = typeof options.isInteractive === "function"
40
+ ? options.isInteractive()
41
+ : isInteractive();
42
+ const browserDisabled = isBrowserDisabled(options.env || process.env);
43
+
44
+ if (interactive && !browserDisabled) {
45
+ const opened = openBrowser(url, options);
46
+ if (!opened) writeLine(url);
47
+ return;
48
+ }
49
+
50
+ writeLine(url);
51
+ }
52
+
53
+ module.exports = {
54
+ cmdUpgrade,
55
+ openBrowser,
56
+ isInteractive,
57
+ isBrowserDisabled,
58
+ };
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ function activationPath(target) {
7
+ return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
8
+ }
9
+
10
+ function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ function ensureDir(filePath) {
15
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
+ }
17
+
18
+ function readEvents(target) {
19
+ const file = activationPath(target);
20
+ if (!fs.existsSync(file)) return [];
21
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
22
+ const events = [];
23
+ for (const ln of lines) {
24
+ try {
25
+ const row = JSON.parse(ln);
26
+ if (row && row.event) events.push(row);
27
+ } catch { /* ignore malformed lines */ }
28
+ }
29
+ return events;
30
+ }
31
+
32
+ function appendEvent(target, entry) {
33
+ const file = activationPath(target);
34
+ try {
35
+ ensureDir(file);
36
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function hasEvent(events, eventName, projectId) {
44
+ return events.some((e) => e.event === eventName && e.project_id === projectId);
45
+ }
46
+
47
+ function findInitTs(events, projectId) {
48
+ const init = events.find((e) => e.event === "init" && e.project_id === projectId);
49
+ return init && init.ts ? init.ts : null;
50
+ }
51
+
52
+ function parseTs(ts) {
53
+ const ms = Date.parse(ts);
54
+ return Number.isFinite(ms) ? ms : null;
55
+ }
56
+
57
+ // Idempotently append `{event:"init", ts, project_id}` on successful init.
58
+ function recordActivationInit(target, projectId) {
59
+ try {
60
+ if (!projectId) return { fired: false };
61
+ const events = readEvents(target);
62
+ if (hasEvent(events, "init", projectId)) return { fired: false };
63
+ const entry = { event: "init", ts: nowIso(), project_id: projectId };
64
+ return { fired: appendEvent(target, entry) };
65
+ } catch {
66
+ return { fired: false };
67
+ }
68
+ }
69
+
70
+ // Idempotently append first_task with duration_ms = first_task.ts - init.ts.
71
+ function recordActivationFirstTask(target, projectId) {
72
+ try {
73
+ if (!projectId) return { fired: false, durationMs: null };
74
+ const events = readEvents(target);
75
+ if (hasEvent(events, "first_task", projectId)) {
76
+ return { fired: false, durationMs: null };
77
+ }
78
+
79
+ const initTs = findInitTs(events, projectId);
80
+ const ts = nowIso();
81
+ let durationMs = null;
82
+ if (initTs) {
83
+ const initMs = parseTs(initTs);
84
+ const taskMs = parseTs(ts);
85
+ if (initMs != null && taskMs != null) {
86
+ durationMs = Math.max(0, taskMs - initMs);
87
+ }
88
+ }
89
+
90
+ const entry = {
91
+ event: "first_task",
92
+ ts,
93
+ project_id: projectId,
94
+ duration_ms: durationMs,
95
+ };
96
+ const fired = appendEvent(target, entry);
97
+ return { fired, durationMs };
98
+ } catch {
99
+ return { fired: false, durationMs: null };
100
+ }
101
+ }
102
+
103
+ function percentile(sorted, p) {
104
+ if (!sorted.length) return null;
105
+ if (sorted.length === 1) return sorted[0];
106
+ const idx = (sorted.length - 1) * p;
107
+ const lo = Math.floor(idx);
108
+ const hi = Math.ceil(idx);
109
+ if (lo === hi) return sorted[lo];
110
+ const weight = idx - lo;
111
+ return Math.round(sorted[lo] * (1 - weight) + sorted[hi] * weight);
112
+ }
113
+
114
+ function getActivationDurationStats(target) {
115
+ const events = readEvents(target);
116
+ const durations = events
117
+ .filter((e) => e.event === "first_task"
118
+ && typeof e.duration_ms === "number"
119
+ && Number.isFinite(e.duration_ms))
120
+ .map((e) => e.duration_ms)
121
+ .sort((a, b) => a - b);
122
+
123
+ return {
124
+ count: durations.length,
125
+ p50_ms: percentile(durations, 0.5),
126
+ p90_ms: percentile(durations, 0.9),
127
+ };
128
+ }
129
+
130
+ function formatDurationMs(ms) {
131
+ if (ms == null) return "?";
132
+ if (ms < 1000) return `${ms}ms`;
133
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
134
+ return `${(ms / 60000).toFixed(1)}m`;
135
+ }
136
+
137
+ function printActivationStats(target) {
138
+ const stats = getActivationDurationStats(target);
139
+ if (stats.count === 0) return stats;
140
+ console.log(
141
+ ` activation TTFV: p50 ${formatDurationMs(stats.p50_ms)}`
142
+ + ` / p90 ${formatDurationMs(stats.p90_ms)}`
143
+ + ` (${stats.count} sample${stats.count === 1 ? "" : "s"})`,
144
+ );
145
+ return stats;
146
+ }
147
+
148
+ module.exports = {
149
+ activationPath,
150
+ readEvents,
151
+ recordActivationInit,
152
+ recordActivationFirstTask,
153
+ getActivationDurationStats,
154
+ printActivationStats,
155
+ formatDurationMs,
156
+ };