@node9/proxy 1.20.0 โ†’ 1.21.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.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <h1 align="center">๐Ÿ›ก๏ธ Node9</h1>
2
- <p align="center"><strong>What did your AI agent actually do? Find out, and stop the dangerous stuff.</strong></p>
2
+ <p align="center"><strong>What did your AI agent actually do? Find out.</strong></p>
3
3
  <p align="center">
4
4
  <a href="https://www.npmjs.com/package/node9-ai"><img src="https://img.shields.io/npm/v/node9-ai.svg" alt="npm version" /></a>
5
5
  <a href="https://www.npmjs.com/package/node9-ai"><img src="https://img.shields.io/npm/dm/node9-ai.svg" alt="monthly downloads" /></a>
@@ -8,21 +8,33 @@
8
8
  <a href="https://huggingface.co/spaces/Node9ai/node9-security-demo"><img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/open-in-hf-spaces-sm.svg" alt="Try on HF Spaces" /></a>
9
9
  </p>
10
10
 
11
- Node9 sits between your AI agent and the tools it can use โ€” recording every action, blocking the dangerous ones, and showing you what happened both live and in retrospect.
11
+ Node9 sits between your AI agent and the tools it can use โ€” **discover** what it's already been doing, **protect** against risky actions in real time, and **review** what happened over any time window.
12
12
 
13
13
  Works with **Claude Code ยท Codex CLI ยท Gemini CLI ยท Cursor ยท Windsurf ยท any MCP server**.
14
14
 
15
15
  ## What Node9 does
16
16
 
17
- - ๐Ÿ›‘ **Block** dangerous AI actions before they run โ€” `rm -rf`, `git push --force`, `DROP TABLE`, credential reads, `curl | bash`
18
- - ๐Ÿ” **Scan** what your AI has already been doing โ€” loops, leaked secrets, blocked operations across every session
19
- - ๐Ÿ”‘ **Catch credential leaks** โ€” AWS keys, GitHub tokens, JWTs, GCP API keys, PEM private keys flagged in tool args, file contents, and shell config
20
- - ๐Ÿ”ญ **Map your blast radius** โ€” every SSH key, AWS credential, and `.env` file an AI agent on this machine could reach right now
17
+ - ๐Ÿ” **Discover** โ€” scan every past AI session for credential leaks, agent loops, blocked operations, and every secret on disk an agent could reach right now
18
+ - ๐Ÿ›ก **Protect** โ€” review or block risky commands before they run โ€” `rm -rf`, `git push --force`, `DROP TABLE`, credential reads, `curl | bash`, AWS/GitHub/Stripe key leaks
19
+ - ๐Ÿ“Š **Review** โ€” period-windowed report (today / week / month / 90 days) โ€” cost per agent, top tools, shields fired, blast radius
20
+
21
+ ## Retrospective scan
22
+
23
+ This is my own machine โ€” 90 days while building Node9. Score 25/100, 5 credential files an AI agent could reach right now.
24
+
25
+ ```bash
26
+ npx node9-ai scan # before installation, runs in ~10s, nothing uploads
27
+ node9 scan # after installation, same output
28
+ ```
29
+
30
+ <p align="center">
31
+ <img src="https://github.com/user-attachments/assets/7c5b30f1-1ca1-40b4-bfd5-d6671002e98e" width="720" alt="Node9 scan scorecard" />
32
+ </p>
21
33
 
22
34
  ## Live monitoring
23
35
 
24
36
  <p align="center">
25
- <img src="https://github.com/user-attachments/assets/25c601db-221d-4553-8b8c-34af85ab30c8" width="720" alt="Node9 monitor dashboard" />
37
+ <img src="https://github.com/user-attachments/assets/4661da97-c174-4bae-ae54-4c52a1d69213" width="720" alt="Node9 monitor dashboard" />
26
38
  </p>
27
39
 
28
40
  `node9 monitor` opens an interactive terminal dashboard with two views:
@@ -30,32 +42,19 @@ Works with **Claude Code ยท Codex CLI ยท Gemini CLI ยท Cursor ยท Windsurf ยท any
30
42
  - **`[1]` Realtime** โ€” live activity, approvals, security alerts, current risk score
31
43
  - **`[2]` Report** โ€” period-windowed summary: cost, top tools, shields fired, blast radius
32
44
 
33
- ## Retrospective scan
45
+ ## Report
34
46
 
35
- This is my own machine โ€” 30 days while building Node9. Score 25/100, 5 credential files an AI agent could reach right now.
47
+ Press `[2]` in monitor for a period-windowed summary. Toggle the window with `[T]oday` ยท `[W]eek` ยท `[M]onth` ยท `[N]inety` โ€” same panels as the scan above, driven by your post-install audit log.
36
48
 
37
49
  <p align="center">
38
- <img src="https://github.com/user-attachments/assets/bc165779-4200-438d-967a-20d42bbfe69e" width="720" alt="Node9 scan scorecard" />
50
+ <img src="https://github.com/user-attachments/assets/66c02a72-e477-443d-807f-d65a21d096cd" width="720" alt="Node9 monitor [2] Report" />
39
51
  </p>
40
52
 
41
- ```
42
- ๐Ÿ›ก Node9 Scan ยท 21 sessions ยท 8,114 tool calls ยท Apr 6 โ€“ May 1, 2026
43
-
44
- Security Score: 25/100 ยท Critical
45
- $3,789 AI spend ยท 62 risky operations
46
-
47
- ๐Ÿ”‘ 14 credential leak (Bearer Token ร—4, GCP API Key ร—4, JWT ร—2)
48
- ๐Ÿ›‘ 15 would have blocked (force-push ร—5, read-ssh ร—4, read-aws ร—4)
49
- ๐Ÿ” 193 agent loops (18% wasted ยท ~$6.51)
50
- ๐Ÿ‘ 33 flagged for review (git-destructive ร—19, rm ร—9, sudo ร—2)
51
-
52
- ๐Ÿ”ญ Blast radius ssh ร— gcp ร— npm ร— other (5 exposures)
53
-
54
- โ†’ npx node9-ai scan run this on your machine
53
+ ```bash
54
+ node9 monitor # press [2] for Report view
55
+ node9 report --period 7d # CLI form, no TUI
55
56
  ```
56
57
 
57
- Run it on yours โ€” `npx node9-ai scan` finishes in ~10 seconds and runs entirely local. Nothing uploads.
58
-
59
58
  ## Install
60
59
 
61
60
  ```bash
@@ -97,10 +96,10 @@ node9 shield list # show all shields + status
97
96
 
98
97
  ## Always on โ€” no config needed
99
98
 
100
- - **Git** โ€” blocks `git push --force`, `git reset --hard`, `git clean -fd`
101
- - **SQL** โ€” blocks `DELETE` / `UPDATE` without `WHERE`, `DROP TABLE`, `TRUNCATE`
102
- - **Shell** โ€” blocks `curl | bash`, unauthorized `sudo`
103
- - **DLP** โ€” blocks AWS keys, GitHub tokens, Stripe keys, PEM private keys in any tool argument, file contents, or shell config (`~/.zshrc`, `~/.bashrc`)
99
+ - **Git** โ€” catches `git push --force`, `git reset --hard`, `git clean -fd`
100
+ - **SQL** โ€” catches `DELETE` / `UPDATE` without `WHERE`, `DROP TABLE`, `TRUNCATE`
101
+ - **Shell** โ€” catches `curl | bash`, unauthorized `sudo`
102
+ - **DLP** โ€” flags AWS keys, GitHub tokens, Stripe keys, PEM private keys in any tool argument, file contents, or shell config (`~/.zshrc`, `~/.bashrc`)
104
103
  - **Response DLP** โ€” background scanner reads Claude's conversation history and alerts you if Claude _wrote_ a secret in its response text
105
104
  - **Auto-undo** โ€” git snapshot before every AI file edit โ†’ `node9 undo` to revert
106
105
  - **Skills pinning** โ€” SHA-256 verification of installed Claude skills / plugins between sessions
@@ -141,17 +140,17 @@ node9 mcp pin reset # clear all pins
141
140
 
142
141
  </details>
143
142
 
144
- ## Observability โ€” five views
143
+ ## Other commands
144
+
145
+ Beyond the three flow commands above (`scan` / `monitor` / `report`):
145
146
 
146
- | Command | What it shows | When to use |
147
- | ---------------- | --------------------------------------------------------- | ----------------------------------------- |
148
- | `node9 blast` | What an AI agent can reach right now โ€” files, creds, env | First thing to run on any machine |
149
- | `node9 scan` | Retrospective audit of existing agent history | Before installing, or to review past risk |
150
- | `node9 tail` | Live stream of every tool call | Watching an agent work in real time |
151
- | `node9 report` | Per-period summary: allowed/blocked/DLP/cost + top tools | Reviewing what happened after a session |
152
- | `node9 sessions` | Session history with prompt, tool trace, cost, snapshot | Reviewing a handoff or past work |
153
- | `node9 dlp` | Credential-leak findings in Claude response text | Any time a DLP desktop alert fires |
154
- | `node9 mask` | Redact plaintext secrets from local session history files | After a DLP finding โ€” cleans local disk |
147
+ | Command | What it shows | When to use |
148
+ | ---------------- | --------------------------------------------------------- | --------------------------------------- |
149
+ | `node9 blast` | What an AI agent can reach right now โ€” files, creds, env | First thing to run on any machine |
150
+ | `node9 tail` | Live stream of every tool call (text-only, no TUI) | Piping into other tools, CI, logs |
151
+ | `node9 sessions` | Session history with prompt, tool trace, cost, snapshot | Reviewing a handoff or past work |
152
+ | `node9 dlp` | Credential-leak findings in Claude response text | Any time a DLP desktop alert fires |
153
+ | `node9 mask` | Redact plaintext secrets from local session history files | After a DLP finding โ€” cleans local disk |
155
154
 
156
155
  Plus a **live HUD** in your Claude Code statusline:
157
156
 
@@ -167,7 +166,7 @@ Node9 surfaces the signal. Here are the patterns worth knowing:
167
166
 
168
167
  | Signal | Likely meaning |
169
168
  | ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
170
- | `Would have blocked` โ‰ฅ 5 in a week | Agent is attempting destructive ops; shields need review |
169
+ | `Would have blocked` โ‰ฅ 5 in a week | Agent is attempting high-impact ops; shields are worth reviewing |
171
170
  | Single `review-git-push` rule >50% of findings | Your own rule is firing as intended โ€” not a risk, just supervision |
172
171
  | DLP finding in `user-prompt` tool | You pasted a secret into your own prompt โ€” rotate the key |
173
172
  | Agent Loop ร—50+ on same file | Agent stuck in edit/test/fix cycle โ€” check context or slow down |
package/dist/cli.js CHANGED
@@ -7780,8 +7780,9 @@ function boxPanel(title, bodyLines, width = PANEL_WIDTH) {
7780
7780
  const inner = width - 4;
7781
7781
  const out = [];
7782
7782
  const titlePad = ` ${title} `;
7783
- const titleSegment = titlePad.length <= inner ? titlePad : titlePad.slice(0, inner);
7784
- const dashFill = "\u2500".repeat(Math.max(0, inner - titleSegment.length));
7783
+ const titleWidth = (0, import_string_width.default)(titlePad);
7784
+ const titleSegment = titleWidth <= inner ? titlePad : titlePad.slice(0, inner);
7785
+ const dashFill = "\u2500".repeat(Math.max(0, inner - (0, import_string_width.default)(titleSegment)));
7785
7786
  out.push(import_chalk3.default.dim("\u256D\u2500") + import_chalk3.default.bold(titleSegment) + import_chalk3.default.dim(`${dashFill}\u2500\u256E`));
7786
7787
  for (const line of bodyLines) {
7787
7788
  const padding = " ".repeat(Math.max(0, inner - line.width));
@@ -7798,11 +7799,12 @@ function relativeDate(timestamp, now = /* @__PURE__ */ new Date()) {
7798
7799
  if (days > 90) return "90d+";
7799
7800
  return `${days}d`;
7800
7801
  }
7801
- var import_chalk3, PANEL_WIDTH;
7802
+ var import_chalk3, import_string_width, PANEL_WIDTH;
7802
7803
  var init_scan_derive = __esm({
7803
7804
  "src/cli/render/scan-derive.ts"() {
7804
7805
  "use strict";
7805
7806
  import_chalk3 = __toESM(require("chalk"));
7807
+ import_string_width = __toESM(require("string-width"));
7806
7808
  PANEL_WIDTH = 76;
7807
7809
  }
7808
7810
  });
@@ -10246,7 +10248,7 @@ function mkLine(...parts) {
10246
10248
  let width = 0;
10247
10249
  for (const [text, fmt] of parts) {
10248
10250
  rendered += fmt ? fmt(text) : text;
10249
- width += text.length;
10251
+ width += (0, import_string_width2.default)(text);
10250
10252
  }
10251
10253
  return { rendered, width };
10252
10254
  }
@@ -10367,7 +10369,11 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10367
10369
  const origin = originForRule(r.name, summary.sections);
10368
10370
  reviewLines.push(
10369
10371
  mkLine(
10370
- ["\u{1F441} ", import_chalk5.default.yellow],
10372
+ // VS-16 (U+FE0F) forces emoji-presentation so string-width
10373
+ // returns 2 cells (matching how modern terminals actually
10374
+ // render it). Without VS-16 string-width says 1 cell โ€” and
10375
+ // the right border drifts off. Same applies to ๐Ÿ›ก / โš  below.
10376
+ ["\u{1F441}\uFE0F ", import_chalk5.default.yellow],
10371
10377
  [shortRule(r.name, 24), import_chalk5.default.bold],
10372
10378
  [" \xD7" + String(r.count).padEnd(4), import_chalk5.default.bold],
10373
10379
  [" "],
@@ -10425,7 +10431,7 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10425
10431
  }
10426
10432
  for (const e of blast.envFindings.slice(0, 3)) {
10427
10433
  blastLines.push(
10428
- mkLine(["\u26A0 ", import_chalk5.default.yellow], [`${e.key} `], [`(${e.patternName})`, import_chalk5.default.dim])
10434
+ mkLine(["\u26A0\uFE0F ", import_chalk5.default.yellow], [`${e.key} `], [`(${e.patternName})`, import_chalk5.default.dim])
10429
10435
  );
10430
10436
  }
10431
10437
  const totalExposed = blast.reachable.length + blast.envFindings.length;
@@ -10449,7 +10455,7 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10449
10455
  if (impact.totalCatches === 0) continue;
10450
10456
  const discount = PROTECTIVE_SHIELD_DISCOUNTS[impact.shieldName] ?? 0;
10451
10457
  const bonus = Math.round(exposed * discount);
10452
- const icon = discount > 0 ? "\u{1F6E1} " : "\u2610 ";
10458
+ const icon = discount > 0 ? "\u{1F6E1}\uFE0F " : "\u2610 ";
10453
10459
  const wouldCatch = `would catch ${impact.totalCatches} op${impact.totalCatches !== 1 ? "s" : ""}`;
10454
10460
  const deltaSuffix = bonus > 0 ? ` \u2192 +${bonus} pts (${blast.score} \u2192 ${blast.score + bonus})` : "";
10455
10461
  shieldLines.push(
@@ -10499,7 +10505,7 @@ function originForRule(ruleName, sections) {
10499
10505
  return "";
10500
10506
  }
10501
10507
  function registerScanCommand(program2) {
10502
- program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 90 days)").option("--days <n>", "Scan last N days of history", "90").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option(
10508
+ program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 90 days)").option("--days <n>", "Scan last N days of history", "90").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option("--classic", "Original chalk-based scorecard layout (default: new Ink-rendered view)").option(
10503
10509
  "--json",
10504
10510
  "Emit machine-readable JSON to stdout (suppresses banner, progress, and renderer)"
10505
10511
  ).option(
@@ -10533,7 +10539,8 @@ function registerScanCommand(program2) {
10533
10539
  })();
10534
10540
  const isWired = getAgentsStatus().some((a) => a.wired);
10535
10541
  const screenshotMode = options.compact || options.narrative;
10536
- const quiet = screenshotMode || options.json;
10542
+ const useInk = !options.classic && !drillDown;
10543
+ const quiet = screenshotMode || options.json || useInk;
10537
10544
  if (!quiet) {
10538
10545
  console.log("");
10539
10546
  if (!isWired) {
@@ -10671,6 +10678,7 @@ function registerScanCommand(program2) {
10671
10678
  });
10672
10679
  return;
10673
10680
  }
10681
+ const useInkForHero = !options.classic && !drillDown;
10674
10682
  if (totalFindings === 0 && scan.dlpFindings.length === 0) {
10675
10683
  console.log(import_chalk5.default.green(" \u2705 No risky operations found in your history."));
10676
10684
  console.log(
@@ -10690,9 +10698,11 @@ function registerScanCommand(program2) {
10690
10698
  const since = daysAgo === 0 ? "today" : daysAgo === 1 ? "yesterday" : `${daysAgo} days ago`;
10691
10699
  return import_chalk5.default.dim(" \xB7 ") + arrow + import_chalk5.default.dim(` since ${since}`);
10692
10700
  })();
10693
- console.log(
10694
- " " + (score.band === "critical" ? import_chalk5.default.red.bold("\u26A0 ") : "") + import_chalk5.default.bold("Security Score ") + score.color.bold(`${blast.score}/100`) + " " + severityDisplay + trendSuffix + import_chalk5.default.dim(" \xB7 ") + (totalRisky > 0 ? import_chalk5.default.red.bold(`${totalRisky} risky operation${totalRisky !== 1 ? "s" : ""}`) : import_chalk5.default.green("No risky operations"))
10695
- );
10701
+ if (!useInkForHero) {
10702
+ console.log(
10703
+ " " + (score.band === "critical" ? import_chalk5.default.red.bold("\u26A0 ") : "") + import_chalk5.default.bold("Security Score ") + score.color.bold(`${blast.score}/100`) + " " + severityDisplay + trendSuffix + import_chalk5.default.dim(" \xB7 ") + (totalRisky > 0 ? import_chalk5.default.red.bold(`${totalRisky} risky operation${totalRisky !== 1 ? "s" : ""}`) : import_chalk5.default.green("No risky operations"))
10704
+ );
10705
+ }
10696
10706
  const cardParts = [];
10697
10707
  if (scan.dlpFindings.length > 0) {
10698
10708
  cardParts.push(
@@ -10721,10 +10731,10 @@ function registerScanCommand(program2) {
10721
10731
  import_chalk5.default.red("\u{1F52D} ") + import_chalk5.default.red.bold(String(blastExposures)) + import_chalk5.default.dim(" exposures")
10722
10732
  );
10723
10733
  }
10724
- if (cardParts.length > 0) {
10734
+ if (cardParts.length > 0 && !useInkForHero) {
10725
10735
  console.log(" " + cardParts.join(import_chalk5.default.dim(" ")));
10726
10736
  }
10727
- if (scan.totalCostUSD > 0) {
10737
+ if (scan.totalCostUSD > 0 && !useInkForHero) {
10728
10738
  console.log(
10729
10739
  " " + import_chalk5.default.dim("AI spend ") + import_chalk5.default.bold(fmtCost(scan.totalCostUSD)) + (summary.loopWastedUSD > 0 ? import_chalk5.default.dim(" \xB7 wasted on loops ") + import_chalk5.default.yellow("~" + fmtCost(summary.loopWastedUSD)) : "")
10730
10740
  );
@@ -10736,16 +10746,38 @@ function registerScanCommand(program2) {
10736
10746
  )
10737
10747
  );
10738
10748
  }
10739
- console.log("");
10749
+ if (!useInkForHero) {
10750
+ console.log("");
10751
+ }
10740
10752
  if (!drillDown) {
10741
- renderPanelScorecard({
10742
- scan,
10743
- summary,
10744
- blast,
10745
- blastExposures,
10746
- blockedCount,
10747
- reviewCount
10748
- });
10753
+ const useInk2 = !options.classic;
10754
+ if (useInk2) {
10755
+ const scanInkPath = import_path21.default.join(__dirname, "scan-ink.mjs");
10756
+ const dynamicImport = new Function("id", "return import(id)");
10757
+ const mod = await dynamicImport(`file://${scanInkPath}`);
10758
+ const rangeLabel2 = options.all ? "all time" : `last ${options.days ?? 90} days`;
10759
+ mod.renderScanScorecardInk(
10760
+ {
10761
+ scan,
10762
+ summary,
10763
+ blast,
10764
+ blastExposures,
10765
+ blockedCount,
10766
+ reviewCount
10767
+ },
10768
+ rangeLabel2
10769
+ );
10770
+ console.log("");
10771
+ } else {
10772
+ renderPanelScorecard({
10773
+ scan,
10774
+ summary,
10775
+ blast,
10776
+ blastExposures,
10777
+ blockedCount,
10778
+ reviewCount
10779
+ });
10780
+ }
10749
10781
  const cta = isWired ? "\u2705 node9 is active" : "\u2192 install node9 to enable protection";
10750
10782
  console.log(" " + import_chalk5.default.green(cta));
10751
10783
  console.log(
@@ -10945,7 +10977,7 @@ function registerScanCommand(program2) {
10945
10977
  }
10946
10978
  );
10947
10979
  }
10948
- var import_chalk5, import_fs19, import_path21, import_os18, CLAUDE_PRICING, GEMINI_PRICING, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTERNS, TERMINAL_ESCAPE_RE2, LOOP_TOOLS, LOOP_THRESHOLD, LOOP_TIMESPAN_THRESHOLD_MS, STUCK_TOOLS_MIN_WASTE, STUCK_TOOLS_LIMIT, RECURRING_SESSION_THRESHOLD, STALE_AGE_DAYS, classifyRuleSeverity2, narrativeRuleLabel2;
10980
+ var import_chalk5, import_fs19, import_path21, import_os18, import_string_width2, CLAUDE_PRICING, GEMINI_PRICING, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTERNS, TERMINAL_ESCAPE_RE2, LOOP_TOOLS, LOOP_THRESHOLD, LOOP_TIMESPAN_THRESHOLD_MS, STUCK_TOOLS_MIN_WASTE, STUCK_TOOLS_LIMIT, RECURRING_SESSION_THRESHOLD, STALE_AGE_DAYS, classifyRuleSeverity2, narrativeRuleLabel2;
10949
10981
  var init_scan = __esm({
10950
10982
  "src/cli/commands/scan.ts"() {
10951
10983
  "use strict";
@@ -10964,6 +10996,7 @@ var init_scan = __esm({
10964
10996
  init_blast();
10965
10997
  init_scan_derive();
10966
10998
  init_protection();
10999
+ import_string_width2 = __toESM(require("string-width"));
10967
11000
  init_scan_json();
10968
11001
  init_scan_history();
10969
11002
  CLAUDE_PRICING = {
package/dist/cli.mjs CHANGED
@@ -7708,6 +7708,7 @@ var init_blast = __esm({
7708
7708
 
7709
7709
  // src/cli/render/scan-derive.ts
7710
7710
  import chalk3 from "chalk";
7711
+ import stringWidth from "string-width";
7711
7712
  function classifyScore(score) {
7712
7713
  if (score >= 80) return { band: "good", label: "Good", color: chalk3.green };
7713
7714
  if (score >= 50) return { band: "at-risk", label: "At Risk", color: chalk3.yellow };
@@ -7756,8 +7757,9 @@ function boxPanel(title, bodyLines, width = PANEL_WIDTH) {
7756
7757
  const inner = width - 4;
7757
7758
  const out = [];
7758
7759
  const titlePad = ` ${title} `;
7759
- const titleSegment = titlePad.length <= inner ? titlePad : titlePad.slice(0, inner);
7760
- const dashFill = "\u2500".repeat(Math.max(0, inner - titleSegment.length));
7760
+ const titleWidth = stringWidth(titlePad);
7761
+ const titleSegment = titleWidth <= inner ? titlePad : titlePad.slice(0, inner);
7762
+ const dashFill = "\u2500".repeat(Math.max(0, inner - stringWidth(titleSegment)));
7761
7763
  out.push(chalk3.dim("\u256D\u2500") + chalk3.bold(titleSegment) + chalk3.dim(`${dashFill}\u2500\u256E`));
7762
7764
  for (const line of bodyLines) {
7763
7765
  const padding = " ".repeat(Math.max(0, inner - line.width));
@@ -8903,6 +8905,7 @@ import chalk5 from "chalk";
8903
8905
  import fs19 from "fs";
8904
8906
  import path21 from "path";
8905
8907
  import os18 from "os";
8908
+ import stringWidth2 from "string-width";
8906
8909
  function claudeModelPrice(model) {
8907
8910
  const base = model.replace(/@.*$/, "").replace(/-\d{8}$/, "");
8908
8911
  for (const [key, p] of Object.entries(CLAUDE_PRICING)) {
@@ -10225,7 +10228,7 @@ function mkLine(...parts) {
10225
10228
  let width = 0;
10226
10229
  for (const [text, fmt] of parts) {
10227
10230
  rendered += fmt ? fmt(text) : text;
10228
- width += text.length;
10231
+ width += stringWidth2(text);
10229
10232
  }
10230
10233
  return { rendered, width };
10231
10234
  }
@@ -10346,7 +10349,11 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10346
10349
  const origin = originForRule(r.name, summary.sections);
10347
10350
  reviewLines.push(
10348
10351
  mkLine(
10349
- ["\u{1F441} ", chalk5.yellow],
10352
+ // VS-16 (U+FE0F) forces emoji-presentation so string-width
10353
+ // returns 2 cells (matching how modern terminals actually
10354
+ // render it). Without VS-16 string-width says 1 cell โ€” and
10355
+ // the right border drifts off. Same applies to ๐Ÿ›ก / โš  below.
10356
+ ["\u{1F441}\uFE0F ", chalk5.yellow],
10350
10357
  [shortRule(r.name, 24), chalk5.bold],
10351
10358
  [" \xD7" + String(r.count).padEnd(4), chalk5.bold],
10352
10359
  [" "],
@@ -10404,7 +10411,7 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10404
10411
  }
10405
10412
  for (const e of blast.envFindings.slice(0, 3)) {
10406
10413
  blastLines.push(
10407
- mkLine(["\u26A0 ", chalk5.yellow], [`${e.key} `], [`(${e.patternName})`, chalk5.dim])
10414
+ mkLine(["\u26A0\uFE0F ", chalk5.yellow], [`${e.key} `], [`(${e.patternName})`, chalk5.dim])
10408
10415
  );
10409
10416
  }
10410
10417
  const totalExposed = blast.reachable.length + blast.envFindings.length;
@@ -10428,7 +10435,7 @@ function renderPanelScorecard(input, now = /* @__PURE__ */ new Date()) {
10428
10435
  if (impact.totalCatches === 0) continue;
10429
10436
  const discount = PROTECTIVE_SHIELD_DISCOUNTS[impact.shieldName] ?? 0;
10430
10437
  const bonus = Math.round(exposed * discount);
10431
- const icon = discount > 0 ? "\u{1F6E1} " : "\u2610 ";
10438
+ const icon = discount > 0 ? "\u{1F6E1}\uFE0F " : "\u2610 ";
10432
10439
  const wouldCatch = `would catch ${impact.totalCatches} op${impact.totalCatches !== 1 ? "s" : ""}`;
10433
10440
  const deltaSuffix = bonus > 0 ? ` \u2192 +${bonus} pts (${blast.score} \u2192 ${blast.score + bonus})` : "";
10434
10441
  shieldLines.push(
@@ -10478,7 +10485,7 @@ function originForRule(ruleName, sections) {
10478
10485
  return "";
10479
10486
  }
10480
10487
  function registerScanCommand(program2) {
10481
- program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 90 days)").option("--days <n>", "Scan last N days of history", "90").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option(
10488
+ program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 90 days)").option("--days <n>", "Scan last N days of history", "90").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option("--classic", "Original chalk-based scorecard layout (default: new Ink-rendered view)").option(
10482
10489
  "--json",
10483
10490
  "Emit machine-readable JSON to stdout (suppresses banner, progress, and renderer)"
10484
10491
  ).option(
@@ -10512,7 +10519,8 @@ function registerScanCommand(program2) {
10512
10519
  })();
10513
10520
  const isWired = getAgentsStatus().some((a) => a.wired);
10514
10521
  const screenshotMode = options.compact || options.narrative;
10515
- const quiet = screenshotMode || options.json;
10522
+ const useInk = !options.classic && !drillDown;
10523
+ const quiet = screenshotMode || options.json || useInk;
10516
10524
  if (!quiet) {
10517
10525
  console.log("");
10518
10526
  if (!isWired) {
@@ -10650,6 +10658,7 @@ function registerScanCommand(program2) {
10650
10658
  });
10651
10659
  return;
10652
10660
  }
10661
+ const useInkForHero = !options.classic && !drillDown;
10653
10662
  if (totalFindings === 0 && scan.dlpFindings.length === 0) {
10654
10663
  console.log(chalk5.green(" \u2705 No risky operations found in your history."));
10655
10664
  console.log(
@@ -10669,9 +10678,11 @@ function registerScanCommand(program2) {
10669
10678
  const since = daysAgo === 0 ? "today" : daysAgo === 1 ? "yesterday" : `${daysAgo} days ago`;
10670
10679
  return chalk5.dim(" \xB7 ") + arrow + chalk5.dim(` since ${since}`);
10671
10680
  })();
10672
- console.log(
10673
- " " + (score.band === "critical" ? chalk5.red.bold("\u26A0 ") : "") + chalk5.bold("Security Score ") + score.color.bold(`${blast.score}/100`) + " " + severityDisplay + trendSuffix + chalk5.dim(" \xB7 ") + (totalRisky > 0 ? chalk5.red.bold(`${totalRisky} risky operation${totalRisky !== 1 ? "s" : ""}`) : chalk5.green("No risky operations"))
10674
- );
10681
+ if (!useInkForHero) {
10682
+ console.log(
10683
+ " " + (score.band === "critical" ? chalk5.red.bold("\u26A0 ") : "") + chalk5.bold("Security Score ") + score.color.bold(`${blast.score}/100`) + " " + severityDisplay + trendSuffix + chalk5.dim(" \xB7 ") + (totalRisky > 0 ? chalk5.red.bold(`${totalRisky} risky operation${totalRisky !== 1 ? "s" : ""}`) : chalk5.green("No risky operations"))
10684
+ );
10685
+ }
10675
10686
  const cardParts = [];
10676
10687
  if (scan.dlpFindings.length > 0) {
10677
10688
  cardParts.push(
@@ -10700,10 +10711,10 @@ function registerScanCommand(program2) {
10700
10711
  chalk5.red("\u{1F52D} ") + chalk5.red.bold(String(blastExposures)) + chalk5.dim(" exposures")
10701
10712
  );
10702
10713
  }
10703
- if (cardParts.length > 0) {
10714
+ if (cardParts.length > 0 && !useInkForHero) {
10704
10715
  console.log(" " + cardParts.join(chalk5.dim(" ")));
10705
10716
  }
10706
- if (scan.totalCostUSD > 0) {
10717
+ if (scan.totalCostUSD > 0 && !useInkForHero) {
10707
10718
  console.log(
10708
10719
  " " + chalk5.dim("AI spend ") + chalk5.bold(fmtCost(scan.totalCostUSD)) + (summary.loopWastedUSD > 0 ? chalk5.dim(" \xB7 wasted on loops ") + chalk5.yellow("~" + fmtCost(summary.loopWastedUSD)) : "")
10709
10720
  );
@@ -10715,16 +10726,38 @@ function registerScanCommand(program2) {
10715
10726
  )
10716
10727
  );
10717
10728
  }
10718
- console.log("");
10729
+ if (!useInkForHero) {
10730
+ console.log("");
10731
+ }
10719
10732
  if (!drillDown) {
10720
- renderPanelScorecard({
10721
- scan,
10722
- summary,
10723
- blast,
10724
- blastExposures,
10725
- blockedCount,
10726
- reviewCount
10727
- });
10733
+ const useInk2 = !options.classic;
10734
+ if (useInk2) {
10735
+ const scanInkPath = path21.join(__dirname, "scan-ink.mjs");
10736
+ const dynamicImport = new Function("id", "return import(id)");
10737
+ const mod = await dynamicImport(`file://${scanInkPath}`);
10738
+ const rangeLabel2 = options.all ? "all time" : `last ${options.days ?? 90} days`;
10739
+ mod.renderScanScorecardInk(
10740
+ {
10741
+ scan,
10742
+ summary,
10743
+ blast,
10744
+ blastExposures,
10745
+ blockedCount,
10746
+ reviewCount
10747
+ },
10748
+ rangeLabel2
10749
+ );
10750
+ console.log("");
10751
+ } else {
10752
+ renderPanelScorecard({
10753
+ scan,
10754
+ summary,
10755
+ blast,
10756
+ blastExposures,
10757
+ blockedCount,
10758
+ reviewCount
10759
+ });
10760
+ }
10728
10761
  const cta = isWired ? "\u2705 node9 is active" : "\u2192 install node9 to enable protection";
10729
10762
  console.log(" " + chalk5.green(cta));
10730
10763
  console.log(
@@ -3308,6 +3308,7 @@ var init_setup = __esm({
3308
3308
 
3309
3309
  // src/cli/render/scan-derive.ts
3310
3310
  import chalk3 from "chalk";
3311
+ import stringWidth from "string-width";
3311
3312
  var init_scan_derive = __esm({
3312
3313
  "src/cli/render/scan-derive.ts"() {
3313
3314
  "use strict";
@@ -3334,6 +3335,7 @@ import chalk4 from "chalk";
3334
3335
  import fs5 from "fs";
3335
3336
  import path7 from "path";
3336
3337
  import os7 from "os";
3338
+ import stringWidth2 from "string-width";
3337
3339
  function claudeModelPrice2(model) {
3338
3340
  const base = model.replace(/@.*$/, "").replace(/-\d{8}$/, "");
3339
3341
  for (const [key, p] of Object.entries(CLAUDE_PRICING2)) {