@pharaoh-so/mcp 0.3.25 → 0.3.27

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/commands/plan.md CHANGED
@@ -39,11 +39,12 @@ Do NOT review from memory or assumptions. Query the actual codebase first.
39
39
  ### If Pharaoh MCP tools are available:
40
40
 
41
41
  1. `get_codebase_map` — current modules, hot files, dependency graph
42
- 2. `search_functions` for keywords related to the plan — find existing code to reuse/extend
43
- 3. `get_module_context` on affected modules — entry points, patterns, conventions
44
- 4. `query_dependencies` between affected modules — coupling, circular deps
45
- 5. `get_blast_radius` on the primary target of the change — know what breaks
46
- 6. `check_reachability` on the primary target — verify it's actually reachable from entry points before worrying about it
42
+ 2. `get_vision_docs` — read the repo's architectural contract. If the plan touches a domain with a MUST-bullet, the assertion is a hard constraint on the plan. Raise it explicitly in Section 1. If `get_vision_docs` returns an empty-state scaffold, flag that the repo needs `/pharaoh:vision` before planning anything architectural.
43
+ 3. `search_functions` for keywords related to the plan — find existing code to reuse/extend
44
+ 4. `get_module_context` on affected modules — entry points, patterns, conventions
45
+ 5. `query_dependencies` between affected modules — coupling, circular deps
46
+ 6. `get_blast_radius` on the primary target of the change — know what breaks
47
+ 7. `check_reachability` on the primary target — verify it's actually reachable from entry points before worrying about it
47
48
 
48
49
  ### Without Pharaoh (graceful fallback):
49
50
 
@@ -71,10 +72,17 @@ Adapt depth to change size. Skip sections that don't apply. **After each section
71
72
 
72
73
  ### Section 1 — Architecture (skip for small/single-file changes)
73
74
 
75
+ - **Vision alignment (check FIRST).** Does this plan touch a domain
76
+ governed by a MUST-bullet in `docs/VISION.md`? If yes, quote the
77
+ assertion and verify the plan doesn't violate it. If the plan would
78
+ violate a MUST, STOP and raise with the user — either the plan changes
79
+ or VISION.md needs an update first (with user approval).
74
80
  - Component boundaries and coupling concerns
75
81
  - Dependency graph: does this change shrink or expand surface area?
76
82
  - Data flow bottlenecks and single points of failure
77
83
  - Does this need new code at all, or can a human process / existing pattern solve it?
84
+ - **If this plan introduces a new invariant**, call it out and include a
85
+ step in Section 6 to add a `### assertion` to VISION.md with a pairing TEST.
78
86
 
79
87
  ### Section 2 — Code Quality (always)
80
88
 
@@ -65,7 +65,8 @@ Then call these additional tools for data recon doesn't cover:
65
65
  - `get_regression_risk` — overall change risk score for the repo
66
66
  - `get_consolidation_opportunities` — duplicate logic the PR may introduce
67
67
  - `check_reachability` — are new exports wired to entry points?
68
- - `get_vision_gaps` — do changes align with or drift from specs?
68
+ - `get_vision_docs` — read the repo's architectural contract. For every touched module, pull its relevant assertions. Any change touching code governed by a MUST-bullet must be checked against that assertion — if the change would violate the MUST, the verdict tips toward BLOCK unless VISION.md is updated in the same PR with user approval.
69
+ - `get_vision_gaps` — do changes align with or drift from specs? New complex functions without a matching assertion are flagged; retired specs with implementing code are flagged.
69
70
 
70
71
  **Output of Phase 1:** Architecture map, module profiles for every touched module, blast radius for high-risk changes, dependency paths between coupled modules, regression risk level, duplication findings, reachability status, and spec alignment.
71
72
 
@@ -249,7 +250,8 @@ If none of these triggers are met, **skip Phase 3** and proceed to Phase 4.
249
250
  - Unreachable exports (new public code with zero callers)
250
251
  - New circular dependencies between modules
251
252
  - HIGH regression risk without corresponding test coverage
252
- - Vision spec violations (building against spec intent)
253
+ - **A MUST-bullet in `docs/VISION.md` is violated by this change** (without VISION.md being updated in the same PR with user approval)
254
+ - Vision spec drift: new complex function has no matching assertion AND the domain it lives in has existing assertions (implies invariants are expected here)
253
255
  - Any CRITICAL finding from Phase 2 or Phase 3 that is confirmed and unfixed
254
256
  - DISAGREE verdict from adversarial review on security-sensitive code, confirmed after verification
255
257
  - Unanchored Cypher query (MATCH without traversing through `Repo {name: $repo}`)
@@ -0,0 +1,184 @@
1
+ ---
2
+ description: Draft a VISION.md for this repo by filling a graph-derived scaffold
3
+ ---
4
+
5
+ # Vision Drafting
6
+
7
+ Author a VISION.md that captures this repo's invariants — what must always be
8
+ true about its behavior. The file becomes a machine-readable contract that
9
+ Pharaoh's linter, PR Guard, and MCP tools reason against on every change.
10
+
11
+ ## Why this matters (explain this to the user before drafting)
12
+
13
+ A VISION.md is leverage, not paperwork. Once the repo has one:
14
+
15
+ - **PR Guard** checks every pull request against its assertions. Changes that
16
+ contradict a MUST-bullet surface as a Check on the PR — the team sees drift
17
+ from intent at the exact moment it happens, not months later.
18
+ - **`get_vision_gaps`** becomes useful. Pharaoh cross-references every spec
19
+ against the function graph and surfaces both sides of the drift: specs
20
+ without implementing code (dead intent) and complex functions with no spec
21
+ (invisible invariants).
22
+ - **`get_vision_docs`** returns spec-to-code traceability. Each assertion
23
+ shows the functions that implement it — a direct map from intent → code.
24
+ - **CI enforces it.** Every MUST bullet pairs with a TEST reference. The
25
+ linter fails when an assertion has no enforcing test. No more wishlist
26
+ specs that drift over time.
27
+
28
+ Without a VISION.md, the tools above return empty. The graph sees code; it
29
+ doesn't see intent. The file is the bridge.
30
+
31
+ ## When to Use
32
+
33
+ - The repo has no `docs/VISION.md` yet and the user wants Pharaoh's full
34
+ value — PR Guard, vision-gaps, spec traceability all depend on the file.
35
+ - `get_vision_docs` returned the "No VISION.md yet" onboarding response.
36
+ - The user said "draft a vision doc", "add a VISION.md", or "scaffold vision".
37
+ - A user is new to Pharaoh and asking what they should do next.
38
+
39
+ ## The flow — collaborative, not solo
40
+
41
+ This is a short working session with the user. Do not solo-fill the scaffold
42
+ — the graph can tell you the shape of the code, but only the user knows what
43
+ must not break. Ask questions, propose, let them steer.
44
+
45
+ 1. **Get the scaffold.** Call `get_vision_docs` with the repo name. When no
46
+ vision doc exists, the tool returns a deterministic scaffold plus the
47
+ agent directive. Use that as your starting structure. Never invent a
48
+ scaffold from scratch; the archetypes encode Pharaoh's framework shape
49
+ and drift is surfaced by the framework-linter.
50
+
51
+ 2. **Interview the user.** Before filling anything, ask: *"What are the 3-5
52
+ things about this repo that must not break, no matter what?"* Their
53
+ answer drives the assertions. If they can't articulate it, probe with
54
+ follow-ups — "what would a P0 incident look like here?", "what would
55
+ wake you up on a weekend?". The goal is to extract real invariants, not
56
+ to generate plausible-sounding ones from graph evidence.
57
+
58
+ 3. **Ground each domain in real code.** For every `## Domain` the scaffold
59
+ rendered, call `get_module_context` for the module(s) that back that
60
+ domain. For example, an "Authentication and Authorization" domain →
61
+ `get_module_context` on the auth module + any session/token modules.
62
+ Read the exported functions, endpoints, env vars, and DB access.
63
+
64
+ 4. **Replace TODOs with WHAT statements.**
65
+ - Every `### TODO:` heading becomes a real assertion title. Describe the
66
+ invariant in plain language — "Sessions expire after 30 minutes of
67
+ inactivity", not "checkSession() returns null after timeout".
68
+ - Every `- MUST: TODO —` bullet becomes a WHAT statement. Banned
69
+ content: function names, file paths, class names, identifiers.
70
+ - Add a `- TEST: path/to/test.ts` line for every assertion. If the test
71
+ doesn't exist yet, write it before committing. A MUST without a TEST
72
+ is not an invariant, it's a wish.
73
+
74
+ 5. **Trim irrelevant domains.** If a rendered `## Domain` doesn't match
75
+ anything real in this repo, delete that whole section. The scaffold is a
76
+ starting point, not a contract to honor exhaustively.
77
+
78
+ 6. **Write the file.** Save to `docs/VISION.md` in the repo. Remove the
79
+ `<!-- scaffold-generated -->` marker and the `<!-- AGENT DIRECTIVE -->`
80
+ block once every TODO is resolved.
81
+
82
+ 7. **Wire it into CLAUDE.md.** This is non-negotiable — it's what turns the
83
+ vision doc from a file on disk into a contract future sessions respect.
84
+ If `CLAUDE.md` exists at the repo root, append (or edit) a section:
85
+
86
+ ```markdown
87
+ ## Vision docs
88
+
89
+ `docs/VISION.md` is this repo's architectural contract. Before any
90
+ non-trivial change, call `get_vision_docs` to read the relevant
91
+ assertions. After any large refactor, run `get_vision_gaps` to surface
92
+ drift between intent and implementation. When adding a new invariant,
93
+ open `docs/VISION.md`, add a `### assertion` under the right `##` domain,
94
+ and pair every `- MUST:` with a `- TEST:` referencing the enforcing test.
95
+
96
+ Never introduce code that would violate a MUST-bullet without first
97
+ updating `docs/VISION.md` (with the user's approval).
98
+ ```
99
+
100
+ If no `CLAUDE.md` exists, create a minimal one with the section above
101
+ plus any project-specific instructions you already have context for.
102
+
103
+ 8. **Verify.** Run `pharaoh vision lint --framework` (or instruct the user
104
+ to). Zero errors. Zero TODO warnings. Zero unresolved placeholders.
105
+
106
+ ## Hard rules
107
+
108
+ - **No HOW in MUST bullets.** Banned: function names, file paths, camelCase
109
+ identifiers, backtick-wrapped code. The linter enforces this as a warning;
110
+ treat it as a hard rule.
111
+ - **Every MUST needs a TEST.** The format-linter errors if a MUST bullet
112
+ has no matching TEST reference to an existing file.
113
+ - **One MUST per line.** Don't pack multiple invariants into a single bullet.
114
+ - **Max 10 prose lines per assertion.** Keep each `###` section tight.
115
+ - **Mirror Pharaoh's canonical domains when they fit.** Rename only when the
116
+ user's project truly has a different shape. The framework-linter warns on
117
+ unrecognized domains so drift is visible.
118
+
119
+ ## What to avoid
120
+
121
+ - **Do NOT** write VISION.md from scratch without calling `get_vision_docs`
122
+ first — you'll miss domains the graph detected and duplicate ones it
123
+ already included.
124
+ - **Do NOT** carry forward the AGENT DIRECTIVE or scaffold-generated marker
125
+ into the committed file. Those are drafting aids, not document content.
126
+ - **Do NOT** write speculative assertions ("sessions will expire someday").
127
+ Only assert what the code enforces today or what you'll commit a test for.
128
+ - **Do NOT** use `pharaoh vision init` — that only drops the format guide,
129
+ not a graph-grounded scaffold. Always go through `get_vision_docs`.
130
+
131
+ ## Completion checklist
132
+
133
+ - [ ] User was interviewed for their real invariants (not graph-guessed)
134
+ - [ ] Every rendered `## Domain` either has ≥1 real assertion or was deleted
135
+ - [ ] Every `### TODO:` heading replaced with a real title
136
+ - [ ] Every `- MUST: TODO —` replaced with a WHAT statement
137
+ - [ ] Every assertion has a `- TEST:` line pointing at an existing test file
138
+ - [ ] Scaffold marker + AGENT DIRECTIVE block removed
139
+ - [ ] `CLAUDE.md` updated (or created) with the Vision docs section
140
+ - [ ] `pharaoh vision lint --framework` exits clean
141
+
142
+ ## After it ships — tell the user what they unlocked
143
+
144
+ Once `docs/VISION.md` is committed, call out the new capabilities concretely:
145
+
146
+ 1. **Every future AI session reads it.** Because CLAUDE.md now references
147
+ `docs/VISION.md`, any agent working in this repo (Claude Code, Cursor,
148
+ anyone else connecting Pharaoh) will consult the file before touching
149
+ architecture. Continuity across sessions, across people, across tools.
150
+ 2. **`get_vision_docs`** now returns spec-to-code traceability per
151
+ assertion. The user can see which functions implement each invariant.
152
+ 3. **`get_vision_gaps`** surfaces drift. Unspecified complex functions
153
+ get flagged; specs with no implementing code get flagged for review
154
+ (either write the code or retire the spec).
155
+ 4. **Their next PR is checked.** PR Guard now evaluates touched code
156
+ against relevant assertions. First-time use often catches real drift.
157
+ 5. **`/pharaoh:plan`** will surface relevant assertions during reconnaissance.
158
+ 6. **`/pharaoh:review`** will flag PRs that touch code governed by a MUST.
159
+
160
+ The bigger payoff: architectural invariants are now enforced in CI the same
161
+ way unit tests enforce behavior. Intent and implementation stop drifting.
162
+
163
+ ## Ongoing maintenance — it's a living file
164
+
165
+ VISION.md rots if it's not maintained. Part of your job in future sessions
166
+ (even ones that aren't about vision directly) is to keep it current:
167
+
168
+ - **When a MUST changes**, update VISION.md in the same PR as the code
169
+ change. Don't let the file lag — stale assertions are worse than none.
170
+ - **When a new architectural decision lands**, add a new `###` assertion
171
+ under the right `##` domain with a pairing TEST. One assertion per
172
+ decision, not batches.
173
+ - **When an assertion stops being load-bearing**, delete it. Retirement is
174
+ as important as addition.
175
+ - **When `get_vision_gaps` shows >5 unimplemented specs**, the doc has
176
+ drifted into wishlist territory. Prune.
177
+ - **Quarterly**: run `pharaoh vision lint --framework` and address warnings.
178
+
179
+ Format reference: https://pharaoh.so/vision-format.md
180
+ ---
181
+
182
+ Here is any additional context for the vision draft:
183
+
184
+ $ARGUMENTS
package/dist/auth.d.ts CHANGED
@@ -85,6 +85,12 @@ export declare function pollForToken(baseUrl: string, deviceCode: string, interv
85
85
  * to pre-fill the activation URL in the user's browser. Silent-on-failure is
86
86
  * critical for headless environments: a missing `open`/`xdg-open` binary must
87
87
  * not crash the proxy.
88
+ *
89
+ * Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
90
+ * proxy as a child process during E2E testing. The openBrowser dependency-
91
+ * injection path in gateway.ts is sufficient for in-process tests, but child-
92
+ * process tests bypass module mocking — the env-var short-circuit catches
93
+ * every spawn path (gateway, printActivationPrompt, direct index.ts calls).
88
94
  */
89
95
  export declare function openBrowser(url: string): void;
90
96
  /**
package/dist/auth.js CHANGED
@@ -112,8 +112,16 @@ export async function pollForToken(baseUrl, deviceCode, interval) {
112
112
  * to pre-fill the activation URL in the user's browser. Silent-on-failure is
113
113
  * critical for headless environments: a missing `open`/`xdg-open` binary must
114
114
  * not crash the proxy.
115
+ *
116
+ * Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
117
+ * proxy as a child process during E2E testing. The openBrowser dependency-
118
+ * injection path in gateway.ts is sufficient for in-process tests, but child-
119
+ * process tests bypass module mocking — the env-var short-circuit catches
120
+ * every spawn path (gateway, printActivationPrompt, direct index.ts calls).
115
121
  */
116
122
  export function openBrowser(url) {
123
+ if (process.env.PHARAOH_MCP_DISABLE_BROWSER === "1")
124
+ return;
117
125
  if (process.platform === "darwin") {
118
126
  execFile("open", [url], () => { });
119
127
  }
package/dist/backend.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface Backend {
26
26
  start?(): Promise<void>;
27
27
  /** Optional teardown (e.g. close SSE). Idempotent. */
28
28
  stop?(): Promise<void>;
29
+ /**
30
+ * Optional long-running promise that completes on clean exit or rejects with
31
+ * a typed error (TokenExpiredError, TenantSuspendedError) the main loop can
32
+ * act on. Backends without a supervisor loop (AuthRequiredBackend) omit this.
33
+ */
34
+ waitForExit?(): Promise<void>;
29
35
  }
30
36
  /**
31
37
  * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
@@ -41,13 +47,16 @@ export declare const AUTH_REQUIRED_TOOLS: Tool[];
41
47
  /**
42
48
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
43
49
  * gateway; returns a structured auth-required response. Once the gateway
44
- * reports the user has authorized (handleToolCall returns null), the caller
45
- * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
50
+ * reports the user has authorized (handleToolCall returns null), this backend
51
+ * invokes the onAuthorized callback so the caller (index.ts main loop) can
52
+ * swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
53
+ * swap — callbacks keep the ownership of the proxy reference in one place.
46
54
  */
47
55
  export declare class AuthRequiredBackend implements Backend {
48
56
  private readonly gateway;
57
+ private readonly onAuthorized?;
49
58
  readonly name: "auth-required";
50
- constructor(gateway: DeviceFlowGateway);
59
+ constructor(gateway: DeviceFlowGateway, onAuthorized?: (() => Promise<void>) | undefined);
51
60
  listTools(): Promise<Tool[]>;
52
61
  callTool(_name: string, _args: Record<string, unknown>): Promise<CallToolResult>;
53
62
  }
package/dist/backend.js CHANGED
@@ -48,14 +48,18 @@ export const AUTH_REQUIRED_TOOLS = [
48
48
  /**
49
49
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
50
50
  * gateway; returns a structured auth-required response. Once the gateway
51
- * reports the user has authorized (handleToolCall returns null), the caller
52
- * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
51
+ * reports the user has authorized (handleToolCall returns null), this backend
52
+ * invokes the onAuthorized callback so the caller (index.ts main loop) can
53
+ * swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
54
+ * swap — callbacks keep the ownership of the proxy reference in one place.
53
55
  */
54
56
  export class AuthRequiredBackend {
55
57
  gateway;
58
+ onAuthorized;
56
59
  name = "auth-required";
57
- constructor(gateway) {
60
+ constructor(gateway, onAuthorized) {
58
61
  this.gateway = gateway;
62
+ this.onAuthorized = onAuthorized;
59
63
  }
60
64
  async listTools() {
61
65
  return AUTH_REQUIRED_TOOLS;
@@ -63,9 +67,41 @@ export class AuthRequiredBackend {
63
67
  async callTool(_name, _args) {
64
68
  const response = await this.gateway.handleToolCall();
65
69
  if (response === null) {
66
- // Credentials just became valid. Return a success result so the
67
- // agent's current tool call doesn't error out. ProxyServer checks
68
- // gateway.hasValidCredentials() after each call and swaps backends.
70
+ // Credentials just became valid. Fire the authorization callback
71
+ // BEFORE returning success so the caller's backend swap is in place
72
+ // by the time the next tool call arrives.
73
+ if (this.onAuthorized) {
74
+ try {
75
+ await this.onAuthorized();
76
+ }
77
+ catch (err) {
78
+ // TenantSuspendedError from the post-auth SSE handshake means
79
+ // the tenant was suspended between authorize and swap. Rethrow
80
+ // so the supervisor exits cleanly instead of looping in
81
+ // auth-required mode forever.
82
+ if (err instanceof TenantSuspendedError)
83
+ throw err;
84
+ // Any other swap failure (SSE start rejected, gateway returned
85
+ // no creds, network blip) must surface as an error to the
86
+ // agent. Returning success would leave the proxy in a zombie
87
+ // state — this backend still active, onAuthorized already
88
+ // fired, every subsequent tool call burning a fresh device
89
+ // flow cycle. isError:true lets the agent see the failure and
90
+ // retry the tool call, which will re-enter the gateway's post-
91
+ // auth branch and attempt the swap again.
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ process.stderr.write(`Pharaoh: onAuthorized callback failed — ${msg}\n`);
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: `Pharaoh: authenticated but backend swap failed — ${msg}. Retry the tool call.`,
99
+ },
100
+ ],
101
+ isError: true,
102
+ };
103
+ }
104
+ }
69
105
  return {
70
106
  content: [
71
107
  {
@@ -207,6 +243,16 @@ export class SseProxyBackend {
207
243
  if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
208
244
  throw err;
209
245
  }
246
+ // connectOnce() inside reconnectAfter() can throw an SseError with
247
+ // code === 401 / 403 when the stored token is rejected on
248
+ // reconnect (the common mid-session token-expiry shape). Run it
249
+ // through classifySSEError — that helper throws typed errors,
250
+ // which propagate out of the current catch via the enclosing
251
+ // catch's implicit re-throw. The session supervisor sees the
252
+ // typed error and can swap backends instead of treating it as a
253
+ // transient retry.
254
+ if (err instanceof Error)
255
+ classifySSEError(err);
210
256
  // Do NOT increment attempt here — the try block already incremented
211
257
  // it before calling reconnectAfter. A second bump would halve the
212
258
  // effective retry budget so only ~3 of the 5 BACKOFF_MS slots
package/dist/index.js CHANGED
@@ -8,14 +8,19 @@
8
8
  *
9
9
  * Two modes determined by whether stdin is a TTY:
10
10
  * - Interactive (TTY): authenticate, print setup instructions, exit.
11
- * - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
11
+ * - Proxy (pipe): bridge stdio ↔ SSE. No valid creds? Run in auth-required
12
+ * mode until device flow completes, then swap to SSE in
13
+ * place — the stdio session never disconnects.
12
14
  */
13
15
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
14
- import { deleteCredentials, writeCredentials } from "./credentials.js";
16
+ import { AuthRequiredBackend, createSseBackend } from "./backend.js";
17
+ import { deleteCredentials, readCredentials, writeCredentials } from "./credentials.js";
18
+ import { createGateway } from "./gateway.js";
15
19
  import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
16
20
  import { runInstallSkills } from "./install-skills.js";
17
- import { exitForReauth, handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
18
- import { startProxy, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
21
+ import { handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
22
+ import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
23
+ import { startInAuthRequiredMode, startInSseMode } from "./proxy-server.js";
19
24
  import { loadAndRefresh } from "./refresh.js";
20
25
  const BANNER = [
21
26
  "",
@@ -87,70 +92,177 @@ async function main() {
87
92
  process.exit(0);
88
93
  }
89
94
  // ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
90
- // Load credentials; refresh silently if the access token is expired but the
91
- // refresh token is still valid. Anything else → exit with guidance.
95
+ // Single flow: load creds → pick starting backend → wait for lifetime
96
+ // events → swap backends in place. Session C design note: we never exit
97
+ // on recoverable conditions (missing creds, expired-refresh, mid-session
98
+ // 401). The stdio session stays alive for the whole life of the agent;
99
+ // only non-recoverable failures (tenant suspended, transient refresh error
100
+ // on cold start) terminate the process.
92
101
  const outcome = await loadAndRefresh(server);
93
- if (outcome === null) {
94
- // No credentials file at all — first-time run or user logged out.
95
- exitForReauth("no credentials — run authentication first");
96
- }
97
- if (!outcome.ok) {
98
- handleRefreshFailure(outcome.action, outcome.message);
99
- return; // unreachable — handleRefreshFailure calls process.exit
100
- }
101
- let creds = outcome.creds;
102
- // Valid credentials — ensure skills are installed before starting proxy
103
- // Silently installs/updates on first run or package update; fast no-op if current.
102
+ // Install skills opportunistically — must not block startup. Failures
103
+ // here include network hiccups (npm registry blips) and transient fs
104
+ // issues, neither of which should prevent the proxy serving MCP traffic.
105
+ // Log the message so "silent" is still distinguishable from "never ran."
104
106
  try {
105
107
  runInstallSkills();
106
108
  }
107
- catch {
109
+ catch (err) {
108
110
  // ALLOWED-CATCH: skill install failure must not block MCP proxy startup
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ process.stderr.write(`Pharaoh: skill install skipped — ${msg}\n`);
109
113
  }
110
- printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
111
- try {
112
- await startProxy(creds.sse_url, creds.access_token);
114
+ // Needs auth: either no creds file OR refresh token is terminally rejected.
115
+ // Both cases: start in auth-required mode, wait for the user to complete
116
+ // device flow, then swap to SSE via the onAuthorized callback.
117
+ const needsAuth = outcome === null || (!outcome.ok && outcome.action === "reauth");
118
+ if (needsAuth) {
119
+ // If we reached here via a rejected refresh, delete the stale creds
120
+ // file so the gateway's internal readCredentials() doesn't pick up the
121
+ // just-invalidated refresh token on restart.
122
+ if (outcome && !outcome.ok)
123
+ deleteCredentials();
124
+ // The callback closure captures `proxy` via the destructured `const`
125
+ // binding declared below. The closure is only invoked once authorization
126
+ // completes (after the user's device-flow round trip), long after the
127
+ // assignment — so TS's strict-binding checks accept the reference.
128
+ const { proxy } = await startInAuthRequiredMode(server, {
129
+ onAuthorized: async (creds) => {
130
+ printLines("Pharaoh: authenticated — swapping to SSE backend.");
131
+ await proxy.setBackend(createSseBackend(creds.sse_url, creds.access_token));
132
+ },
133
+ });
134
+ printLines("Pharaoh: awaiting authentication via Claude Code...");
135
+ await waitForProxyLifetime(proxy, server);
136
+ return;
113
137
  }
114
- catch (err) {
115
- if (err instanceof TokenExpiredError) {
116
- // Mid-session token expired — attempt one silent refresh + retry
117
- // before giving up. This is what makes the proxy self-heal across
118
- // long sessions rather than dying at arbitrary moments.
119
- const retryCreds = await tryMidSessionRefresh(creds, server);
120
- if (retryCreds) {
121
- creds = retryCreds;
122
- printLines("Pharaoh: token refreshed mid-session — reconnecting");
123
- try {
124
- await startProxy(creds.sse_url, creds.access_token);
125
- return;
138
+ // Non-recoverable refresh failure (transient / preserve). Don't delete
139
+ // creds; the next spawn will retry. Match the pre-Session-C behavior for
140
+ // these codepaths exactly — if we can't refresh on cold start, we can't
141
+ // serve any tool calls, so bailing is honest.
142
+ if (!outcome.ok) {
143
+ handleRefreshFailure(outcome.action, outcome.message);
144
+ return; // unreachable — handleRefreshFailure calls process.exit
145
+ }
146
+ // Happy path: valid credentials. Start SSE backend, wait for lifetime,
147
+ // handle any mid-session failures by swapping backends in place.
148
+ const creds = outcome.creds;
149
+ printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
150
+ const proxy = await startInSseMode(creds);
151
+ await waitForProxyLifetime(proxy, server);
152
+ }
153
+ /** Circuit breaker: abort after this many TokenExpiredError recoveries in the window. */
154
+ const RECOVERY_WINDOW_MS = 60_000;
155
+ const MAX_RECOVERIES_PER_WINDOW = 3;
156
+ /**
157
+ * Block on proxy.waitForLifetime() and reconcile mid-session failures by
158
+ * swapping the backend in place. Loops so successive swaps (e.g. SSE → auth
159
+ * → SSE) all stay under the same stdio connection.
160
+ *
161
+ * Terminates (returns normally) only when:
162
+ * - stdio closes (Claude Code disconnects), or
163
+ * - a non-recoverable branch explicitly process.exit()s.
164
+ *
165
+ * Circuit breaker: a buggy or hostile upstream can force a SSE↔auth churn by
166
+ * issuing a fresh 401 immediately after each successful reauth. Cap the
167
+ * recovery rate so the proxy exits with a diagnostic instead of pinning the
168
+ * user in a loop that re-prompts device flow every few seconds.
169
+ */
170
+ async function waitForProxyLifetime(proxy, server) {
171
+ const recoveryTimestamps = [];
172
+ while (true) {
173
+ try {
174
+ await proxy.waitForLifetime();
175
+ return; // stdio closed cleanly — end of session
176
+ }
177
+ catch (err) {
178
+ if (err instanceof TokenExpiredError) {
179
+ const now = Date.now();
180
+ // Drop timestamps older than the window.
181
+ while (recoveryTimestamps.length && now - recoveryTimestamps[0] > RECOVERY_WINDOW_MS) {
182
+ recoveryTimestamps.shift();
126
183
  }
127
- catch (retryErr) {
128
- if (retryErr instanceof TokenExpiredError) {
129
- // Second 401 after a successful refresh — the fresh tokens
130
- // are already rejected, so the session is genuinely dead.
131
- // Delete creds before exit so the next spawn shows the
132
- // re-auth prompt instead of looping on the just-rejected
133
- // access token (which `isExpired` would still say is fresh).
134
- deleteCredentials();
135
- exitForReauth("token rejected after refresh");
136
- }
137
- if (retryErr instanceof TenantSuspendedError) {
138
- printLines(`Pharaoh: ${retryErr.message}`);
139
- process.exit(1);
140
- }
141
- throw retryErr;
184
+ recoveryTimestamps.push(now);
185
+ if (recoveryTimestamps.length > MAX_RECOVERIES_PER_WINDOW) {
186
+ printLines(`Pharaoh: ${recoveryTimestamps.length} token-expired recoveries in ${RECOVERY_WINDOW_MS / 1000}s — aborting to avoid an auth loop. Restart to retry.`);
187
+ process.exit(1);
142
188
  }
189
+ const recovered = await recoverFromTokenExpired(proxy, server);
190
+ if (recovered)
191
+ continue; // swapped to a new backend; keep waiting
192
+ return; // recovery exited / handed off to auth-required loop
143
193
  }
144
- // Refresh failed — exit message already printed by tryMidSessionRefresh
145
- process.exit(1);
146
- }
147
- if (err instanceof TenantSuspendedError) {
148
- printLines(`Pharaoh: ${err.message}`);
149
- process.exit(1);
194
+ if (err instanceof TenantSuspendedError) {
195
+ printLines(`Pharaoh: ${err.message}`);
196
+ process.exit(1);
197
+ }
198
+ throw err;
150
199
  }
151
- throw err;
152
200
  }
153
201
  }
202
+ /**
203
+ * Handle a mid-session 401 without tearing down the stdio session:
204
+ * 1. Attempt a silent refresh. If it works, swap to a fresh SSE backend.
205
+ * 2. If the refresh says reauth, swap to AuthRequiredBackend — the next
206
+ * tool call surfaces the auth URL. The onAuthorized callback swaps us
207
+ * back to SSE once the user completes device flow.
208
+ * 3. If the refresh is transient/preserve, the session can't recover here
209
+ * — exit with the classified message.
210
+ *
211
+ * Returns `true` when a backend swap happened and the caller should keep
212
+ * waiting on waitForLifetime(); returns `false` when the caller's loop should
213
+ * exit (because this function either handed off to an async auth-required
214
+ * swap-back or process.exit()'d for a non-recoverable case).
215
+ */
216
+ async function recoverFromTokenExpired(proxy, server) {
217
+ // Re-read creds from disk — the SSE backend doesn't expose its token, and
218
+ // a prior refresh may have rotated what's on disk. Silent-refresh the
219
+ // token regardless of the on-disk expiry (the 401 is authoritative: the
220
+ // server rejected us, so force a round trip).
221
+ const diskCreds = readCredentials();
222
+ if (!diskCreds) {
223
+ // No creds on disk — shouldn't happen mid-session, but if it does,
224
+ // fall through to auth-required mode to get new ones.
225
+ await swapToAuthRequired(proxy, server);
226
+ return true;
227
+ }
228
+ const result = await tryMidSessionRefresh(diskCreds, server);
229
+ if (result.ok) {
230
+ printLines("Pharaoh: token refreshed mid-session — swapping SSE backend");
231
+ await proxy.setBackend(createSseBackend(result.creds.sse_url, result.creds.access_token));
232
+ return true;
233
+ }
234
+ // Branch on the explicit classification returned by tryMidSessionRefresh
235
+ // rather than inferring from disk state. `reauth` means the refresh token
236
+ // is terminally rejected — swap to auth-required so the user can re-
237
+ // authenticate without the session tearing down. `preserve`/`transient`
238
+ // mean the server's temporarily unable to refresh us; exit with the
239
+ // message so the user knows their credentials are intact.
240
+ if (result.action === "reauth") {
241
+ await swapToAuthRequired(proxy, server);
242
+ return true;
243
+ }
244
+ printLines(`Pharaoh: mid-session refresh failed (${result.action}) — credentials preserved; restart to retry.`);
245
+ process.exit(1);
246
+ }
247
+ /**
248
+ * Swap the live ProxyServer over to an AuthRequiredBackend, wiring an
249
+ * onAuthorized callback that swaps back to SSE. Used on both mid-session
250
+ * reauth and first-missing-creds recovery.
251
+ */
252
+ async function swapToAuthRequired(proxy, server) {
253
+ const gateway = createGateway({ server });
254
+ const authBackend = new AuthRequiredBackend(gateway, async () => {
255
+ const fresh = gateway.getCredentials();
256
+ if (!fresh) {
257
+ process.stderr.write("Pharaoh: swap-back aborted — gateway returned no creds\n");
258
+ return;
259
+ }
260
+ printLines("Pharaoh: re-authenticated — swapping back to SSE backend.");
261
+ await proxy.setBackend(createSseBackend(fresh.sse_url, fresh.access_token));
262
+ });
263
+ printLines("Pharaoh: re-authentication required — next tool call will surface the URL.");
264
+ await proxy.setBackend(authBackend);
265
+ }
154
266
  main().catch((err) => {
155
267
  // Default: print only error name/code to avoid leaking tokens, internal URLs,
156
268
  // or stack fragments that persist in CI logs. Full message behind PHARAOH_DEBUG.