@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 +13 -5
- package/commands/review.md +4 -2
- package/commands/vision.md +184 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +8 -0
- package/dist/backend.d.ts +12 -3
- package/dist/backend.js +52 -6
- package/dist/index.js +167 -55
- package/dist/lifecycle.d.ts +13 -3
- package/dist/lifecycle.js +6 -5
- package/dist/proxy-server.d.ts +39 -7
- package/dist/proxy-server.js +90 -12
- package/dist/proxy.d.ts +13 -20
- package/dist/proxy.js +25 -42
- package/inspect-tools.json +27 -1
- package/package.json +1 -1
- package/skills/pharaoh/SKILL.md +12 -1
- package/skills/plan/SKILL.md +13 -5
- package/skills/review/SKILL.md +4 -2
- package/skills/vision/SKILL.md +185 -0
package/dist/lifecycle.d.ts
CHANGED
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type Credentials } from "./credentials.js";
|
|
9
9
|
import { type RefreshFailureAction } from "./refresh.js";
|
|
10
|
+
/** Discriminated result from tryMidSessionRefresh. */
|
|
11
|
+
export type MidSessionRefreshResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
creds: Credentials;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
action: RefreshFailureAction;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
10
19
|
/**
|
|
11
20
|
* Attempt a silent refresh triggered by a mid-session TokenExpiredError.
|
|
12
|
-
* Returns new credentials on success,
|
|
13
|
-
*
|
|
21
|
+
* Returns new credentials on success, or a tagged failure carrying the
|
|
22
|
+
* RefreshFailureAction so the caller can branch on the real classification
|
|
23
|
+
* instead of re-inferring from disk state.
|
|
14
24
|
*/
|
|
15
|
-
export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<
|
|
25
|
+
export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<MidSessionRefreshResult>;
|
|
16
26
|
/**
|
|
17
27
|
* Handle a failed `loadAndRefresh` at startup. Exits the process with the
|
|
18
28
|
* appropriate message and creds-file state per the classified action.
|
package/dist/lifecycle.js
CHANGED
|
@@ -10,14 +10,15 @@ import { NPX_COMMAND, printLines } from "./helpers.js";
|
|
|
10
10
|
import { refreshIfNeeded } from "./refresh.js";
|
|
11
11
|
/**
|
|
12
12
|
* Attempt a silent refresh triggered by a mid-session TokenExpiredError.
|
|
13
|
-
* Returns new credentials on success,
|
|
14
|
-
*
|
|
13
|
+
* Returns new credentials on success, or a tagged failure carrying the
|
|
14
|
+
* RefreshFailureAction so the caller can branch on the real classification
|
|
15
|
+
* instead of re-inferring from disk state.
|
|
15
16
|
*/
|
|
16
17
|
export async function tryMidSessionRefresh(creds, server) {
|
|
17
18
|
const outcome = await refreshIfNeeded(creds, server);
|
|
18
19
|
if (outcome.ok)
|
|
19
|
-
return outcome.creds;
|
|
20
|
-
// Log the failure reason before caller
|
|
20
|
+
return { ok: true, creds: outcome.creds };
|
|
21
|
+
// Log the failure reason before the caller decides what to do.
|
|
21
22
|
if (outcome.action === "reauth") {
|
|
22
23
|
printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
|
|
23
24
|
deleteCredentials();
|
|
@@ -27,7 +28,7 @@ export async function tryMidSessionRefresh(creds, server) {
|
|
|
27
28
|
// the user knows their credentials file is intact (matches startup path).
|
|
28
29
|
printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
|
|
29
30
|
}
|
|
30
|
-
return
|
|
31
|
+
return { ok: false, action: outcome.action, message: outcome.message };
|
|
31
32
|
}
|
|
32
33
|
/**
|
|
33
34
|
* Handle a failed `loadAndRefresh` at startup. Exits the process with the
|
package/dist/proxy-server.d.ts
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
|
|
10
10
|
* Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The caller supplies an `onAuthorized` callback that fires from inside the
|
|
12
|
+
* backend the moment device flow completes — the callback swaps ProxyServer
|
|
13
|
+
* over to an SseProxyBackend in place.
|
|
13
14
|
*
|
|
14
15
|
* - startInSseMode: happy path — valid credentials are already on disk. Starts
|
|
15
16
|
* the server directly with an SSE backend.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle:
|
|
19
|
+
* - `waitForLifetime()` is the single awaitable the main loop blocks on. It
|
|
20
|
+
* resolves when Claude Code disconnects (stdio transport closed) and rejects
|
|
21
|
+
* with typed errors when the active backend hits a terminal failure. Backend
|
|
22
|
+
* swaps happen internally via callbacks; the main loop does NOT re-await.
|
|
16
23
|
*/
|
|
17
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
25
|
import { type Backend } from "./backend.js";
|
|
@@ -27,6 +34,8 @@ export declare class ProxyServer {
|
|
|
27
34
|
private transport;
|
|
28
35
|
private backend;
|
|
29
36
|
private started;
|
|
37
|
+
private transportClosed;
|
|
38
|
+
private transportClosedResolve;
|
|
30
39
|
constructor(initialBackend: Backend);
|
|
31
40
|
/**
|
|
32
41
|
* Wire up list/call handlers. Each handler reads this.backend fresh at
|
|
@@ -45,6 +54,26 @@ export declare class ProxyServer {
|
|
|
45
54
|
* Kept separate from start() so the stdio wiring stays implicit in prod.
|
|
46
55
|
*/
|
|
47
56
|
startWithTransport(transport: StdioServerTransport): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Block until either (a) the stdio transport closes cleanly (Claude Code
|
|
59
|
+
* disconnected) or (b) the current backend's supervisor promise rejects
|
|
60
|
+
* with a terminal error. Returns when the transport closes. Throws when
|
|
61
|
+
* a backend's waitForExit rejects.
|
|
62
|
+
*
|
|
63
|
+
* Swap-aware: if setBackend() is called while waitForLifetime is blocked,
|
|
64
|
+
* the old backend's exit promise is abandoned and the new backend's is
|
|
65
|
+
* awaited. This matters for "SSE → auth-required → SSE2" flows where we
|
|
66
|
+
* want errors from SSE2 to bubble up, not the already-dead SSE1.
|
|
67
|
+
*/
|
|
68
|
+
waitForLifetime(): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Used by setBackend to wake waitForLifetime so it can re-sample. Default
|
|
71
|
+
* no-op so a swap fired before waitForLifetime's first iteration (e.g. an
|
|
72
|
+
* onAuthorized that completes between proxy.start() and the first
|
|
73
|
+
* waitForLifetime call) doesn't crash. The missed wake is harmless — the
|
|
74
|
+
* next waitForLifetime iteration samples this.backend directly.
|
|
75
|
+
*/
|
|
76
|
+
private backendSwappedSignal;
|
|
48
77
|
/**
|
|
49
78
|
* Swap the backend reference and fire a tools/list_changed notification so
|
|
50
79
|
* the client re-fetches. No in-flight tracking — the SDK routes each call
|
|
@@ -59,12 +88,15 @@ export declare class ProxyServer {
|
|
|
59
88
|
* the proxy in auth-required mode so the stdio connection stays alive while
|
|
60
89
|
* the user completes the device flow.
|
|
61
90
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* real
|
|
91
|
+
* The caller supplies `opts.onAuthorized`, which fires from inside the
|
|
92
|
+
* AuthRequiredBackend the moment device flow completes. Typical implementation:
|
|
93
|
+
* swap the ProxyServer over to an SseProxyBackend so the next tool call goes
|
|
94
|
+
* to the real server. This keeps the proxy reference under the caller's
|
|
95
|
+
* control — the backend never imports ProxyServer.
|
|
66
96
|
*/
|
|
67
|
-
export declare function startInAuthRequiredMode(server: string
|
|
97
|
+
export declare function startInAuthRequiredMode(server: string, opts: {
|
|
98
|
+
onAuthorized: (creds: Credentials) => Promise<void>;
|
|
99
|
+
}): Promise<{
|
|
68
100
|
proxy: ProxyServer;
|
|
69
101
|
gateway: DeviceFlowGateway;
|
|
70
102
|
}>;
|
package/dist/proxy-server.js
CHANGED
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
|
|
10
10
|
* Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The caller supplies an `onAuthorized` callback that fires from inside the
|
|
12
|
+
* backend the moment device flow completes — the callback swaps ProxyServer
|
|
13
|
+
* over to an SseProxyBackend in place.
|
|
13
14
|
*
|
|
14
15
|
* - startInSseMode: happy path — valid credentials are already on disk. Starts
|
|
15
16
|
* the server directly with an SSE backend.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle:
|
|
19
|
+
* - `waitForLifetime()` is the single awaitable the main loop blocks on. It
|
|
20
|
+
* resolves when Claude Code disconnects (stdio transport closed) and rejects
|
|
21
|
+
* with typed errors when the active backend hits a terminal failure. Backend
|
|
22
|
+
* swaps happen internally via callbacks; the main loop does NOT re-await.
|
|
16
23
|
*/
|
|
17
24
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
25
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -29,6 +36,8 @@ export class ProxyServer {
|
|
|
29
36
|
transport = null;
|
|
30
37
|
backend;
|
|
31
38
|
started = false;
|
|
39
|
+
transportClosed;
|
|
40
|
+
transportClosedResolve;
|
|
32
41
|
constructor(initialBackend) {
|
|
33
42
|
this.backend = initialBackend;
|
|
34
43
|
this.server = new Server({ name: "pharaoh-proxy", version: getPackageVersion() }, {
|
|
@@ -36,6 +45,9 @@ export class ProxyServer {
|
|
|
36
45
|
// to throw; the SDK's assertNotificationCapability gates it.
|
|
37
46
|
capabilities: { tools: { listChanged: true } },
|
|
38
47
|
});
|
|
48
|
+
this.transportClosed = new Promise((resolve) => {
|
|
49
|
+
this.transportClosedResolve = resolve;
|
|
50
|
+
});
|
|
39
51
|
this.registerHandlers();
|
|
40
52
|
}
|
|
41
53
|
/**
|
|
@@ -64,6 +76,11 @@ export class ProxyServer {
|
|
|
64
76
|
await this.backend.start();
|
|
65
77
|
this.transport = new StdioServerTransport();
|
|
66
78
|
await this.server.connect(this.transport);
|
|
79
|
+
// Server.connect() wraps transport.onclose; use the Protocol-level hook
|
|
80
|
+
// instead so we don't clobber the SDK's chain. Fires when the transport
|
|
81
|
+
// closes cleanly (client disconnect) — not on error paths, which surface
|
|
82
|
+
// through backend.waitForExit() and the server's own onerror plumbing.
|
|
83
|
+
this.server.onclose = () => this.transportClosedResolve();
|
|
67
84
|
this.started = true;
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
@@ -78,8 +95,44 @@ export class ProxyServer {
|
|
|
78
95
|
this.transport = transport;
|
|
79
96
|
// biome-ignore lint/suspicious/noExplicitAny: test transport is a Transport, but the type import chain is awkward here
|
|
80
97
|
await this.server.connect(transport);
|
|
98
|
+
this.server.onclose = () => this.transportClosedResolve();
|
|
81
99
|
this.started = true;
|
|
82
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Block until either (a) the stdio transport closes cleanly (Claude Code
|
|
103
|
+
* disconnected) or (b) the current backend's supervisor promise rejects
|
|
104
|
+
* with a terminal error. Returns when the transport closes. Throws when
|
|
105
|
+
* a backend's waitForExit rejects.
|
|
106
|
+
*
|
|
107
|
+
* Swap-aware: if setBackend() is called while waitForLifetime is blocked,
|
|
108
|
+
* the old backend's exit promise is abandoned and the new backend's is
|
|
109
|
+
* awaited. This matters for "SSE → auth-required → SSE2" flows where we
|
|
110
|
+
* want errors from SSE2 to bubble up, not the already-dead SSE1.
|
|
111
|
+
*/
|
|
112
|
+
async waitForLifetime() {
|
|
113
|
+
while (true) {
|
|
114
|
+
const swapSignal = new Promise((resolve) => {
|
|
115
|
+
this.backendSwappedSignal = () => resolve("swap");
|
|
116
|
+
});
|
|
117
|
+
const backendExit = this.backend.waitForExit?.() ?? new Promise(() => { });
|
|
118
|
+
const outcome = await Promise.race([
|
|
119
|
+
this.transportClosed.then(() => "closed"),
|
|
120
|
+
backendExit.then(() => "exit"),
|
|
121
|
+
swapSignal,
|
|
122
|
+
]);
|
|
123
|
+
if (outcome === "swap")
|
|
124
|
+
continue; // re-sample waitForExit against the new backend
|
|
125
|
+
return; // "closed" or "exit" (the backend's waitForExit resolved cleanly)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Used by setBackend to wake waitForLifetime so it can re-sample. Default
|
|
130
|
+
* no-op so a swap fired before waitForLifetime's first iteration (e.g. an
|
|
131
|
+
* onAuthorized that completes between proxy.start() and the first
|
|
132
|
+
* waitForLifetime call) doesn't crash. The missed wake is harmless — the
|
|
133
|
+
* next waitForLifetime iteration samples this.backend directly.
|
|
134
|
+
*/
|
|
135
|
+
backendSwappedSignal = () => { };
|
|
83
136
|
/**
|
|
84
137
|
* Swap the backend reference and fire a tools/list_changed notification so
|
|
85
138
|
* the client re-fetches. No in-flight tracking — the SDK routes each call
|
|
@@ -99,15 +152,26 @@ export class ProxyServer {
|
|
|
99
152
|
// Fire the notification after the swap so a client that races a list
|
|
100
153
|
// request against it sees the new catalog.
|
|
101
154
|
await this.server.sendToolListChanged();
|
|
102
|
-
//
|
|
103
|
-
//
|
|
155
|
+
// Wake any in-flight waitForLifetime so it re-samples the new backend's
|
|
156
|
+
// waitForExit. Without this, SSE2 errors after a swap-back would be lost.
|
|
157
|
+
this.backendSwappedSignal();
|
|
158
|
+
// Fire-and-forget stop of the old backend. The swap has already happened
|
|
159
|
+
// so stop failures can't break the running session — but log them so
|
|
160
|
+
// resource leaks (file descriptors, keepalive timers, EventSource auto-
|
|
161
|
+
// reconnect surviving a failed close) are visible.
|
|
104
162
|
if (prev !== next && prev.stop) {
|
|
105
|
-
prev.stop().catch(() => {
|
|
163
|
+
prev.stop().catch((err) => {
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
process.stderr.write(`Pharaoh: previous backend stop() failed — ${msg}\n`);
|
|
166
|
+
});
|
|
106
167
|
}
|
|
107
168
|
}
|
|
108
169
|
async stop() {
|
|
109
170
|
if (this.backend.stop) {
|
|
110
|
-
await this.backend.stop().catch(() => {
|
|
171
|
+
await this.backend.stop().catch((err) => {
|
|
172
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
173
|
+
process.stderr.write(`Pharaoh: backend stop() failed during shutdown — ${msg}\n`);
|
|
174
|
+
});
|
|
111
175
|
}
|
|
112
176
|
if (this.transport) {
|
|
113
177
|
await this.server.close().catch(() => { });
|
|
@@ -124,14 +188,28 @@ export class ProxyServer {
|
|
|
124
188
|
* the proxy in auth-required mode so the stdio connection stays alive while
|
|
125
189
|
* the user completes the device flow.
|
|
126
190
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* real
|
|
191
|
+
* The caller supplies `opts.onAuthorized`, which fires from inside the
|
|
192
|
+
* AuthRequiredBackend the moment device flow completes. Typical implementation:
|
|
193
|
+
* swap the ProxyServer over to an SseProxyBackend so the next tool call goes
|
|
194
|
+
* to the real server. This keeps the proxy reference under the caller's
|
|
195
|
+
* control — the backend never imports ProxyServer.
|
|
131
196
|
*/
|
|
132
|
-
export async function startInAuthRequiredMode(server) {
|
|
197
|
+
export async function startInAuthRequiredMode(server, opts) {
|
|
133
198
|
const gateway = createGateway({ server });
|
|
134
|
-
const backend = new AuthRequiredBackend(gateway)
|
|
199
|
+
const backend = new AuthRequiredBackend(gateway, async () => {
|
|
200
|
+
// Pull fresh creds from the gateway (which just wrote them to disk) so
|
|
201
|
+
// the callback doesn't race a separate disk read. The gateway is the
|
|
202
|
+
// authoritative post-auth source.
|
|
203
|
+
const creds = gateway.getCredentials();
|
|
204
|
+
if (!creds) {
|
|
205
|
+
// Should never happen — gateway.handleToolCall() only returns null
|
|
206
|
+
// after persistAuthorizedToken sets lastCreds. Keep the guard so a
|
|
207
|
+
// future refactor doesn't silently ship a swap-to-nothing bug.
|
|
208
|
+
process.stderr.write("Pharaoh: authorized but no credentials available — skipping swap\n");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await opts.onAuthorized(creds);
|
|
212
|
+
});
|
|
135
213
|
const proxy = new ProxyServer(backend);
|
|
136
214
|
await proxy.start();
|
|
137
215
|
return { proxy, gateway };
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* directly and `startProxy` will disappear at that point.
|
|
2
|
+
* Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
|
|
3
|
+
* backend.ts (SseProxyBackend); this module exports only the error types and
|
|
4
|
+
* the classifier so both the backend and tests can produce/assert on the
|
|
5
|
+
* same typed errors.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
|
|
10
|
-
* to produce the same typed errors so downstream consumers don't care which
|
|
11
|
-
* path surfaced them.
|
|
12
|
-
*
|
|
13
|
-
* RECONNECTION DESIGN (inherited by SseProxyBackend):
|
|
7
|
+
* RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
|
|
14
8
|
* The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
|
|
15
9
|
* auto-reconnect (every 3s by default). This is problematic because:
|
|
16
10
|
* 1. Each reconnect creates a NEW server-side session (new auth, new ID)
|
|
@@ -33,14 +27,13 @@ export declare class TenantSuspendedError extends Error {
|
|
|
33
27
|
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
34
28
|
* Exported for testability; used internally by the reconnect loop in
|
|
35
29
|
* backend.ts.
|
|
36
|
-
*/
|
|
37
|
-
export declare function classifySSEError(err: Error): void;
|
|
38
|
-
/**
|
|
39
|
-
* Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
|
|
40
|
-
* until the underlying SSE backend exits. Preserved so index.ts (Session C)
|
|
41
|
-
* keeps compiling until the wiring PR; the return/throw contract matches the
|
|
42
|
-
* original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
|
|
43
30
|
*
|
|
44
|
-
*
|
|
31
|
+
* The fallback uses status-prefix regex patterns rather than a bare
|
|
32
|
+
* `msg.includes("401")`. Session C calls this classifier on every reconnect
|
|
33
|
+
* error (not just SSE transport onerror events), so a substring match would
|
|
34
|
+
* misclassify any unrelated error text containing "401" — e.g. a port number,
|
|
35
|
+
* a timestamp suffix, or HTML body text echoed from a proxy. The regex
|
|
36
|
+
* requires an HTTP-shaped context so only actual status codes trigger a reauth
|
|
37
|
+
* loop.
|
|
45
38
|
*/
|
|
46
|
-
export declare function
|
|
39
|
+
export declare function classifySSEError(err: Error): void;
|
package/dist/proxy.js
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* directly and `startProxy` will disappear at that point.
|
|
2
|
+
* Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
|
|
3
|
+
* backend.ts (SseProxyBackend); this module exports only the error types and
|
|
4
|
+
* the classifier so both the backend and tests can produce/assert on the
|
|
5
|
+
* same typed errors.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
|
|
10
|
-
* to produce the same typed errors so downstream consumers don't care which
|
|
11
|
-
* path surfaced them.
|
|
12
|
-
*
|
|
13
|
-
* RECONNECTION DESIGN (inherited by SseProxyBackend):
|
|
7
|
+
* RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
|
|
14
8
|
* The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
|
|
15
9
|
* auto-reconnect (every 3s by default). This is problematic because:
|
|
16
10
|
* 1. Each reconnect creates a NEW server-side session (new auth, new ID)
|
|
@@ -39,10 +33,19 @@ export class TenantSuspendedError extends Error {
|
|
|
39
33
|
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
40
34
|
* Exported for testability; used internally by the reconnect loop in
|
|
41
35
|
* backend.ts.
|
|
36
|
+
*
|
|
37
|
+
* The fallback uses status-prefix regex patterns rather than a bare
|
|
38
|
+
* `msg.includes("401")`. Session C calls this classifier on every reconnect
|
|
39
|
+
* error (not just SSE transport onerror events), so a substring match would
|
|
40
|
+
* misclassify any unrelated error text containing "401" — e.g. a port number,
|
|
41
|
+
* a timestamp suffix, or HTML body text echoed from a proxy. The regex
|
|
42
|
+
* requires an HTTP-shaped context so only actual status codes trigger a reauth
|
|
43
|
+
* loop.
|
|
42
44
|
*/
|
|
43
45
|
export function classifySSEError(err) {
|
|
44
46
|
const msg = err.message ?? "";
|
|
45
|
-
// SseError from SDK includes the HTTP status code
|
|
47
|
+
// SseError from SDK includes the HTTP status code on `.code` — the
|
|
48
|
+
// authoritative signal.
|
|
46
49
|
if ("code" in err) {
|
|
47
50
|
const code = err.code;
|
|
48
51
|
if (code === 401)
|
|
@@ -50,35 +53,15 @@ export function classifySSEError(err) {
|
|
|
50
53
|
if (code === 403)
|
|
51
54
|
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
52
55
|
}
|
|
53
|
-
// Fallback
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
|
|
64
|
-
*
|
|
65
|
-
* @deprecated Session C will call ProxyServer / startInSseMode directly.
|
|
66
|
-
*/
|
|
67
|
-
export async function startProxy(sseUrl, token) {
|
|
68
|
-
// Lazy imports to keep module load order clean — backend.ts imports from
|
|
69
|
-
// proxy.ts for the typed errors and classifier above, so a top-level
|
|
70
|
-
// import would create a cycle.
|
|
71
|
-
const { createSseBackend } = await import("./backend.js");
|
|
72
|
-
const { ProxyServer } = await import("./proxy-server.js");
|
|
73
|
-
const backend = createSseBackend(sseUrl, token);
|
|
74
|
-
const proxy = new ProxyServer(backend);
|
|
75
|
-
await proxy.start();
|
|
76
|
-
// Block on the SSE reconnect loop — matches the original semantics of
|
|
77
|
-
// startProxy, which resolved only when the connection terminated.
|
|
78
|
-
try {
|
|
79
|
-
await backend.waitForExit();
|
|
80
|
-
}
|
|
81
|
-
finally {
|
|
82
|
-
await proxy.stop().catch(() => { });
|
|
56
|
+
// Fallback regex for errors that don't expose `.code`. Matches common HTTP
|
|
57
|
+
// status renderings: "HTTP 401", "HTTP/1.1 401", "401 Unauthorized",
|
|
58
|
+
// "status 401", "status: 401" — but NOT bare "401" anywhere in the string.
|
|
59
|
+
const m = msg.match(/\b(?:HTTP(?:\/[\d.]+)?|status:?)\s+(\d{3})\b|\b(\d{3})\s+(?:Unauthorized|Forbidden)\b/i);
|
|
60
|
+
if (m) {
|
|
61
|
+
const code = Number(m[1] ?? m[2]);
|
|
62
|
+
if (code === 401)
|
|
63
|
+
throw new TokenExpiredError();
|
|
64
|
+
if (code === 403)
|
|
65
|
+
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
83
66
|
}
|
|
84
67
|
}
|
package/inspect-tools.json
CHANGED
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
},
|
|
208
208
|
{
|
|
209
209
|
"name": "get_design_system",
|
|
210
|
-
"description": "Discover the design system BEFORE creating any UI component.\n\nCALL THIS WHEN:\n• You're about to create a React/Vue/Svelte component — check what already exists\n• You need to know the canonical component for a UI pattern (button, input, modal)\n• You want to find design tokens (colors, spacing, typography) to use\n• You suspect raw HTML elements are used instead of existing components\n\nRETURNS: Components with props and usage count, design tokens with values, and anti-patterns where raw HTML is used instead of existing components. Screen/page components are excluded by default.\n\nEXAMPLES:\n• \"What components does this design system have?\"\n• \"Is there already a Button component I should use?\"\n• \"What color tokens are available?\"\n• \"
|
|
210
|
+
"description": "Discover the design system BEFORE creating any UI component.\n\nCALL THIS WHEN:\n• You're about to create a React/Vue/Svelte component — check what already exists\n• You need to know the canonical component for a UI pattern (button, input, modal)\n• You want to find design tokens (colors, spacing, typography) to use\n• You suspect raw HTML elements are used instead of existing components\n\nRETURNS: Components with props and usage count (paginated), design tokens with values, and anti-patterns where raw HTML is used instead of existing components. Pass `path_pattern`, `name_prefix`, `offset`, or `limit` to narrow or page through results. Screen/page components are excluded by default.\n\nEXAMPLES:\n• \"What components does this design system have?\"\n• \"Is there already a Button component I should use?\"\n• \"What color tokens are available?\"\n• \"Show me all components in /atoms/\" → use path_pattern=\"/atoms/\"\n• \"Show me the next page\" → use offset=50\n\nWHY NOT JUST READ FILES: Design systems span dozens of files across multiple directories. This returns the complete inventory with usage frequency — the most-used components are the most canonical.",
|
|
211
211
|
"inputSchema": {
|
|
212
212
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
213
213
|
"type": "object",
|
|
@@ -231,6 +231,32 @@
|
|
|
231
231
|
"default": false,
|
|
232
232
|
"description": "Include screen/page components (*Screen, *Page, *View, *Route) that are typically app-level consumers, not reusable primitives",
|
|
233
233
|
"type": "boolean"
|
|
234
|
+
},
|
|
235
|
+
"path_pattern": {
|
|
236
|
+
"description": "Substring match against File.path (e.g. '/atoms/', '/ui/Button'). Narrows to components whose file path contains this exact substring.",
|
|
237
|
+
"type": "string",
|
|
238
|
+
"minLength": 1,
|
|
239
|
+
"maxLength": 200
|
|
240
|
+
},
|
|
241
|
+
"name_prefix": {
|
|
242
|
+
"description": "Exact prefix match against component name (e.g. 'Button' matches Button, ButtonGroup, ButtonIcon). Case-sensitive.",
|
|
243
|
+
"type": "string",
|
|
244
|
+
"minLength": 1,
|
|
245
|
+
"maxLength": 200
|
|
246
|
+
},
|
|
247
|
+
"offset": {
|
|
248
|
+
"default": 0,
|
|
249
|
+
"description": "Pagination offset (0-based). Capped at 10000.",
|
|
250
|
+
"type": "integer",
|
|
251
|
+
"minimum": 0,
|
|
252
|
+
"maximum": 10000
|
|
253
|
+
},
|
|
254
|
+
"limit": {
|
|
255
|
+
"default": 50,
|
|
256
|
+
"description": "Page size. Default 50, max 200.",
|
|
257
|
+
"type": "integer",
|
|
258
|
+
"minimum": 1,
|
|
259
|
+
"maximum": 200
|
|
234
260
|
}
|
|
235
261
|
},
|
|
236
262
|
"required": [
|
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.
|
|
4
|
+
"version": "0.3.27",
|
|
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/skills/pharaoh/SKILL.md
CHANGED
|
@@ -27,14 +27,23 @@ For unmapped private repos, direct the user to install the Pharaoh GitHub App
|
|
|
27
27
|
|
|
28
28
|
| Trigger | Call |
|
|
29
29
|
|---------|------|
|
|
30
|
+
| Before ANY architectural change | `get_vision_docs` — read the relevant MUST-bullets first |
|
|
30
31
|
| Before modifying code | `get_module_context` + `get_blast_radius` |
|
|
31
32
|
| Before writing new code | `search_functions` |
|
|
32
33
|
| Before creating UI components | `get_design_system` |
|
|
33
34
|
| Before tracing module connections | `query_dependencies` |
|
|
34
35
|
| Before implementing a spec | `get_vision_docs` |
|
|
36
|
+
| After a large refactor | `get_vision_gaps` — surface intent/implementation drift |
|
|
35
37
|
| Before deleting code | `check_reachability` |
|
|
36
38
|
| Initial orientation | `pharaoh_recon` (batches the above) |
|
|
37
39
|
|
|
40
|
+
**Vision docs are first-class.** If the repo has a `docs/VISION.md`, it's
|
|
41
|
+
the contract for what must always be true. Read it before touching
|
|
42
|
+
architecture. If a change would violate a MUST-bullet, stop and raise it
|
|
43
|
+
with the user before coding. If the repo has no VISION.md, offer to run
|
|
44
|
+
`/pharaoh:vision` at the next natural break — it's the highest-leverage
|
|
45
|
+
setup step for any team using Pharaoh.
|
|
46
|
+
|
|
38
47
|
## Anti-patterns
|
|
39
48
|
|
|
40
49
|
- Do NOT read files one-by-one to understand architecture — call `get_codebase_map`.
|
|
@@ -45,10 +54,12 @@ For unmapped private repos, direct the user to install the Pharaoh GitHub App
|
|
|
45
54
|
|
|
46
55
|
## Automatic workflows
|
|
47
56
|
|
|
48
|
-
**PR review:** `pharaoh_recon` (map + touched modules + blast radius) → `get_regression_risk` → `get_consolidation_opportunities`. Verdict: SHIP, SHIP WITH CHANGES, or BLOCK.
|
|
57
|
+
**PR review:** `pharaoh_recon` (map + touched modules + blast radius) → `get_vision_docs` (relevant assertions) → `get_regression_risk` → `get_consolidation_opportunities`. Verdict: SHIP, SHIP WITH CHANGES, or BLOCK. Any PR touching code governed by a MUST-bullet should reference the assertion in the review.
|
|
49
58
|
|
|
50
59
|
**Health audit:** `get_unused_code` → `get_test_coverage` → `get_regression_risk` → `get_vision_gaps` → `get_cross_repo_audit`. Grade A–F.
|
|
51
60
|
|
|
61
|
+
**Vision maintenance:** When an architectural decision lands in a PR, update `docs/VISION.md` in the same commit — add an assertion if one is new, retire one if it no longer applies. Stale vision docs are worse than none.
|
|
62
|
+
|
|
52
63
|
## Using results
|
|
53
64
|
|
|
54
65
|
- Lead with what the data reveals about this codebase — not a raw dump. Highlight surprises.
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -45,11 +45,12 @@ Do NOT review from memory or assumptions. Query the actual codebase first.
|
|
|
45
45
|
### If Pharaoh MCP tools are available:
|
|
46
46
|
|
|
47
47
|
1. `get_codebase_map` — current modules, hot files, dependency graph
|
|
48
|
-
2. `
|
|
49
|
-
3. `
|
|
50
|
-
4. `
|
|
51
|
-
5. `
|
|
52
|
-
6. `
|
|
48
|
+
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.
|
|
49
|
+
3. `search_functions` for keywords related to the plan — find existing code to reuse/extend
|
|
50
|
+
4. `get_module_context` on affected modules — entry points, patterns, conventions
|
|
51
|
+
5. `query_dependencies` between affected modules — coupling, circular deps
|
|
52
|
+
6. `get_blast_radius` on the primary target of the change — know what breaks
|
|
53
|
+
7. `check_reachability` on the primary target — verify it's actually reachable from entry points before worrying about it
|
|
53
54
|
|
|
54
55
|
### Without Pharaoh (graceful fallback):
|
|
55
56
|
|
|
@@ -77,10 +78,17 @@ Adapt depth to change size. Skip sections that don't apply. **After each section
|
|
|
77
78
|
|
|
78
79
|
### Section 1 — Architecture (skip for small/single-file changes)
|
|
79
80
|
|
|
81
|
+
- **Vision alignment (check FIRST).** Does this plan touch a domain
|
|
82
|
+
governed by a MUST-bullet in `docs/VISION.md`? If yes, quote the
|
|
83
|
+
assertion and verify the plan doesn't violate it. If the plan would
|
|
84
|
+
violate a MUST, STOP and raise with the user — either the plan changes
|
|
85
|
+
or VISION.md needs an update first (with user approval).
|
|
80
86
|
- Component boundaries and coupling concerns
|
|
81
87
|
- Dependency graph: does this change shrink or expand surface area?
|
|
82
88
|
- Data flow bottlenecks and single points of failure
|
|
83
89
|
- Does this need new code at all, or can a human process / existing pattern solve it?
|
|
90
|
+
- **If this plan introduces a new invariant**, call it out and include a
|
|
91
|
+
step in Section 6 to add a `### assertion` to VISION.md with a pairing TEST.
|
|
84
92
|
|
|
85
93
|
### Section 2 — Code Quality (always)
|
|
86
94
|
|
package/skills/review/SKILL.md
CHANGED
|
@@ -71,7 +71,8 @@ Then call these additional tools for data recon doesn't cover:
|
|
|
71
71
|
- `get_regression_risk` — overall change risk score for the repo
|
|
72
72
|
- `get_consolidation_opportunities` — duplicate logic the PR may introduce
|
|
73
73
|
- `check_reachability` — are new exports wired to entry points?
|
|
74
|
-
- `
|
|
74
|
+
- `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.
|
|
75
|
+
- `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.
|
|
75
76
|
|
|
76
77
|
**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.
|
|
77
78
|
|
|
@@ -255,7 +256,8 @@ If none of these triggers are met, **skip Phase 3** and proceed to Phase 4.
|
|
|
255
256
|
- Unreachable exports (new public code with zero callers)
|
|
256
257
|
- New circular dependencies between modules
|
|
257
258
|
- HIGH regression risk without corresponding test coverage
|
|
258
|
-
-
|
|
259
|
+
- **A MUST-bullet in `docs/VISION.md` is violated by this change** (without VISION.md being updated in the same PR with user approval)
|
|
260
|
+
- Vision spec drift: new complex function has no matching assertion AND the domain it lives in has existing assertions (implies invariants are expected here)
|
|
259
261
|
- Any CRITICAL finding from Phase 2 or Phase 3 that is confirmed and unfixed
|
|
260
262
|
- DISAGREE verdict from adversarial review on security-sensitive code, confirmed after verification
|
|
261
263
|
- Unanchored Cypher query (MATCH without traversing through `Repo {name: $repo}`)
|