@pharaoh-so/mcp 0.3.52 → 0.3.53

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/.mcp.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "pharaoh": {
4
4
  "command": "npx",
5
5
  "args": [
6
- "@pharaoh-so/mcp@0.3.52"
6
+ "@pharaoh-so/mcp@0.3.53"
7
7
  ]
8
8
  }
9
9
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to `@pharaoh-so/mcp` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.53] - 2026-10-23
9
+
10
+ ### Changed
11
+ - The Trace card is a compact receipt: the finding with its counts, a verdict such as risk, and one bar showing where the results sit (callers by module, dead or possibly unused, matches by module, code by module). The evidence opens on Details. A single-tool card is about 80px tall instead of 290 to 2,200px.
12
+ - While a tool runs, the card says what Pharaoh is doing ("Tracing callers of parseEvent…"). Caveats such as an incomplete graph or a stale map stay visible while the evidence is closed.
13
+
14
+ ### Fixed
15
+ - With no `claude` CLI on PATH, `npx @pharaoh-so/mcp` stops before sign-in and says why: setup registers Pharaoh through that CLI, and the Claude desktop app's built-in Claude Code isn't on PATH. The message gives the CLI installer. It used to link the desktop app download, write the slash commands anyway and end with "Pharaoh is ready".
16
+ - When `claude mcp add` fails, setup ends without "Pharaoh is ready" and names the `claude mcp add` command to run by hand.
17
+
8
18
  ## [0.3.52] - 2026-10-23
9
19
 
10
20
  ### Added
package/dist/index.js CHANGED
@@ -80,6 +80,13 @@ async function main() {
80
80
  // Running `npx @pharaoh-so/mcp` is the only command a user needs.
81
81
  if (isInteractive) {
82
82
  process.stderr.write(`${BANNER}\n`);
83
+ // Setup registers everything through the `claude` CLI on PATH. Without it,
84
+ // stop before sign-in: nothing is written and saved credentials stay.
85
+ const { claudeCliMissing, hasClaude, runSetup } = await import("./setup.js");
86
+ if (!hasClaude()) {
87
+ printLines(...claudeCliMissing());
88
+ process.exit(1);
89
+ }
83
90
  // Always re-authenticate for a fresh session
84
91
  deleteCredentials();
85
92
  const deviceCode = await requestDeviceCode(server);
@@ -92,10 +99,11 @@ async function main() {
92
99
  const newCreds = tokenToCredentials(token, sseUrl);
93
100
  writeCredentials(newCreds);
94
101
  printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
95
- // Register MCP server + install skills (silent — user doesn't need internals)
102
+ // Register MCP server + install skills (silent — user doesn't need internals).
103
+ // runSetup has printed the failure and the command to run by hand; no success line.
104
+ if (!runSetup({ silent: true }))
105
+ process.exit(1);
96
106
  process.stderr.write(" Setting up...");
97
- const { runSetup } = await import("./setup.js");
98
- runSetup({ silent: true });
99
107
  const { keptCommands } = runInstallSkills(undefined, { silent: true });
100
108
  process.stderr.write(" done.\n");
101
109
  // Two things the user must hear about: their settings.json (the only
@@ -11,7 +11,7 @@ import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { ensurePharaohHome } from "./credentials.js";
14
- import { claudeCliFor } from "./setup.js";
14
+ import { CLAUDE_CLI_SETUP_URL, claudeCliFor } from "./setup.js";
15
15
  import { getPackageVersion, PACKAGE_NAME, pinnedPackageSpec } from "./version.js";
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  /** Path to the bundled package root (one level up from dist/). */
@@ -766,7 +766,7 @@ export function runInstallSkills(home = homedir(), options) {
766
766
  "Pharaoh: no supported skill platform detected.",
767
767
  "",
768
768
  "Supported platforms:",
769
- " • Claude Code — install from https://claude.ai/download",
769
+ ` • Claude Code — install the CLI from ${CLAUDE_CLI_SETUP_URL}`,
770
770
  " • Codex — install from https://developers.openai.com/codex",
771
771
  " • OpenClaw — install from https://openclaw.dev/install",
772
772
  "",
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  /** Runs one `claude` CLI command; true on success. */
2
2
  export type ClaudeCli = (args: string[]) => boolean;
3
+ /** Anthropic's Claude Code CLI install page: every platform and install method. */
4
+ export declare const CLAUDE_CLI_SETUP_URL = "https://code.claude.com/docs/en/setup";
5
+ /**
6
+ * Whether a `claude` CLI on PATH starts. It is launched the way `runClaude`
7
+ * launches it, so the check and the commands agree, with no `which` (minimal
8
+ * containers lack it). The Claude desktop app's bundled copy isn't on PATH.
9
+ *
10
+ * @returns True when `claude --version` runs.
11
+ */
12
+ export declare function hasClaude(): boolean;
13
+ /**
14
+ * What setup prints when no `claude` CLI is on PATH: npx registers everything
15
+ * through that CLI, the desktop app's copy doesn't count, and the CLI shares
16
+ * ~/.claude with the desktop app, so installing it covers the Code tab too.
17
+ *
18
+ * @param platform - Picks the one-line installer (PowerShell on Windows).
19
+ * @returns The lines to print.
20
+ */
21
+ export declare function claudeCliMissing(platform?: NodeJS.Platform): string[];
3
22
  /**
4
23
  * A `claude` CLI runner for the Claude Code config under `home`: true when the
5
24
  * command succeeded, false when it failed or `claude` isn't installed. Another
package/dist/setup.js CHANGED
@@ -11,20 +11,54 @@ import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { NPX_COMMAND, printLines } from "./helpers.js";
13
13
  import { pinnedPackageSpec } from "./version.js";
14
- /** Check if `claude` CLI is available in PATH. */
15
- function hasClaude() {
14
+ /** Anthropic's Claude Code CLI install page: every platform and install method. */
15
+ export const CLAUDE_CLI_SETUP_URL = "https://code.claude.com/docs/en/setup";
16
+ /** Pharaoh's setup guide for agents other than Claude Code (stdio server, sign-in on first call). */
17
+ const HEADLESS_SETUP_URL = "https://pharaoh.so/docs/connect/headless";
18
+ /**
19
+ * Whether a `claude` CLI on PATH starts. It is launched the way `runClaude`
20
+ * launches it, so the check and the commands agree, with no `which` (minimal
21
+ * containers lack it). The Claude desktop app's bundled copy isn't on PATH.
22
+ *
23
+ * @returns True when `claude --version` runs.
24
+ */
25
+ export function hasClaude() {
16
26
  try {
17
- const which = process.platform === "win32" ? "where" : "which";
18
- execFileSync(which, ["claude"], {
19
- encoding: "utf-8",
20
- stdio: ["pipe", "pipe", "pipe"],
21
- });
27
+ execFileSync("claude", ["--version"], { stdio: "pipe", timeout: 10_000 });
22
28
  return true;
23
29
  }
24
30
  catch {
31
+ // A `claude` that can't be started is one setup can't use either.
25
32
  return false;
26
33
  }
27
34
  }
35
+ /**
36
+ * What setup prints when no `claude` CLI is on PATH: npx registers everything
37
+ * through that CLI, the desktop app's copy doesn't count, and the CLI shares
38
+ * ~/.claude with the desktop app, so installing it covers the Code tab too.
39
+ *
40
+ * @param platform - Picks the one-line installer (PowerShell on Windows).
41
+ * @returns The lines to print.
42
+ */
43
+ export function claudeCliMissing(platform = process.platform) {
44
+ // The shell goes in the label: anything after the command gets pasted with it.
45
+ const [shell, installer] = platform === "win32"
46
+ ? [" (PowerShell)", "irm https://claude.ai/install.ps1 | iex"]
47
+ : ["", "curl -fsSL https://claude.ai/install.sh | bash"];
48
+ return [
49
+ "Pharaoh: the claude command isn't on your PATH, so setup stopped.",
50
+ "",
51
+ "npx adds Pharaoh to Claude Code through the Claude Code CLI. The desktop app runs its own copy of Claude Code, and that copy isn't on your PATH.",
52
+ "",
53
+ ` Install the CLI${shell}: ${installer}`,
54
+ ` Other ways: ${CLAUDE_CLI_SETUP_URL}`,
55
+ ` Then, in a new terminal: ${NPX_COMMAND}`,
56
+ "",
57
+ "The CLI and the desktop app share ~/.claude, so the desktop app's Code tab gets Pharaoh too.",
58
+ `Not using Claude Code? OpenClaw: ${NPX_COMMAND} --install-skills. Other agents: ${HEADLESS_SETUP_URL}`,
59
+ "",
60
+ ];
61
+ }
28
62
  /** Run a claude CLI command, returning true on success. Surfaces stderr on failure. */
29
63
  function runClaude(args, env = process.env) {
30
64
  try {
@@ -128,7 +162,7 @@ export function runSetup(options) {
128
162
  const silent = options?.silent ?? false;
129
163
  const home = options?.home ?? homedir();
130
164
  if (!options?.claude && !hasClaude()) {
131
- printLines("Pharaoh: Claude Code CLI not found.", "", "Install Claude Code first: https://claude.ai/download", `Then re-run: ${NPX_COMMAND}`, "");
165
+ printLines(...claudeCliMissing());
132
166
  return false;
133
167
  }
134
168
  const claude = options?.claude ?? claudeCliFor(home);
@@ -142,9 +176,9 @@ export function runSetup(options) {
142
176
  claude(["mcp", "remove", "pharaoh", "-s", "project"]);
143
177
  // Step 3: Register as global stdio MCP server, pinned to the running version
144
178
  const spec = pinnedPackageSpec();
145
- const added = claude(["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec]);
146
- if (!added) {
147
- printLines("Pharaoh: failed to register MCP server.", `Try manually: ${NPX_COMMAND}`, "");
179
+ const addArgs = ["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec];
180
+ if (!claude(addArgs)) {
181
+ printLines("Pharaoh: failed to register MCP server.", `Try manually: claude ${addArgs.join(" ")}`, "");
148
182
  return false;
149
183
  }
150
184
  if (!silent)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.52",
4
+ "version": "0.3.53",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
package/trace-app.html CHANGED
@@ -6,60 +6,117 @@
6
6
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; font-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
7
7
  <title>Pharaoh Trace</title>
8
8
  <style>
9
- :root { color-scheme:dark; --bg:#171813; --text:#e5e6d8; --muted:#aaa995; --gold:#d4ad51; --yellow:#edc54c; --rule:#414236; --hover:#22251c; --error:#e3a18d; font:400 12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace; background:var(--bg); color:var(--text); }
10
- :root[data-theme="light"] { color-scheme:light; --bg:#faf8f1; --text:#292b20; --muted:#656553; --gold:#866315; --yellow:#926700; --rule:#d8d4c5; --hover:#efede2; --error:#a13929; }
9
+ :root { color-scheme:dark; --stone:#2a2520; --rule:#3a352e; --track:#342e28; --hover:#332d27; --gold:#d4a843; --papyrus:#e8dcc8; --text:#c4b99a; --muted:#9d927b; --red:#f87171; --amber:#f0c855; --green:#4ade80;
10
+ --s0:#d4a843; --s1:#b08a3a; --s2:#8c6e31; --s3:#6e5728; --s4:#574522; --s-muted:#5f574a; --s-warning:#f0c855; --s-error:#ef4444;
11
+ --sans:"DM Sans",system-ui,-apple-system,"Segoe UI",sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; }
12
+ :root[data-theme="light"] { color-scheme:light; --stone:#f7f3ea; --rule:#e2d8c4; --track:#ece4d3; --hover:#efe8da; --gold:#946c0f; --papyrus:#2a2520; --text:#4a4235; --muted:#71675a; --red:#b42318; --amber:#9a5b00; --green:#15803d;
13
+ --s0:#b08515; --s1:#c9a24a; --s2:#d9bb72; --s3:#e6cf98; --s4:#efdfb8; --s-muted:#cfc4ad; --s-warning:#e0a526; --s-error:#d93f2f; }
11
14
  * { box-sizing:border-box; }
12
- body { margin:0; min-width:0; }
13
- #trace { border-left:2px solid var(--gold); padding:9px 14px 8px; min-width:0; }
14
- header { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:baseline; gap:3px 14px; color:var(--muted); font-size:10px; }
15
- #tool { overflow-wrap:anywhere; min-width:0; }
16
- #status { white-space:nowrap; }
17
- #status[data-state="ok"] { color:#4ade80; }
18
- #status[data-state="pending"],.filled { color:var(--yellow); }
19
- #status[data-state="error"] { color:var(--error); }
20
- #trace[data-pending="true"] #status { animation:pending 1.4s ease-in-out infinite alternate; }
21
- @keyframes pending { from { opacity:.55; } to { opacity:1; } }
22
- #repo { font-size:14px; margin:4px 0 0; overflow-wrap:anywhere; }
23
- #provenance { color:var(--muted); font-size:10px; overflow-wrap:anywhere; margin-top:2px; }
24
- h1 { font-size:14px; line-height:1.5; font-weight:500; margin:6px 0 0; overflow-wrap:anywhere; }
25
- #summary { margin:2px 0 0; color:var(--muted); overflow-wrap:anywhere; }
26
- ul { list-style:none; padding:0; margin:4px 0 0; min-width:0; }
27
- li { min-width:0; }
28
- summary,.row { display:grid; grid-template-columns:18px minmax(0,1fr) auto; gap:6px; align-items:baseline; padding:2px 0; min-width:0; }
29
- summary { list-style:none; min-height:24px; border-bottom:1px solid var(--rule); cursor:pointer; }
30
- summary::-webkit-details-marker { display:none; }
31
- summary:hover { background:var(--hover); }
32
- summary:focus-visible { outline:2px solid var(--gold); outline-offset:2px; }
33
- .branch { color:var(--gold); white-space:pre; }
34
- details>summary .branch::after { content:"+"; }
35
- details[open]>summary .branch::after { content:"−"; }
36
- .label,.detail,.badge { overflow-wrap:anywhere; white-space:pre-wrap; min-width:0; unicode-bidi:plaintext; }
37
- details[open]>summary .label { color:var(--gold); }
38
- .detail { display:block; color:var(--muted); font-size:10px; margin-top:1px; }
39
- .badge { color:var(--muted); font-size:10px; text-align:right; }
40
- .badge[data-state="ok"] { color:#4ade80; }
41
- .badge[data-state="warning"],.badge[data-state="pending"] { color:var(--yellow); }
42
- .badge[data-state="error"] { color:var(--error); }
43
- .children { margin:0 0 3px 17px; border-left:1px solid var(--rule); padding-left:9px; }
44
- .meter { display:block; color:var(--muted); font-size:11px; margin:2px 0; overflow-wrap:anywhere; }
45
- #notes { border-top:1px solid var(--rule); margin-top:6px; padding-top:5px; color:var(--muted); font-size:10px; }
46
- #notes p { margin:2px 0; overflow-wrap:anywhere; white-space:pre-wrap; }
47
- #fallback { font:inherit; font-size:11px; margin:6px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; unicode-bidi:plaintext; }
15
+ html,body { margin:0; background:transparent; min-width:0; }
16
+ .label,.detail,.badge { overflow-wrap:anywhere; min-width:0; unicode-bidi:plaintext; }
17
+ .mark-s { font:600 12px var(--mono); width:1ch; }
18
+ #trace { background:var(--stone); color:var(--text); border:1px solid var(--rule); border-radius:6px; padding:9px 12px; font:13px/1.45 var(--sans); min-width:0; }
19
+ .head { display:flex; flex-wrap:wrap; align-items:baseline; gap:2px 10px; }
20
+ .mark { color:var(--gold); font-size:14px; line-height:1; }
21
+ .headline { flex:1 1 16em; margin:0; min-width:0; overflow-wrap:anywhere; }
22
+ #kind { color:var(--muted); }
23
+ #target { color:var(--papyrus); font:600 13px/1.45 var(--mono); }
24
+ #target,.legend > span,.rows .label { unicode-bidi:isolate; }
25
+ #title { color:var(--papyrus); font-weight:600; }
26
+ #summary { margin-left:8px; }
27
+ .num { color:var(--gold); font:600 12.5px var(--mono); }
28
+ .meta { margin-left:auto; display:flex; gap:10px; white-space:nowrap; font-size:12px; }
29
+ #verdict[data-state="error"] { color:var(--red); } #verdict[data-state="warning"] { color:var(--amber); } #verdict[data-state="ok"] { color:var(--green); }
30
+ #sha { color:var(--muted); font:11.5px var(--mono); }
31
+ .bar { position:relative; display:flex; gap:2px; height:6px; margin:8px 0 0; border-radius:3px; overflow:hidden; background:var(--track); }
32
+ .bar i,.mini i { display:block; flex:0 1 0; min-width:0; }
33
+ .bar i[data-shown] { min-width:3px; }
34
+ .s0 { background:var(--s0); } .s1 { background:var(--s1); } .s2 { background:var(--s2); } .s3 { background:var(--s3); } .s4 { background:var(--s4); }
35
+ .muted { background:var(--s-muted); } .warning { background:var(--s-warning); } .error { background:var(--s-error); }
36
+ .foot { display:flex; align-items:baseline; gap:4px 14px; margin-top:6px; flex-wrap:wrap; }
37
+ .legend { display:flex; flex-wrap:wrap; gap:2px 14px; margin:0; color:var(--muted); font-size:12px; min-width:0; flex:1 1 auto; }
38
+ #trace[data-reveal] .legend { opacity:0; }
39
+ .legend b { color:var(--text); font:500 11.5px var(--mono); margin-left:4px; }
40
+ .sw { display:inline-block; width:8px; height:8px; border-radius:1px; margin-right:5px; }
41
+ .toggle { margin-left:auto; display:flex; align-items:baseline; gap:4px; border:0; background:none; padding:2px 0; color:var(--gold); font:12px var(--sans); cursor:pointer; white-space:nowrap; }
42
+ .toggle:hover { color:var(--papyrus); }
43
+ .toggle:focus-visible,summary:focus-visible { outline:2px solid var(--gold); outline-offset:2px; border-radius:2px; }
44
+ .chev { display:inline-block; font-size:10px; }
45
+ .toggle[aria-expanded="true"] .chev { transform:rotate(90deg); }
46
+ .caveat { margin:6px 0 0; color:var(--amber); font-size:12px; overflow-wrap:anywhere; }
47
+ .caveat span + span::before { content:" · "; color:var(--muted); }
48
+ .rows { list-style:none; margin:4px 0 0; padding:0; }
49
+ .rows li { display:flex; flex-wrap:wrap; align-items:baseline; gap:0 8px; padding:4px 0 5px; border-top:1px solid var(--rule); min-width:0; }
50
+ .rows li:first-child { border-top:0; }
51
+ .rows .kind { color:var(--muted); font-size:12.5px; }
52
+ .rows .label { color:var(--papyrus); font:12.5px var(--mono); }
53
+ .rows .prose { color:var(--text); font:12.5px var(--sans); }
54
+ .rows .detail { color:var(--text); font-size:12.5px; }
55
+ .rows .end { margin-left:auto; font-size:12px; white-space:nowrap; }
56
+ .rows .more { color:var(--muted); font-size:12px; }
57
+ .rows .mini { display:flex; gap:1px; flex:1 0 100%; height:3px; margin-top:3px; border-radius:2px; overflow:hidden; background:var(--track); }
58
+ [data-state="ok"] > .mark-s { color:var(--green); } [data-state="warning"] > .mark-s, [data-state="pending"] > .mark-s { color:var(--amber); } [data-state="error"] > .mark-s, .end[data-state="error"] { color:var(--red); } .end[data-state="warning"] { color:var(--amber); }
59
+ .drawer { display:grid; grid-template-rows:0fr; }
60
+ .drawer[data-open] { grid-template-rows:1fr; }
61
+ .drawer-inner { overflow:hidden; min-height:0; }
62
+ .drawer[data-open] .drawer-inner { border-top:1px solid var(--rule); margin-top:8px; padding-top:4px; }
63
+ ul.tree,.tree ul { list-style:none; margin:0; padding:0; }
64
+ .tree ul { margin:0 0 4px 6px; padding-left:10px; border-left:1px solid var(--rule); }
65
+ .tree summary,.leaf { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:baseline; padding:3px 0; font-size:12.5px; min-width:0; }
66
+ .tree summary { list-style:none; cursor:pointer; color:var(--text); }
67
+ .tree summary::-webkit-details-marker { display:none; }
68
+ .tree summary:hover { background:var(--hover); }
69
+ .tree summary .label::before { content:"▸"; display:inline-block; color:var(--gold); font-size:10px; width:12px; }
70
+ .tree details[open] > summary .label::before { transform:rotate(90deg); }
71
+ .tree details[open] > summary .label { color:var(--gold); }
72
+ .leaf .label { color:var(--papyrus); font:12px var(--mono); }
73
+ .tree > li > .leaf { padding-left:12px; }
74
+ .tree > li > .leaf .label { color:var(--text); font:12.5px var(--sans); }
75
+ .tree .label .detail { color:var(--muted); font:11px var(--mono); margin-left:8px; }
76
+ .tree .label .caveat { color:var(--amber); font:11.5px var(--sans); margin:0 0 0 8px; }
77
+ .badge { color:var(--muted); font:11.5px var(--mono); text-align:right; white-space:nowrap; }
78
+ .badge[data-state="ok"] { color:var(--green); } .badge[data-state="warning"],.badge[data-state="pending"] { color:var(--amber); } .badge[data-state="error"] { color:var(--red); }
79
+ .meter { display:block; height:3px; margin-top:3px; border-radius:2px; background:var(--track); max-width:240px; }
80
+ .meter i { display:block; height:100%; border-radius:2px; background:var(--gold); }
81
+ .notes,.provenance { color:var(--muted); font-size:11.5px; margin:6px 0 2px; }
82
+ .notes p { margin:2px 0; overflow-wrap:anywhere; white-space:pre-wrap; }
83
+ #fallback { font:11.5px/1.5 var(--mono); color:var(--text); margin:6px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; unicode-bidi:plaintext; }
84
+ @media(max-width:400px) { #trace { padding:8px 10px; } #summary { margin-left:0; } #summary:not([hidden]) { display:block; } .tree summary,.leaf { grid-template-columns:minmax(0,1fr); gap:0; } .badge { text-align:left; } }
85
+ /* Motion only for people who have not asked to reduce it. */
86
+ @media(prefers-reduced-motion:no-preference) {
87
+ .bar i,.mini i { transition:flex-grow .7s cubic-bezier(.22,.8,.24,1); }
88
+ .legend { transition:opacity .4s ease .2s; }
89
+ .chev { transition:transform .3s ease; }
90
+ .drawer { transition:grid-template-rows .3s ease; }
91
+ .tree summary .label::before { transition:transform .2s ease; }
92
+ #trace[data-state="pending"] .bar::after,#trace[data-state="waiting"] .bar::after { content:""; position:absolute; top:0; bottom:0; left:0; width:35%; background:linear-gradient(90deg,transparent,var(--gold),transparent); opacity:.55; animation:sweep 1.3s ease-in-out infinite; }
93
+ @keyframes sweep { from { transform:translateX(-100%); } to { transform:translateX(290%); } }
94
+ }
48
95
  [hidden] { display:none; }
49
- @media(max-width:400px) { #trace { padding:8px 10px 7px; } summary,.row { grid-template-columns:15px minmax(0,1fr); gap:4px; } .badge { grid-column:2; text-align:left; } .children { margin-left:9px; padding-left:7px; } }
50
- @media(prefers-reduced-motion:reduce) { #trace[data-pending="true"] #status { animation:none; } }
51
96
  </style>
52
97
  </head>
53
98
  <body>
54
- <main id="trace" data-pending="false">
55
- <header><span id="tool">Pharaoh result</span><span id="status" role="status" aria-live="polite" aria-atomic="true">waiting</span></header>
56
- <div id="repo" hidden></div>
57
- <div id="provenance" hidden></div>
58
- <h1 id="title">Waiting for a tool result</h1>
59
- <p id="summary" hidden></p>
60
- <ul id="tree" aria-label="Result evidence" hidden></ul>
61
- <pre id="fallback">The tool's text response remains available in chat.</pre>
62
- <div id="notes" hidden></div>
99
+ <main id="trace" data-state="waiting">
100
+ <div class="head">
101
+ <span class="mark" aria-hidden="true">☥</span>
102
+ <p class="headline"><span id="kind"></span><span id="target"></span><span id="title">Pharaoh</span><span id="summary" role="status" aria-live="polite">Waiting for the tool</span></p>
103
+ <span id="meta" class="meta"><span id="verdict" hidden></span><span id="sha" hidden></span></span>
104
+ </div>
105
+ <div id="bar" class="bar" aria-hidden="true"></div>
106
+ <div id="foot" class="foot">
107
+ <p id="legend" class="legend"></p>
108
+ <button id="toggle" class="toggle" type="button" aria-expanded="false" aria-controls="drawer" hidden><span id="toggle-label">Details</span><span class="chev" aria-hidden="true">▸</span></button>
109
+ </div>
110
+ <p id="caveat" class="caveat" hidden></p>
111
+ <ul id="rows" class="rows" hidden></ul>
112
+ <div id="drawer" class="drawer" inert>
113
+ <div class="drawer-inner">
114
+ <ul id="tree" class="tree" aria-label="Result evidence"></ul>
115
+ <pre id="fallback" hidden></pre>
116
+ <div id="notes" class="notes" hidden></div>
117
+ <p id="provenance" class="provenance" hidden></p>
118
+ </div>
119
+ </div>
63
120
  </main>
64
121
  <script>
65
122
  (() => {
@@ -67,11 +124,18 @@
67
124
  const META_KEY = "so.pharaoh/trace";
68
125
  const PROTOCOL_VERSION = "2026-01-26";
69
126
  // Kept in parity with TRACE_LIMITS in trace-contract.ts by the resource test.
70
- const LIMITS = { bytes:65536, nodes:120, depth:6, label:512, detail:2048, badge:128, summary:1024, notes:16, note:1024, repo:256 };
127
+ const LIMITS = { bytes:65536, nodes:120, depth:6, label:512, detail:2048, badge:128, summary:1024, notes:16, note:1024, repo:256, segments:8 };
71
128
  const MAX_TEXT = 32768;
129
+ const MAX_ROWS = 5;
130
+ const TONES = ["warning","error","muted"];
131
+ const STATES = ["ok","pending","warning","error"];
132
+ // What each tool is doing while it runs, shown with the argument it targets.
133
+ const VERBS = { get_blast_radius:"Tracing callers of", get_unused_code:"Checking reachability in", search_functions:"Searching for", get_codebase_map:"Mapping", get_module_context:"Reading module", pharaoh_recon:"Running recon on", pharaoh_account:"Checking account" };
72
134
  const root = document.getElementById("trace");
73
- const fields = Object.fromEntries(["tool","status","repo","provenance","title","summary","tree","fallback","notes"].map(id => [id,document.getElementById(id)]));
135
+ const fields = Object.fromEntries(["kind","target","title","summary","meta","verdict","sha","bar","caveat","rows","foot","legend","toggle","toggle-label","drawer","tree","fallback","notes","provenance"].map(id => [id,document.getElementById(id)]));
136
+ const compact = new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1});
74
137
  const initId = "pharaoh-trace-initialize";
138
+ let tool = "";
75
139
  let ready = false;
76
140
  let closed = false;
77
141
  let cancelled = false;
@@ -81,37 +145,76 @@
81
145
  const record = value => value !== null && typeof value === "object" && !Array.isArray(value);
82
146
  const string = (value,max) => typeof value === "string" && value.length <= max;
83
147
  const send = message => { if (!closed) window.parent.postMessage({jsonrpc:"2.0",...message},"*"); };
84
- function status(label,state) {
85
- fields.status.textContent = label;
86
- fields.status.dataset.state = state;
87
- root.dataset.pending = String(state === "pending");
148
+ function element(tag,className,text) {
149
+ const node = document.createElement(tag);
150
+ if (className) node.className = className;
151
+ if (text !== undefined) node.textContent = text;
152
+ return node;
153
+ }
154
+ // Counts read at a glance: digit runs become gold monospace spans, everything else stays text.
155
+ function rich(target,text) {
156
+ target.replaceChildren();
157
+ for (const part of String(text || "").split(/(\b\d[\d,.]*\b)/)) {
158
+ if (part) target.append(/^\d/.test(part) ? element("span","num",part) : part);
159
+ }
88
160
  }
89
161
  function display(field,text) {
90
162
  fields[field].textContent = text || "";
91
163
  fields[field].hidden = !text;
92
164
  }
165
+ // "Impact: attachTraceView" reads as a muted kind and a monospace target.
166
+ const splitTitle = title => /^([^:]{1,40}): (.+)$/.exec(title);
167
+ function headline(title,summary) {
168
+ const split = splitTitle(title);
169
+ display("kind",split ? `${split[1]} ` : "");
170
+ display("target",split ? split[2] : "");
171
+ display("title",split ? "" : title);
172
+ rich(fields.summary,summary);
173
+ fields.summary.hidden = !summary;
174
+ }
175
+ // The toggle ends the legend line, or the headline when there is no legend.
176
+ function place(withLegend) {
177
+ (withLegend ? fields.foot : fields.meta).append(fields.toggle);
178
+ fields.foot.hidden = !withLegend;
179
+ }
180
+ function setOpen(open) {
181
+ fields.drawer.toggleAttribute("data-open",open);
182
+ fields.drawer.toggleAttribute("inert",!open);
183
+ fields.toggle.setAttribute("aria-expanded",String(open));
184
+ fields["toggle-label"].textContent = open ? "Hide" : "Details";
185
+ }
186
+ function clearResult() {
187
+ setOpen(false);
188
+ for (const field of ["bar","rows","legend","tree","notes"]) fields[field].replaceChildren();
189
+ for (const field of ["verdict","sha","caveat","provenance","fallback"]) display(field,"");
190
+ delete fields.verdict.dataset.state;
191
+ fields.sha.title = "";
192
+ fields.bar.hidden = true;
193
+ fields.rows.hidden = true;
194
+ fields.notes.hidden = true;
195
+ fields.toggle.hidden = true;
196
+ }
93
197
  function notes(items) {
94
- fields.notes.replaceChildren();
95
- for (const text of items) {
96
- const paragraph = document.createElement("p");
97
- paragraph.textContent = text;
98
- fields.notes.append(paragraph);
99
- }
198
+ for (const text of items) fields.notes.append(element("p","",text));
100
199
  fields.notes.hidden = items.length === 0;
101
200
  }
102
- function clearResult() {
103
- fields.tree.replaceChildren();
104
- fields.tree.hidden = true;
105
- display("summary","");
106
- display("provenance","");
107
- notes([]);
201
+ function caveats(items) {
202
+ fields.caveat.replaceChildren(...items.map(text => element("span","",text)));
203
+ fields.caveat.hidden = items.length === 0;
108
204
  }
109
205
  function fallback(title,text,notice,state="neutral") {
110
206
  clearResult();
111
- fields.title.textContent = title;
207
+ root.dataset.state = state === "error" ? "error" : "ready";
208
+ const first = text.split("\n").find(line => line.trim()) || "";
209
+ headline(title,first.length > 160 ? `${first.slice(0,159)}…` : first);
112
210
  display("fallback",text);
113
- status(state === "error" ? "[!] error" : state === "cancelled" ? "[||] cancelled" : "response received",state);
211
+ if (state === "error" || state === "cancelled") {
212
+ fields.verdict.dataset.state = state === "error" ? "error" : "warning";
213
+ display("verdict",state === "error" ? "Failed" : "Cancelled");
214
+ }
114
215
  if (notice) notes([notice]);
216
+ fields.toggle.hidden = !text;
217
+ place(false);
115
218
  }
116
219
  function readableText(result) {
117
220
  if (!record(result) || !Array.isArray(result.content)) return {text:"No readable text was returned. Read the tool response in chat.",omitted:false};
@@ -129,12 +232,20 @@
129
232
  }
130
233
  return {text:text || "No readable text was returned. Read the tool response in chat.",omitted};
131
234
  }
235
+ function validGlance(glance) {
236
+ if (glance === undefined) return true;
237
+ if (!record(glance)) return false;
238
+ if (glance.segments !== undefined && (!Array.isArray(glance.segments) || glance.segments.length > LIMITS.segments || !glance.segments.every(s => record(s) && string(s.label,LIMITS.badge) && Number.isFinite(s.value) && s.value >= 0 && (s.tone === undefined || TONES.includes(s.tone))))) return false;
239
+ if (glance.verdict !== undefined && (!record(glance.verdict) || !string(glance.verdict.label,LIMITS.badge) || (glance.verdict.status !== undefined && !["ok","warning","error"].includes(glance.verdict.status)))) return false;
240
+ return glance.caveat === undefined || string(glance.caveat,LIMITS.note);
241
+ }
132
242
  function validView(view) {
133
243
  try {
134
244
  if (!record(view) || view.version !== 1 || !string(view.title,LIMITS.label) || !view.title.trim() || !Array.isArray(view.nodes)) return false;
135
245
  if (new TextEncoder().encode(JSON.stringify(view)).byteLength > LIMITS.bytes) return false;
136
246
  if (view.repo !== undefined && !string(view.repo,LIMITS.repo)) return false;
137
247
  if (view.summary !== undefined && !string(view.summary,LIMITS.summary)) return false;
248
+ if (!validGlance(view.glance)) return false;
138
249
  if (view.notes !== undefined && (!Array.isArray(view.notes) || view.notes.length > LIMITS.notes || !view.notes.every(note => string(note,LIMITS.note)))) return false;
139
250
  if (view.freshness !== undefined) {
140
251
  const value = view.freshness;
@@ -145,71 +256,145 @@
145
256
  if (++count > LIMITS.nodes || depth > LIMITS.depth || !record(node) || !string(node.label,LIMITS.label) || !node.label.trim()) return false;
146
257
  if (node.detail !== undefined && !string(node.detail,LIMITS.detail)) return false;
147
258
  if (node.badge !== undefined && !string(node.badge,LIMITS.badge)) return false;
148
- if (node.status !== undefined && !["ok","pending","warning","error"].includes(node.status)) return false;
259
+ if (node.status !== undefined && !STATES.includes(node.status)) return false;
149
260
  if (node.meter !== undefined && (!record(node.meter) || !Number.isFinite(node.meter.value) || !Number.isFinite(node.meter.total) || node.meter.total <= 0 || node.meter.value < 0 || node.meter.value > node.meter.total)) return false;
261
+ if (!validGlance(node.glance)) return false;
150
262
  return node.children === undefined || (Array.isArray(node.children) && node.children.every(child => visit(child,depth + 1)));
151
263
  };
152
264
  return view.nodes.every(node => visit(node,1));
153
265
  } catch { return false; }
154
266
  }
155
- function element(tag,className,text) {
156
- const node = document.createElement(tag);
157
- if (className) node.className = className;
158
- if (text !== undefined) node.textContent = text;
159
- return node;
267
+ // Segments start empty and grow to their share once painted: the one motion on arrival.
268
+ function segments(target,items) {
269
+ const total = items.reduce((sum,item) => sum + item.value,0);
270
+ if (!total) return [];
271
+ let shade = 0;
272
+ return items.filter(item => item.value > 0).map((item,index) => {
273
+ const slice = element("i",item.tone || `s${Math.min(shade++,4)}`);
274
+ slice.style.transitionDelay = `${index * 70}ms`;
275
+ slice.dataset.share = String(item.value / total);
276
+ target.append(slice);
277
+ return slice;
278
+ });
279
+ }
280
+ function grow(slices) {
281
+ requestAnimationFrame(() => requestAnimationFrame(() => {
282
+ for (const slice of slices) { slice.style.flexGrow = slice.dataset.share; slice.dataset.shown = ""; }
283
+ delete root.dataset.reveal;
284
+ }));
285
+ }
286
+ function legend(items) {
287
+ let shade = 0;
288
+ for (const item of items.filter(item => item.value > 0)) {
289
+ const entry = element("span");
290
+ entry.append(element("span",`sw ${item.tone || `s${Math.min(shade++,4)}`}`),item.label,element("b","",compact.format(item.value)));
291
+ fields.legend.append(entry);
292
+ }
160
293
  }
161
- function renderNode(node,last,open=false) {
294
+ function verdict(target,value) {
295
+ if (!value) return;
296
+ target.textContent = value.label;
297
+ if (value.status) target.dataset.state = value.status;
298
+ target.hidden = false;
299
+ }
300
+ // Sections (recon) and status lines (states, named checks) stay visible; groups open on demand.
301
+ function rowFor(node,slices) {
302
+ const item = element("li");
303
+ if (node.status) {
304
+ item.dataset.state = node.status;
305
+ item.append(element("span","mark-s",{ok:"✓",pending:"…",warning:"!",error:"×"}[node.status]));
306
+ }
307
+ const split = splitTitle(node.label);
308
+ if (split) item.append(element("span","kind",split[1]));
309
+ // Names read in monospace; sentences (state messages) read as prose.
310
+ item.append(element("span",split || !/\s/.test(node.label) ? "label" : "label prose",split ? split[2] : node.label));
311
+ if (node.detail) { const detail = element("span","detail"); rich(detail,node.detail); item.append(detail); }
312
+ const end = element("span","end",node.badge || "");
313
+ end.hidden = !node.badge;
314
+ if (node.glance?.verdict) { end.hidden = true; verdict(end,node.glance.verdict); }
315
+ item.append(end);
316
+ if (node.glance?.caveat) item.append(element("span","detail caveat",node.glance.caveat));
317
+ if (node.glance?.segments?.length) {
318
+ const mini = element("span","mini");
319
+ mini.setAttribute("aria-hidden","true");
320
+ slices.push(...segments(mini,node.glance.segments));
321
+ item.append(mini);
322
+ }
323
+ return item;
324
+ }
325
+ function renderNode(node) {
162
326
  const item = element("li");
163
327
  const group = Array.isArray(node.children) && node.children.length > 0;
164
- const container = group ? element("details") : item;
165
- if (group && open) container.open = true;
166
- const row = element(group ? "summary" : "div",group ? "" : "row");
167
- const branch = element("span","branch",group ? "" : last ? "└─" : "├─");
168
- branch.setAttribute("aria-hidden","true");
328
+ const row = element(group ? "summary" : "div",group ? "" : "leaf");
169
329
  const label = element("span","label",node.label);
170
330
  if (node.detail) label.append(element("span","detail",node.detail));
171
- const symbol = {ok:"[ok]",pending:"[...]",warning:"[!]",error:"[!]"}[node.status] || "";
172
- const badge = element("span","badge",[symbol,node.badge].filter(Boolean).join(" "));
173
- if (node.status) badge.dataset.state = node.status;
174
- row.append(branch,label,badge);
175
- container.append(row);
176
331
  if (node.meter) {
177
- const filled = Math.round(node.meter.value / node.meter.total * 12);
178
332
  const meter = element("span","meter");
179
- meter.append("[",element("span","filled","#".repeat(filled)),`${".".repeat(12-filled)}] ${node.meter.value} / ${node.meter.total}`);
333
+ meter.title = `${node.meter.value} / ${node.meter.total}`;
334
+ const fill = element("i");
335
+ fill.style.width = `${Math.max(1,Math.round(node.meter.value / node.meter.total * 100))}%`;
336
+ meter.append(fill);
180
337
  label.append(meter);
181
338
  }
182
- if (group) {
183
- const children = element("ul","children");
184
- node.children.forEach((child,index) => { children.append(renderNode(child,index === node.children.length-1)); });
185
- container.append(children);
186
- item.append(container);
187
- }
339
+ if (node.glance?.caveat) label.append(element("span","caveat",node.glance.caveat));
340
+ const symbol = {ok:"✓",pending:"…",warning:"!",error:"×"}[node.status] || "";
341
+ const badge = element("span","badge",[symbol,node.badge || node.glance?.verdict?.label].filter(Boolean).join(" "));
342
+ const state = node.status || node.glance?.verdict?.status;
343
+ if (state) badge.dataset.state = state;
344
+ row.append(label,badge);
345
+ if (!group) { item.append(row); return item; }
346
+ const details = element("details");
347
+ const children = element("ul");
348
+ for (const child of node.children) children.append(renderNode(child));
349
+ details.append(row,children);
350
+ item.append(details);
188
351
  return item;
189
352
  }
190
353
  function renderView(view) {
191
354
  clearResult();
192
- fields.title.textContent = view.title;
193
- display("repo",view.repo);
194
- display("summary",view.summary);
195
- display("fallback","");
196
- if (view.freshness) {
197
- const freshness = view.freshness;
198
- display("provenance",[freshness.mappedCommit ? `mapped ${freshness.mappedCommit}` : "mapped commit unknown",freshness.lastAnalyzed ? `as of ${freshness.lastAnalyzed}` : "map time unknown",`status: ${freshness.status}`].join(" · "));
355
+ cancelled = false;
356
+ root.dataset.state = "ready";
357
+ root.dataset.reveal = "";
358
+ const glance = view.glance || {};
359
+ headline(view.title,view.summary);
360
+ verdict(fields.verdict,glance.verdict);
361
+ const freshness = view.freshness;
362
+ if (freshness?.mappedCommit) {
363
+ display("sha",`@${/^[0-9a-f]{7,}$/i.test(freshness.mappedCommit) ? freshness.mappedCommit.slice(0,7) : freshness.mappedCommit.slice(0,12)}`);
364
+ fields.sha.title = `Mapped ${freshness.mappedCommit}${freshness.lastAnalyzed ? ` as of ${freshness.lastAnalyzed}` : ""}`;
199
365
  }
200
- const firstGroup = view.nodes.findIndex(node => node.children?.length);
201
- view.nodes.forEach((node,index) => { fields.tree.append(renderNode(node,index === view.nodes.length-1,index === firstGroup)); });
202
- fields.tree.hidden = view.nodes.length === 0;
366
+ if (freshness) display("provenance",[freshness.mappedCommit ? `Mapped ${freshness.mappedCommit}` : "Mapped commit unknown",freshness.lastAnalyzed ? `as of ${freshness.lastAnalyzed}` : "map time unknown",`status: ${freshness.status}`].join(" · "));
367
+ caveats([glance.caveat,freshness?.status === "stale" ? "Map is behind the latest push" : ""].filter(Boolean));
368
+ const slices = segments(fields.bar,glance.segments || []);
369
+ fields.bar.hidden = slices.length === 0;
370
+ legend(glance.segments || []);
371
+ // Up to MAX_ROWS rows, warnings and verdicts first, shown in their original order.
372
+ const candidates = view.nodes.filter(node => node.glance || node.status);
373
+ const urgent = node => node.status === "error" || node.status === "warning" || node.glance?.verdict || node.glance?.caveat;
374
+ const chosen = new Set([...new Set([...candidates.filter(urgent),...candidates])].slice(0,MAX_ROWS));
375
+ const visible = candidates.filter(node => chosen.has(node));
376
+ for (const node of visible) fields.rows.append(rowFor(node,slices));
377
+ if (candidates.length > visible.length) fields.rows.append(element("li","more",`+${candidates.length - visible.length} more in Details`));
378
+ fields.rows.hidden = visible.length === 0;
379
+ for (const node of view.nodes) if (!visible.includes(node) || node.children?.length) fields.tree.append(renderNode(node));
380
+ // One click on Details shows evidence: the first group is already open inside the drawer.
381
+ fields.tree.querySelector("details")?.setAttribute("open","");
203
382
  notes(view.notes || []);
204
- status("[ok] result","ok");
383
+ fields.toggle.hidden = !fields.tree.childElementCount && fields.notes.hidden && fields.provenance.hidden;
384
+ place(fields.legend.childElementCount > 0);
385
+ grow(slices);
205
386
  }
206
387
  function input(params) {
207
388
  cancelled = false;
208
389
  clearResult();
209
- display("fallback","Waiting for the tool response.");
210
- fields.title.textContent = "Request in progress";
211
- display("repo",record(params.arguments) && string(params.arguments.repo,LIMITS.repo) ? params.arguments.repo : "");
212
- status("[>] working","pending");
390
+ root.dataset.state = "pending";
391
+ const args = record(params.arguments) ? params.arguments : {};
392
+ const target = ["entity","query","module","repo"].map(key => args[key]).find(value => string(value,LIMITS.repo) && value.trim());
393
+ const verb = Object.hasOwn(VERBS,tool) ? VERBS[tool] : "Querying the Pharaoh graph";
394
+ headline("","");
395
+ display("kind",target ? `${verb} ` : `${verb}…`);
396
+ display("target",target ? `${target}…` : "");
397
+ fields.bar.hidden = false;
213
398
  }
214
399
  function result(params) {
215
400
  if (cancelled) return;
@@ -226,7 +411,7 @@
226
411
  function context(params) {
227
412
  if (params.theme === "dark" || params.theme === "light") document.documentElement.dataset.theme = params.theme;
228
413
  const name = params.toolInfo?.tool?.name;
229
- if (string(name,128)) fields.tool.textContent = name;
414
+ if (string(name,128)) tool = name;
230
415
  }
231
416
  function size() {
232
417
  if (!ready || closed || resizeFrame) return;
@@ -244,7 +429,9 @@
244
429
  const observer = typeof ResizeObserver === "function" ? new ResizeObserver(size) : null;
245
430
  observer?.observe(root);
246
431
  root.addEventListener("toggle",size,true);
432
+ root.addEventListener("transitionend",size);
247
433
  window.addEventListener("resize",size);
434
+ fields.toggle.addEventListener("click",() => { setOpen(fields.toggle.getAttribute("aria-expanded") !== "true"); size(); });
248
435
  function receive(event) {
249
436
  if (closed || event.source !== window.parent || !record(event.data) || event.data.jsonrpc !== "2.0") return;
250
437
  const message = event.data;
@@ -271,7 +458,7 @@
271
458
  clearTimeout(initTimeout);
272
459
  cancelAnimationFrame(resizeFrame);
273
460
  observer?.disconnect();
274
- root.dataset.pending = "false";
461
+ root.dataset.state = "ready";
275
462
  window.removeEventListener("message",receive);
276
463
  } else send({id:message.id,error:{code:-32601,message:"Unsupported request"}});
277
464
  return;
@@ -289,7 +476,7 @@
289
476
  const initTimeout = setTimeout(() => {
290
477
  if (!ready && !closed) fallback("Text response available","Read the tool response in chat.","Trace has not received a host connection.");
291
478
  },10000);
292
- send({id:initId,method:"ui/initialize",params:{appInfo:{name:"Pharaoh Trace",version:"1.1.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:PROTOCOL_VERSION}});
479
+ send({id:initId,method:"ui/initialize",params:{appInfo:{name:"Pharaoh Trace",version:"1.2.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:PROTOCOL_VERSION}});
293
480
  })();
294
481
  </script>
295
482
  </body>