@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.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.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +157 -45
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -16,7 +16,13 @@ import { globTool, grepTool, readTool } from '../tools/file-tools.js';
16
16
  import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
17
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
18
18
  import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
19
+ import { PUGI_TAGLINE } from '@pugi/personas';
19
20
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
21
+ import { runJobsCommand } from '../commands/jobs.js';
22
+ import { runConfigCommand } from './commands/config.js';
23
+ import { runPrivacyCommand } from './commands/privacy.js';
24
+ import { runUndoCommand } from './commands/undo.js';
25
+ import { runBudgetCommand } from './commands/budget.js';
20
26
  /**
21
27
  * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
22
28
  *
@@ -32,8 +38,9 @@ const PUGI_CLI_VERSION = '0.1.0-alpha.1';
32
38
  const handlers = {
33
39
  accounts,
34
40
  build: runEngineTask('build_task'),
41
+ budget: dispatchBudget,
35
42
  code: runEngineTask('code'),
36
- config: notImplemented('config'),
43
+ config: dispatchConfig,
37
44
  doctor,
38
45
  explain: runEngineTask('explain'),
39
46
  fix: runEngineTask('fix'),
@@ -41,20 +48,70 @@ const handlers = {
41
48
  help,
42
49
  idea,
43
50
  init,
51
+ jobs,
44
52
  login,
45
53
  logout,
46
54
  plan: runEngineTask('plan'),
47
- privacy: notImplemented('privacy'),
55
+ privacy: dispatchPrivacy,
48
56
  review,
49
57
  resume,
50
58
  sessions,
51
59
  sync,
52
- undo: notImplemented('undo'),
60
+ undo: dispatchUndo,
53
61
  version,
54
62
  whoami,
55
63
  };
64
+ async function dispatchConfig(args, flags, _session) {
65
+ await runConfigCommand(args, {
66
+ workspaceRoot: process.cwd(),
67
+ json: flags.json,
68
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
69
+ });
70
+ }
71
+ async function dispatchPrivacy(args, flags, _session) {
72
+ await runPrivacyCommand(args, {
73
+ json: flags.json,
74
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
75
+ });
76
+ }
77
+ async function dispatchUndo(args, flags, session) {
78
+ await runUndoCommand(args, {
79
+ workspaceRoot: process.cwd(),
80
+ session,
81
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
82
+ });
83
+ }
84
+ async function dispatchBudget(args, flags, _session) {
85
+ await runBudgetCommand(args, {
86
+ workspaceRoot: process.cwd(),
87
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
88
+ });
89
+ }
56
90
  export async function runCli(argv) {
57
- const { command, args, flags } = parseArgs(argv);
91
+ const { command, args, flags, isBareInvocation } = parseArgs(argv);
92
+ // Bare `pugi` on a TTY enters the REPL-by-default agentic session
93
+ // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
94
+ // that brings Pugi to parity with Claude Code / Codex CLI. When the
95
+ // operator has no credentials yet, we fall back to the α5.0 splash
96
+ // so the install-time `pugi` surface still shows the wordmark +
97
+ // quick-start hints. Non-TTY (CI, pipes, `--no-tty`) also falls
98
+ // through to the splash because the REPL needs raw input and SSE.
99
+ if (isBareInvocation && isInteractive(flags)) {
100
+ const runtimeConfig = resolveRuntimeConfig();
101
+ if (runtimeConfig) {
102
+ const { renderRepl } = await import('../tui/repl-render.js');
103
+ await renderRepl({
104
+ apiUrl: runtimeConfig.apiUrl,
105
+ apiKey: runtimeConfig.apiKey,
106
+ workspaceLabel: workspaceLabel(process.cwd()),
107
+ cliVersion: PUGI_CLI_VERSION,
108
+ });
109
+ return;
110
+ }
111
+ const { renderSplash } = await import('../tui/render.js');
112
+ await renderSplash(PUGI_CLI_VERSION);
113
+ return;
114
+ }
58
115
  const handler = handlers[command] ?? help;
59
116
  const session = openSession(process.cwd());
60
117
  recordCommandStarted(session, command);
@@ -80,6 +137,7 @@ function parseArgs(argv) {
80
137
  dryRun: false,
81
138
  triple: false,
82
139
  offline: false,
140
+ noTty: false,
83
141
  };
84
142
  const args = [];
85
143
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -87,10 +145,10 @@ function parseArgs(argv) {
87
145
  // the test block). Normalize them to the `version` command so users can
88
146
  // discover the CLI works without knowing our subcommand grammar.
89
147
  if (argv[0] === '--version' || argv[0] === '-v') {
90
- return { command: 'version', args: [], flags };
148
+ return { command: 'version', args: [], flags, isBareInvocation: false };
91
149
  }
92
150
  if (argv[0] === '--help' || argv[0] === '-h') {
93
- return { command: 'help', args: [], flags };
151
+ return { command: 'help', args: [], flags, isBareInvocation: false };
94
152
  }
95
153
  for (let index = 0; index < argv.length; index += 1) {
96
154
  const arg = argv[index] ?? '';
@@ -112,6 +170,9 @@ function parseArgs(argv) {
112
170
  else if (arg === '--offline') {
113
171
  flags.offline = true;
114
172
  }
173
+ else if (arg === '--no-tty') {
174
+ flags.noTty = true;
175
+ }
115
176
  else if (arg.startsWith('--privacy=')) {
116
177
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
117
178
  }
@@ -126,10 +187,12 @@ function parseArgs(argv) {
126
187
  args.push(arg);
127
188
  }
128
189
  }
190
+ const isBareInvocation = args.length === 0;
129
191
  return {
130
192
  command: args.shift() ?? 'help',
131
193
  args,
132
194
  flags,
195
+ isBareInvocation,
133
196
  };
134
197
  }
135
198
  async function version(_args, flags, _session) {
@@ -144,7 +207,7 @@ async function help(_args, flags, _session) {
144
207
  writeOutput(flags, { commands }, [
145
208
  'Pugi CLI',
146
209
  '',
147
- 'Usage: pugi <command> [--json] [--web] [--remote]',
210
+ 'Usage: pugi <command> [--json] [--web] [--remote] [--no-tty]',
148
211
  '',
149
212
  'Commands:',
150
213
  ...commands.map((command) => ` ${command}`),
@@ -163,6 +226,11 @@ async function help(_args, flags, _session) {
163
226
  'Sync safety:',
164
227
  ' pugi sync --dry-run --privacy metadata',
165
228
  '',
229
+ 'Interactivity:',
230
+ ' --no-tty Force the line-buffered output path (CI, pipes,',
231
+ ' recording flows, dumb terminals).',
232
+ '',
233
+ PUGI_TAGLINE,
166
234
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
167
235
  ].join('\n'));
168
236
  }
@@ -1711,17 +1779,30 @@ function parseProviderFlag(args) {
1711
1779
  return lower;
1712
1780
  }
1713
1781
  /**
1714
- * Returns true when stdin is attached to a TTY AND `--json` was not
1715
- * supplied. We only prompt interactively when the human is plausibly
1716
- * looking at the screen. The two-condition gate matches Claude Code,
1717
- * gh CLI, and Codex CLI conventions.
1782
+ * Returns true when BOTH stdin and stdout are attached to a TTY AND
1783
+ * `--json` / `--no-tty` / CI markers were not supplied. We only
1784
+ * prompt or render Ink surfaces when a human is plausibly watching
1785
+ * the screen. The multi-condition gate matches Claude Code, gh CLI,
1786
+ * Codex CLI, and the npm CLI conventions.
1718
1787
  */
1719
1788
  function isInteractive(flags) {
1720
1789
  if (flags.json)
1721
1790
  return false;
1722
- // `process.stdin.isTTY` is `undefined` when stdin is a pipe (CI) and
1723
- // `true` when attached to a real terminal.
1724
- return Boolean(process.stdin.isTTY);
1791
+ if (flags.noTty)
1792
+ return false;
1793
+ // Common CI / scripted-context markers. CI is set by every major
1794
+ // provider (GitHub Actions, GitLab, CircleCI, Travis, Buildkite).
1795
+ if (process.env.CI)
1796
+ return false;
1797
+ if (process.env.PUGI_NO_TTY)
1798
+ return false;
1799
+ // `process.stdin.isTTY` / `process.stdout.isTTY` are `undefined`
1800
+ // when the stream is a pipe and `true` when attached to a real
1801
+ // terminal. Require both so a `pugi | tee` invocation falls back
1802
+ // to the line-buffered output path.
1803
+ const stdinTty = Boolean(process.stdin.isTTY);
1804
+ const stdoutTty = Boolean(process.stdout.isTTY);
1805
+ return stdinTty && stdoutTty;
1725
1806
  }
1726
1807
  async function login(args, flags, _session) {
1727
1808
  if (args.includes('--help') || args.includes('-h')) {
@@ -1742,7 +1823,7 @@ async function login(args, flags, _session) {
1742
1823
  ' --provider env Promote PUGI_API_KEY from the environment into the store.',
1743
1824
  ' --token <PAT> Inline API key (visible in `ps`).',
1744
1825
  ' --token-stdin Read API key from stdin (gh-CLI style).',
1745
- ' --label <name> Friendly label for `pugi accounts list`.',
1826
+ ' --label <name> Short label surfaced in `pugi accounts list`.',
1746
1827
  ' --api-url <url> Override the Anvil endpoint (self-hosted).',
1747
1828
  ' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
1748
1829
  '',
@@ -1818,6 +1899,14 @@ async function login(args, flags, _session) {
1818
1899
  // Path 4: interactive menu (TTY, not --json, no token args).
1819
1900
  if (isInteractive(flags)) {
1820
1901
  const choice = await promptLoginVariant(apiUrl);
1902
+ if (choice === null) {
1903
+ // User dismissed the picker via Esc / q. Use exit 130, the
1904
+ // standard "terminated by user signal" exit code (gh CLI,
1905
+ // codex, ssh, vim all use this).
1906
+ writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
1907
+ process.exitCode = 130;
1908
+ return;
1909
+ }
1821
1910
  await dispatchLoginProvider(choice, {
1822
1911
  apiUrl,
1823
1912
  flags,
@@ -1826,39 +1915,31 @@ async function login(args, flags, _session) {
1826
1915
  });
1827
1916
  return;
1828
1917
  }
1829
- // Path 5: no token, no TTY → preserve existing behaviour (device flow
1830
- // unless explicitly opted out).
1831
- if (noDeviceFlow) {
1832
- throw new Error('pugi login requires a token under `--no-device-flow`. Pass `--token <PAT>`, pipe via `--token-stdin`, set PUGI_LOGIN_TOKEN, or use `--provider env` with PUGI_API_KEY exported.');
1833
- }
1834
- await performDeviceFlowLogin(apiUrl, flags, labelFlag);
1918
+ // Path 5: no token, no TTY → previously fell through to a silent
1919
+ // device flow that nobody could answer. The Ink-TUI work refuses
1920
+ // that branch and raises a deterministic error so CI surfaces a
1921
+ // failed login immediately. The message lists every escape hatch.
1922
+ throw new Error('pugi login requires a token in non-interactive mode. Pass `--provider device|token|env`, `--token <PAT>`, pipe via `--token-stdin`, set PUGI_LOGIN_TOKEN, or use `--provider env` with PUGI_API_KEY exported.');
1835
1923
  }
1836
1924
  /**
1837
- * Render the interactive picker shown when `pugi login` runs on a TTY
1838
- * with no token args. Reads a single digit from stdin and returns the
1839
- * provider keyword. Mirrors Claude Code's auth picker UX.
1925
+ * Render the interactive Ink picker shown when `pugi login` runs on
1926
+ * a TTY with no token args. Returns the chosen provider, or `null`
1927
+ * when the user dismisses the picker via Esc / q. Mirrors the
1928
+ * Claude Code / Codex CLI auth picker UX.
1929
+ *
1930
+ * The Ink import is dynamic so a non-interactive `pugi <anything>`
1931
+ * never pays the React+Ink module-load cost. ESM dynamic-import is
1932
+ * cached after first call (same as require).
1840
1933
  */
1841
1934
  async function promptLoginVariant(apiUrl) {
1842
- process.stderr.write([
1843
- '',
1844
- `How would you like to log in to Pugi? (endpoint: ${apiUrl})`,
1845
- '',
1846
- ' 1) Browser-based OAuth (recommended) — opens app.pugi.io, you approve',
1847
- ' 2) Paste an API key (PAT)',
1848
- ' 3) Use PUGI_API_KEY from environment',
1849
- '',
1850
- ].join('\n'));
1851
- const answer = await readSingleChoice('Enter choice [1-3] (default 1): ');
1852
- switch (answer) {
1853
- case '':
1854
- case '1':
1855
- return 'device';
1856
- case '2':
1857
- return 'token';
1858
- case '3':
1859
- return 'env';
1860
- default:
1861
- throw new Error(`Invalid login choice "${answer}". Expected 1, 2, or 3.`);
1935
+ const { renderLoginPicker, LoginCancelledError } = await import('../tui/render.js');
1936
+ try {
1937
+ return await renderLoginPicker(apiUrl);
1938
+ }
1939
+ catch (error) {
1940
+ if (error instanceof LoginCancelledError)
1941
+ return null;
1942
+ throw error;
1862
1943
  }
1863
1944
  }
1864
1945
  /**
@@ -2441,7 +2522,7 @@ function formatResetSuffix(resetAtIso) {
2441
2522
  * Render the login-method label shown in `pugi whoami` and emitted in
2442
2523
  * the JSON envelope. Aliases the resolver's `source` discriminator (env
2443
2524
  * vs file) plus the stored `fileSource` (token vs device-flow vs env
2444
- * promotion) into a single human-friendly word.
2525
+ * promotion) into a single short word.
2445
2526
  */
2446
2527
  function describeLoginMethod(credential) {
2447
2528
  if (credential.source === 'env')
@@ -2604,6 +2685,22 @@ function extractApiUrlFlag(args) {
2604
2685
  function extractLabelFlag(args) {
2605
2686
  return extractNamedFlagValue(args, 'label');
2606
2687
  }
2688
+ /**
2689
+ * `pugi jobs` — surface the persistent JobRegistry on the CLI.
2690
+ * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
2691
+ * (list/status/tail/kill) lives in `src/commands/jobs.ts`; this
2692
+ * handler is a thin shim so the existing command map dispatch
2693
+ * remains the single entry point.
2694
+ */
2695
+ async function jobs(args, flags, session) {
2696
+ const exitCode = await runJobsCommand(args, { json: flags.json }, {
2697
+ write: (text) => process.stdout.write(text),
2698
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
2699
+ }, session.id);
2700
+ if (exitCode !== 0) {
2701
+ process.exitCode = exitCode;
2702
+ }
2703
+ }
2607
2704
  function notImplemented(command) {
2608
2705
  return async (_args, flags) => {
2609
2706
  const payload = {
@@ -2632,6 +2729,21 @@ function ensurePugiGitIgnore(cwd, created, skipped) {
2632
2729
  writeFileSync(gitignorePath, next, { encoding: 'utf8' });
2633
2730
  created.push(`${gitignorePath} (+${marker})`);
2634
2731
  }
2732
+ /**
2733
+ * Compute the workspace label surfaced in the REPL header bar
2734
+ * (Sprint α5.7). We prefer the basename of the workspace root because
2735
+ * that is what the operator sees in their shell prompt — keeping the
2736
+ * REPL header in sync with `pwd` lets the operator orient at a glance.
2737
+ * Empty / pathological cwd values (a worktree resolved to `/`) fall
2738
+ * back to `workspace` so the header never collapses.
2739
+ */
2740
+ function workspaceLabel(cwd) {
2741
+ const segments = cwd.split('/').filter((s) => s.length > 0);
2742
+ const last = segments[segments.length - 1];
2743
+ if (!last || last.length === 0)
2744
+ return 'workspace';
2745
+ return last;
2746
+ }
2635
2747
  function ensureDir(path, created, skipped) {
2636
2748
  if (existsSync(path)) {
2637
2749
  skipped.push(path);
@@ -0,0 +1,192 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ const RATE_INPUT_USD_PER_TOKEN = 0.000003;
4
+ const RATE_OUTPUT_USD_PER_TOKEN = 0.000015;
5
+ export async function runBudgetCommand(args, ctx) {
6
+ const flags = parseFlags(args);
7
+ const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
8
+ if (!existsSync(eventsPath)) {
9
+ ctx.writeOutput({
10
+ command: 'budget',
11
+ status: 'noop',
12
+ reason: 'no_session',
13
+ tokens: 0,
14
+ dollars: 0,
15
+ }, 'No session events found. Run a Pugi command first.');
16
+ return;
17
+ }
18
+ const events = readEvents(eventsPath);
19
+ const cutoff = flags.sinceMs === null ? 0 : Date.now() - flags.sinceMs;
20
+ const summary = summarise(events, cutoff);
21
+ const dollars = estimateDollars(summary.tokens);
22
+ const payload = {
23
+ command: 'budget',
24
+ status: 'ok',
25
+ workspaceRoot: ctx.workspaceRoot,
26
+ window: flags.sinceMs === null ? 'session' : `last ${args.find((a) => a.startsWith('--since='))?.slice('--since='.length) ?? ''}`,
27
+ tokens: summary.tokens,
28
+ dollars,
29
+ perCommand: summary.perCommand,
30
+ perPersona: summary.perPersona,
31
+ rate: {
32
+ inputUsdPerToken: RATE_INPUT_USD_PER_TOKEN,
33
+ outputUsdPerToken: RATE_OUTPUT_USD_PER_TOKEN,
34
+ assumedSplit: 'event log does not break tokens into in/out; rate uses output-token assumption',
35
+ },
36
+ };
37
+ const text = [
38
+ 'Pugi budget',
39
+ `Window: ${payload.window}`,
40
+ `Tokens: ${summary.tokens}`,
41
+ `Estimated cost: $${dollars.toFixed(4)} (output-token rate)`,
42
+ summary.perCommand.length > 0
43
+ ? `Per command:\n${summary.perCommand
44
+ .map((entry) => ` ${entry.command.padEnd(12)} ${entry.tokens} tokens`)
45
+ .join('\n')}`
46
+ : 'Per command: (no entries)',
47
+ summary.perPersona.length > 0
48
+ ? `Per persona:\n${summary.perPersona
49
+ .map((entry) => ` ${entry.persona.padEnd(20)} ${entry.tokens} tokens`)
50
+ .join('\n')}`
51
+ : 'Per persona: (no entries)',
52
+ ].join('\n');
53
+ ctx.writeOutput(payload, text);
54
+ }
55
+ function summarise(events, cutoffMs) {
56
+ let tokens = 0;
57
+ const perCommand = new Map();
58
+ const perPersona = new Map();
59
+ // Track the active command via command_started / command_completed
60
+ // bookends so we can attribute tool_result tokens to the surrounding
61
+ // command. Multiple commands per session are normal.
62
+ let activeCommand = null;
63
+ for (const event of events) {
64
+ if (!matchesWindow(event, cutoffMs))
65
+ continue;
66
+ if (event.type === 'session' && event.name === 'command_started' && typeof event.command === 'string') {
67
+ activeCommand = event.command;
68
+ continue;
69
+ }
70
+ if (event.type === 'session' && event.name === 'command_completed') {
71
+ activeCommand = null;
72
+ continue;
73
+ }
74
+ const eventTokens = extractTokens(event);
75
+ if (eventTokens > 0) {
76
+ tokens += eventTokens;
77
+ if (activeCommand) {
78
+ perCommand.set(activeCommand, (perCommand.get(activeCommand) ?? 0) + eventTokens);
79
+ }
80
+ const persona = extractPersona(event);
81
+ if (persona) {
82
+ perPersona.set(persona, (perPersona.get(persona) ?? 0) + eventTokens);
83
+ }
84
+ }
85
+ }
86
+ return {
87
+ tokens,
88
+ perCommand: Array.from(perCommand.entries())
89
+ .map(([command, value]) => ({ command, tokens: value }))
90
+ .sort((a, b) => b.tokens - a.tokens),
91
+ perPersona: Array.from(perPersona.entries())
92
+ .map(([persona, value]) => ({ persona, tokens: value }))
93
+ .sort((a, b) => b.tokens - a.tokens),
94
+ };
95
+ }
96
+ function matchesWindow(event, cutoffMs) {
97
+ if (cutoffMs === 0)
98
+ return true;
99
+ const ts = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : NaN;
100
+ if (!Number.isFinite(ts))
101
+ return false;
102
+ return ts >= cutoffMs;
103
+ }
104
+ /**
105
+ * Extract a token count from any event shape we know about.
106
+ *
107
+ * - `tool_result.tokensUsed` — engine adapter emits this.
108
+ * - `subagent.completed.tokensUsed` — α5.4 subagent runner.
109
+ * - `engine.turn.tokensIn/tokensOut` — Anvil F1 metric mirror, future.
110
+ *
111
+ * Unknown events return 0 so adding new event types upstream never
112
+ * breaks `pugi budget`.
113
+ */
114
+ function extractTokens(event) {
115
+ const direct = numericField(event, 'tokensUsed');
116
+ if (direct > 0)
117
+ return direct;
118
+ const tIn = numericField(event, 'tokensIn');
119
+ const tOut = numericField(event, 'tokensOut');
120
+ if (tIn + tOut > 0)
121
+ return tIn + tOut;
122
+ return 0;
123
+ }
124
+ function numericField(event, key) {
125
+ const raw = event[key];
126
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)
127
+ return 0;
128
+ return Math.floor(raw);
129
+ }
130
+ function extractPersona(event) {
131
+ const candidate = event['persona'] ?? event['personaSlug'] ?? event['subagent'];
132
+ if (typeof candidate === 'string' && candidate.length > 0)
133
+ return candidate;
134
+ return null;
135
+ }
136
+ function estimateDollars(totalTokens) {
137
+ // Use output-token rate as the worst case so a user planning to upgrade
138
+ // a tier is not surprised by an under-estimate.
139
+ return Number((totalTokens * RATE_OUTPUT_USD_PER_TOKEN).toFixed(6));
140
+ }
141
+ function parseFlags(args) {
142
+ const flags = { json: false, sinceMs: null };
143
+ for (let i = 0; i < args.length; i += 1) {
144
+ const arg = args[i] ?? '';
145
+ if (arg === '--json')
146
+ flags.json = true;
147
+ else if (arg.startsWith('--since='))
148
+ flags.sinceMs = parseDuration(arg.slice('--since='.length));
149
+ else if (arg === '--since') {
150
+ const value = args[i + 1];
151
+ if (!value)
152
+ throw new Error('--since requires a duration like 24h, 30m, or 7d.');
153
+ flags.sinceMs = parseDuration(value);
154
+ i += 1;
155
+ }
156
+ }
157
+ return flags;
158
+ }
159
+ function parseDuration(raw) {
160
+ const match = /^(\d+)(h|m|d|s)?$/.exec(raw.trim());
161
+ if (!match) {
162
+ throw new Error(`Invalid --since duration "${raw}". Expected forms: 24h, 30m, 7d, 90s.`);
163
+ }
164
+ const amount = Number(match[1]);
165
+ const unit = match[2] ?? 'h';
166
+ const multiplier = unit === 'h'
167
+ ? 60 * 60 * 1000
168
+ : unit === 'm'
169
+ ? 60 * 1000
170
+ : unit === 'd'
171
+ ? 24 * 60 * 60 * 1000
172
+ : 1000;
173
+ return amount * multiplier;
174
+ }
175
+ function readEvents(path) {
176
+ const raw = readFileSync(path, 'utf8');
177
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
178
+ const out = [];
179
+ for (const line of lines) {
180
+ try {
181
+ const parsed = JSON.parse(line);
182
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
183
+ out.push(parsed);
184
+ }
185
+ }
186
+ catch {
187
+ // partial-write lines are ignored
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ //# sourceMappingURL=budget.js.map