@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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/CHANGELOG.md +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — embedded MCP client.
|
|
3
|
+
*
|
|
4
|
+
* Speaks to the DevSpec MCP endpoint (stateless Streamable HTTP, POST-only,
|
|
5
|
+
* bearer auth — see devspecv2 apps/web/app/api/mcp/route.ts) using the
|
|
6
|
+
* official SDK. The MCP tool surface is the interop contract across all
|
|
7
|
+
* DevSpec plugins; this client lets us honor that contract while presenting
|
|
8
|
+
* every verb as a NATIVE pi tool (VISION.md, "Transport").
|
|
9
|
+
*
|
|
10
|
+
* Two isolated clients per process (item 781886cf):
|
|
11
|
+
* - "pump" — poll / heartbeat / trail / bond. Must not wait on a model verb.
|
|
12
|
+
* - "work" — native tools and recall. A hang or parse error dies here.
|
|
13
|
+
*
|
|
14
|
+
* The server is stateless, so there is no session to manage. Transport
|
|
15
|
+
* failure retires that lane's client and reconnects lazily; MCP `isError`
|
|
16
|
+
* application results do not, because close() aborts every in-flight sibling
|
|
17
|
+
* (item 8e1b3c05). In-flight waiters keep a retired client alive until drain.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
21
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
22
|
+
import { resolveEndpoint } from "./auth-store.ts";
|
|
23
|
+
import { argsHaveProjectScope, folderScopeArgs, needsProjectScope } from "./project-scope.ts";
|
|
24
|
+
import { LOADED_CLIENT_VERSIONS, pluginHeaders, withClientVersions } from "./client-versions.ts";
|
|
25
|
+
import { conversationScope, hasConversationProjectScope, readProjectChoiceRequired, type ProjectChoiceRequired } from "./project-choice.ts";
|
|
26
|
+
|
|
27
|
+
const CLIENT_NAME = "devspec-pi";
|
|
28
|
+
// The MCP handshake names the same loaded artifact the x-devspec-plugin header
|
|
29
|
+
// does; a hand-kept literal here had drifted to a release long gone.
|
|
30
|
+
const CLIENT_VERSION = LOADED_CLIENT_VERSIONS.plugin_version ?? "unknown";
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
32
|
+
/** Hard cap for model-facing verbs. The SDK timeout alone did not fire on the
|
|
33
|
+
* live Streamable-HTTP parse hang (~90s, session d8883a49). */
|
|
34
|
+
export const MODEL_TIMEOUT_MS = 25_000;
|
|
35
|
+
|
|
36
|
+
export type ClientLane = "pump" | "work";
|
|
37
|
+
|
|
38
|
+
/** Token rejected by the server (401 + invalid_api_token / invalid_connection_capability). The user needs to re-login. */
|
|
39
|
+
export class DevspecAuthError extends Error {}
|
|
40
|
+
|
|
41
|
+
/** Anything else: network, server 5xx, MCP protocol errors. */
|
|
42
|
+
export class DevspecApiError extends Error {}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Authentication could not be EVALUATED (HTTP 503 auth_validation_unavailable).
|
|
46
|
+
* Retryable — the credential may be perfectly valid. Never terminal, never a
|
|
47
|
+
* reason to end the bond or tell the user to log in (item 781886cf).
|
|
48
|
+
*/
|
|
49
|
+
export class DevspecAuthUnavailableError extends DevspecApiError {
|
|
50
|
+
readonly credentialType: "api_token" | "connection_capability" | null;
|
|
51
|
+
constructor(message: string, credentialType: "api_token" | "connection_capability" | null = null) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.credentialType = credentialType;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** MCP tool result with `isError: true` — a verb refusal, not a dead socket. */
|
|
58
|
+
export class DevspecToolError extends DevspecApiError {
|
|
59
|
+
readonly projectChoice: ProjectChoiceRequired | null;
|
|
60
|
+
constructor(message: string, raw?: unknown) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.projectChoice = readProjectChoiceRequired(raw);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The per-token request budget is exhausted; carries the server's retry hint. */
|
|
67
|
+
export class DevspecRateLimitError extends DevspecApiError {
|
|
68
|
+
retryAfterMs: number;
|
|
69
|
+
constructor(message: string, retryAfterMs: number) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.retryAfterMs = retryAfterMs;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Structured failure carried by an SDK/transport error, when the transport
|
|
77
|
+
* preserved it. The MCP SDK throws StreamableHTTPError with `.code` set to
|
|
78
|
+
* the HTTP status and the response body embedded in the message as
|
|
79
|
+
* `Streamable HTTP error: Error POSTing to endpoint: <body>` — so both the
|
|
80
|
+
* status and the server's JSON failure contract (item 798e2375) survive.
|
|
81
|
+
* An earlier comment here claimed the status was lost; it was wrong, and the
|
|
82
|
+
* regex-only classifier built on that claim is what turned transient 503s
|
|
83
|
+
* into "revoked" (item 781886cf).
|
|
84
|
+
*/
|
|
85
|
+
export interface StructuredServerFailure {
|
|
86
|
+
status: number | null;
|
|
87
|
+
code: string | null;
|
|
88
|
+
retryable: boolean | null;
|
|
89
|
+
credentialType: "api_token" | "connection_capability" | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extract status / machine code / retry verdict from an SDK-shaped error. Exported for tests. */
|
|
93
|
+
export function readStructuredFailure(err: unknown): StructuredServerFailure {
|
|
94
|
+
const out: StructuredServerFailure = { status: null, code: null, retryable: null, credentialType: null };
|
|
95
|
+
if (typeof err === "object" && err !== null) {
|
|
96
|
+
const rec = err as Record<string, unknown>;
|
|
97
|
+
for (const key of ["code", "status", "statusCode"]) {
|
|
98
|
+
const v = rec[key];
|
|
99
|
+
if (typeof v === "number" && Number.isInteger(v) && v >= 100 && v <= 599) {
|
|
100
|
+
out.status = v;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
// String codes (ECONNREFUSED, …) are transport noise, never HTTP statuses.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
const jsonStart = message.indexOf("{");
|
|
108
|
+
if (jsonStart >= 0) {
|
|
109
|
+
let body: Record<string, unknown> | null = null;
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.parse(message.slice(jsonStart)) as Record<string, unknown>;
|
|
112
|
+
} catch {
|
|
113
|
+
body = null;
|
|
114
|
+
}
|
|
115
|
+
if (body && typeof body === "object") {
|
|
116
|
+
if (typeof body.code === "string") out.code = body.code;
|
|
117
|
+
if (typeof body.retryable === "boolean") out.retryable = body.retryable;
|
|
118
|
+
if (body.credential_type === "api_token" || body.credential_type === "connection_capability") {
|
|
119
|
+
out.credentialType = body.credential_type;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Truncated or wrapped body — salvage the machine fields directly.
|
|
123
|
+
const codeMatch = message.match(/"code"\s*:\s*"([^"]+)"/);
|
|
124
|
+
if (codeMatch) out.code = codeMatch[1];
|
|
125
|
+
const retryMatch = message.match(/"retryable"\s*:\s*(true|false)/);
|
|
126
|
+
if (retryMatch) out.retryable = retryMatch[1] === "true";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** Parse "Rate limit exceeded. Retry after N seconds." → ms, or null. */
|
|
132
|
+
export function parseRateLimitRetryMs(message: string): number | null {
|
|
133
|
+
if (!/rate limit/i.test(message)) return null;
|
|
134
|
+
const m = message.match(/retry after (\d+)\s*seconds?/i);
|
|
135
|
+
return m ? Number.parseInt(m[1], 10) * 1000 : 5_000;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Cross-extension-file rate-limit cooldown (globalThis — pi isolates module
|
|
139
|
+
// graphs per extension file). While cooling down, calls WAIT rather than
|
|
140
|
+
// hammering the limiter (failed attempts likely count against the budget).
|
|
141
|
+
const RL_KEY = "__devspecPiRateLimit";
|
|
142
|
+
|
|
143
|
+
function rateLimitStore(): { until: number } {
|
|
144
|
+
const g = globalThis as unknown as Record<string, { until: number } | undefined>;
|
|
145
|
+
if (!g[RL_KEY]) g[RL_KEY] = { until: 0 };
|
|
146
|
+
return g[RL_KEY] as { until: number };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const MAX_COOLDOWN_WAIT_MS = 30_000;
|
|
150
|
+
|
|
151
|
+
async function waitOutCooldown(): Promise<void> {
|
|
152
|
+
const wait = rateLimitStore().until - Date.now();
|
|
153
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, Math.min(wait, MAX_COOLDOWN_WAIT_MS)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Minimal MCP client surface the lane cache holds. Real SDK Client satisfies this. */
|
|
157
|
+
export type LaneClient = {
|
|
158
|
+
callTool: (
|
|
159
|
+
request: { name: string; arguments?: Record<string, unknown> },
|
|
160
|
+
schema?: unknown,
|
|
161
|
+
options?: { timeout?: number },
|
|
162
|
+
) => Promise<unknown>;
|
|
163
|
+
readResource: (request: { uri: string }, options?: { timeout?: number }) => Promise<unknown>;
|
|
164
|
+
close: () => Promise<void>;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export type LaneClientFactory = (args: {
|
|
168
|
+
token: string;
|
|
169
|
+
lane: ClientLane;
|
|
170
|
+
url: string;
|
|
171
|
+
connectionCapability?: string;
|
|
172
|
+
}) => Promise<LaneClient>;
|
|
173
|
+
|
|
174
|
+
interface CacheEntry {
|
|
175
|
+
token: string;
|
|
176
|
+
url: string;
|
|
177
|
+
/** Hidden connection capability bound into this transport's request headers. */
|
|
178
|
+
connectionCapability?: string;
|
|
179
|
+
client: LaneClient;
|
|
180
|
+
inFlight: number;
|
|
181
|
+
retired: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface LaneSlot {
|
|
185
|
+
entry: CacheEntry | null;
|
|
186
|
+
connecting: Promise<CacheEntry> | null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface LaneCache {
|
|
190
|
+
pump: LaneSlot;
|
|
191
|
+
work: LaneSlot;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Cross-extension-file singleton: pi isolates each extension file's module
|
|
195
|
+
// graph, so the cache lives on globalThis (one MCP client per lane).
|
|
196
|
+
// V2: inFlight/retired slots. Do not reuse the V1 `{ pump: CacheEntry | null }` shape.
|
|
197
|
+
const GLOBAL_KEY = "__devspecPiClientCacheByLaneV2";
|
|
198
|
+
const FACTORY_KEY = "__devspecPiLaneClientFactory";
|
|
199
|
+
|
|
200
|
+
function emptySlot(): LaneSlot {
|
|
201
|
+
return { entry: null, connecting: null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cacheStore(): LaneCache {
|
|
205
|
+
const g = globalThis as unknown as Record<string, LaneCache | undefined>;
|
|
206
|
+
if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = { pump: emptySlot(), work: emptySlot() };
|
|
207
|
+
return g[GLOBAL_KEY] as LaneCache;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function laneClientFactory(): LaneClientFactory | null {
|
|
211
|
+
const g = globalThis as unknown as Record<string, LaneClientFactory | null | undefined>;
|
|
212
|
+
return g[FACTORY_KEY] ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Test-only: replace Streamable HTTP connect. `null` restores the real transport. */
|
|
216
|
+
export function setLaneClientFactoryForTests(factory: LaneClientFactory | null): void {
|
|
217
|
+
const g = globalThis as unknown as Record<string, LaneClientFactory | null | undefined>;
|
|
218
|
+
g[FACTORY_KEY] = factory;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function closeEntry(entry: CacheEntry | null): void {
|
|
222
|
+
if (entry) void entry.client.close().catch(() => {});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function reusable(
|
|
226
|
+
entry: CacheEntry | null,
|
|
227
|
+
token: string,
|
|
228
|
+
url: string,
|
|
229
|
+
connectionCapability?: string,
|
|
230
|
+
): entry is CacheEntry {
|
|
231
|
+
return !!entry && !entry.retired && entry.token === token && entry.url === url &&
|
|
232
|
+
entry.connectionCapability === connectionCapability;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function retireEntry(lane: ClientLane, entry: CacheEntry): void {
|
|
236
|
+
entry.retired = true;
|
|
237
|
+
const slot = cacheStore()[lane];
|
|
238
|
+
if (slot.entry === entry) slot.entry = null;
|
|
239
|
+
if (entry.inFlight <= 0) closeEntry(entry);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function releaseClient(entry: CacheEntry): void {
|
|
243
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1);
|
|
244
|
+
if (entry.retired && entry.inFlight === 0) closeEntry(entry);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function forceCloseSlot(slot: LaneSlot): void {
|
|
248
|
+
if (slot.entry) {
|
|
249
|
+
closeEntry(slot.entry);
|
|
250
|
+
slot.entry = null;
|
|
251
|
+
}
|
|
252
|
+
slot.connecting = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Drop a lane (or both). Logout / token change force-closes; in-flight RPCs abort. */
|
|
256
|
+
export function resetClientCache(lane?: ClientLane): void {
|
|
257
|
+
const store = cacheStore();
|
|
258
|
+
if (!lane) {
|
|
259
|
+
forceCloseSlot(store.pump);
|
|
260
|
+
forceCloseSlot(store.work);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
forceCloseSlot(store[lane]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function openClient(
|
|
267
|
+
token: string,
|
|
268
|
+
lane: ClientLane,
|
|
269
|
+
url: string,
|
|
270
|
+
connectionCapability?: string,
|
|
271
|
+
): Promise<LaneClient> {
|
|
272
|
+
const factory = laneClientFactory();
|
|
273
|
+
if (factory) return factory({ token, lane, url, connectionCapability });
|
|
274
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
275
|
+
requestInit: {
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${token}`,
|
|
278
|
+
...pluginHeaders(),
|
|
279
|
+
...(connectionCapability ? { "x-devspec-connection-capability": connectionCapability } : {}),
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const client = new Client({ name: CLIENT_NAME, version: CLIENT_VERSION }, { capabilities: {} });
|
|
284
|
+
await client.connect(transport);
|
|
285
|
+
return client as unknown as LaneClient;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function acquireClient(token: string, lane: ClientLane, connectionCapability?: string, endpoint?: string): Promise<CacheEntry> {
|
|
289
|
+
const url = endpoint ?? (laneClientFactory() ? "https://devspec.test/api/mcp" : await resolveEndpoint());
|
|
290
|
+
const slot = cacheStore()[lane];
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (reusable(slot.entry, token, url, connectionCapability)) {
|
|
293
|
+
slot.entry.inFlight += 1;
|
|
294
|
+
return slot.entry;
|
|
295
|
+
}
|
|
296
|
+
if (slot.connecting) {
|
|
297
|
+
try {
|
|
298
|
+
await slot.connecting;
|
|
299
|
+
} catch {
|
|
300
|
+
/* connect failed; loop and try again */
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const previous = slot.entry;
|
|
305
|
+
const connecting = (async () => {
|
|
306
|
+
if (previous) retireEntry(lane, previous);
|
|
307
|
+
const client = await openClient(token, lane, url, connectionCapability);
|
|
308
|
+
const entry: CacheEntry = {
|
|
309
|
+
token,
|
|
310
|
+
url,
|
|
311
|
+
...(connectionCapability ? { connectionCapability } : {}),
|
|
312
|
+
client,
|
|
313
|
+
inFlight: 0,
|
|
314
|
+
retired: false,
|
|
315
|
+
};
|
|
316
|
+
slot.entry = entry;
|
|
317
|
+
return entry;
|
|
318
|
+
})();
|
|
319
|
+
slot.connecting = connecting;
|
|
320
|
+
try {
|
|
321
|
+
const entry = await connecting;
|
|
322
|
+
entry.inFlight += 1;
|
|
323
|
+
return entry;
|
|
324
|
+
} finally {
|
|
325
|
+
if (slot.connecting === connecting) slot.connecting = null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Map SDK/transport errors to typed client errors. Exported for tests.
|
|
332
|
+
* Classification is structured-first (item 781886cf): the HTTP status from
|
|
333
|
+
* StreamableHTTPError.code and the server's JSON failure contract
|
|
334
|
+
* (item 798e2375) decide; message regexes cover only exact legacy server
|
|
335
|
+
* strings and transport noise. A bare "401" or "unauthorized" in unrelated
|
|
336
|
+
* text is NEVER credential rejection — that pattern is what killed healthy
|
|
337
|
+
* bonds on transient 503s.
|
|
338
|
+
*/
|
|
339
|
+
export function mapClientError(err: unknown): Error {
|
|
340
|
+
if (
|
|
341
|
+
err instanceof DevspecAuthError || err instanceof DevspecAuthUnavailableError ||
|
|
342
|
+
err instanceof DevspecToolError || err instanceof DevspecRateLimitError
|
|
343
|
+
) {
|
|
344
|
+
return err;
|
|
345
|
+
}
|
|
346
|
+
if (err instanceof DevspecApiError) return err;
|
|
347
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
348
|
+
const structured = readStructuredFailure(err);
|
|
349
|
+
// 1. The server's explicit word wins. Unavailable validation is retryable
|
|
350
|
+
// even when the body happens to mention 401/unauthorized in prose.
|
|
351
|
+
if (
|
|
352
|
+
structured.retryable === true || structured.code === "auth_validation_unavailable" ||
|
|
353
|
+
structured.status === 503
|
|
354
|
+
) {
|
|
355
|
+
return new DevspecAuthUnavailableError(
|
|
356
|
+
"DevSpec could not check credentials right now (service unavailable). Retrying — no action needed.",
|
|
357
|
+
structured.credentialType,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (structured.code === "invalid_api_token") {
|
|
361
|
+
return new DevspecAuthError("DevSpec rejected the token (invalid or revoked). Run /devspec.login.");
|
|
362
|
+
}
|
|
363
|
+
if (structured.code === "invalid_connection_capability") {
|
|
364
|
+
return new DevspecAuthError(
|
|
365
|
+
"DevSpec rejected the connection credentials (invalid or expired). Reconnecting — run /devspec.login if it persists.",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
// 2. Exact legacy server strings (pre-code servers). Narrow on purpose.
|
|
369
|
+
if (/Invalid or revoked API token/.test(message)) {
|
|
370
|
+
return new DevspecAuthError("DevSpec rejected the token (invalid or revoked). Run /devspec.login.");
|
|
371
|
+
}
|
|
372
|
+
if (/Invalid or expired connection capability/.test(message)) {
|
|
373
|
+
return new DevspecAuthError(
|
|
374
|
+
"DevSpec rejected the connection credentials (invalid or expired). Reconnecting — run /devspec.login if it persists.",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
// 3. Anything else — including ambiguous proxy/gateway 401s with no server
|
|
378
|
+
// code — is not proof of revocation. Recoverable; the pump rides it out.
|
|
379
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|socket|certificate/i.test(message)) {
|
|
380
|
+
return new DevspecApiError(`Cannot reach DevSpec (${message}). Check your connection.`);
|
|
381
|
+
}
|
|
382
|
+
return new DevspecApiError(message);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Transport / parse / timeout / unavailable validation — recoverable. Never a human End. */
|
|
386
|
+
export function isRecoverableTransportError(err: unknown): boolean {
|
|
387
|
+
if (err instanceof DevspecAuthUnavailableError) return true;
|
|
388
|
+
if (err instanceof DevspecAuthError || err instanceof DevspecToolError) return false;
|
|
389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
390
|
+
return /parse|streamable|timed? ?out|ETIMEDOUT|fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|socket|certificate|network|abort|Cannot reach DevSpec|Connection closed|-32000/i.test(
|
|
391
|
+
message,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function mcpToolErrorText(raw: unknown, name: string): string | null {
|
|
396
|
+
const r = raw as { isError?: boolean; content?: Array<{ type?: string; text?: string }> } | null;
|
|
397
|
+
if (!r?.isError) return null;
|
|
398
|
+
const text = (r.content ?? []).filter((c) => c?.type === "text").map((c) => c.text).join(" ");
|
|
399
|
+
return text || `DevSpec tool ${name} failed.`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The server reports a terminal projection outcome as a tool error string:
|
|
404
|
+
* "Your message was NOT stored (<outcome>). …" (item fa834f2e). Extract the
|
|
405
|
+
* outcome for legacy diagnostics only. A stale binding is not permission to
|
|
406
|
+
* retry unbound or to assume the message was delivered. Returns null when the raw result
|
|
407
|
+
* is not a not-stored rejection.
|
|
408
|
+
*/
|
|
409
|
+
export function remotePostRejectionFromError(err: unknown): string | null {
|
|
410
|
+
if (!(err instanceof Error)) return null;
|
|
411
|
+
const notStored = /NOT stored \((\w+)\)/.exec(err.message)?.[1];
|
|
412
|
+
if (notStored) return notStored;
|
|
413
|
+
// The attempt-gate failure (phase='trail' requires an exact open activity
|
|
414
|
+
// attempt for this command segment) — a distinct class from projection
|
|
415
|
+
// outcomes, hit by bound trail posts whose segment attempt is gone.
|
|
416
|
+
if (/requires an exact open activity attempt/.test(err.message)) return "no_open_attempt";
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Only a REJECTED credential ends the bond from the client. Unavailable
|
|
422
|
+
* validation, transport noise, and ambiguous 401s are recoverable by
|
|
423
|
+
* construction of mapClientError — they never reach this predicate as
|
|
424
|
+
* DevspecAuthError (item 781886cf). A poll that reports `ended`/`not_found`
|
|
425
|
+
* is a *server* verdict — classified separately.
|
|
426
|
+
*/
|
|
427
|
+
export function shouldEndBondOnPollFailure(err: unknown): boolean {
|
|
428
|
+
return err instanceof DevspecAuthError || mapClientError(err) instanceof DevspecAuthError;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Only deliberate human teardown is sticky. Matches the server's
|
|
433
|
+
* PERMANENT_END_REASONS (ui / local_stop): owner_gone and auth are
|
|
434
|
+
* recoverable ends the revive path re-validates, idle_timeout was never
|
|
435
|
+
* sticky, and a missing reason says nothing (item 781886cf).
|
|
436
|
+
*/
|
|
437
|
+
export function isPermanentServerEnd(status?: string | null, endReason?: string | null): boolean {
|
|
438
|
+
if (status !== "ended" && status !== "not_found") return false;
|
|
439
|
+
return endReason === "ui" || endReason === "local_stop";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* A person ended this agent from DevSpec (End on the Agents page or in a session), so
|
|
444
|
+
* Pi closes itself (item 7813bcd9). The server's `ui` is the only end that does this:
|
|
445
|
+
* only the connection's owner can cause it. A local stop means the person is at this
|
|
446
|
+
* terminal and keeps Pi open, and every recoverable end keeps it open too.
|
|
447
|
+
*/
|
|
448
|
+
export function personEndClosesPi(status?: string | null, endReason?: string | null): boolean {
|
|
449
|
+
return isPermanentServerEnd(status, endReason) && endReason === "ui";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export interface CallOptions {
|
|
453
|
+
/** Host-frozen endpoint for an existing observation; never adopt a new login's URL on retry. */
|
|
454
|
+
endpoint?: string;
|
|
455
|
+
/** Non-idempotent mechanical delivery: no transport, scope, or rate-limit replay. */
|
|
456
|
+
retry?: "never";
|
|
457
|
+
/** Recheck captured authority after cooldown/client acquisition, immediately before send. */
|
|
458
|
+
beforeSend?: () => void;
|
|
459
|
+
/** Request timeout (default 60s). Long-poll callers pass hold + margin. */
|
|
460
|
+
timeoutMs?: number;
|
|
461
|
+
/** Hidden host-held capability. It is stamped as a transport header, never an argument. */
|
|
462
|
+
connectionCapability?: string;
|
|
463
|
+
/**
|
|
464
|
+
* Working directory for folder scope. When set, the first call includes this
|
|
465
|
+
* checkout's origin remote and/or `.devspec/project.json` pin so a
|
|
466
|
+
* multi-project token can resolve. If those facts were missing and the
|
|
467
|
+
* server still asks which project, one retry attaches them.
|
|
468
|
+
*/
|
|
469
|
+
cwd?: string;
|
|
470
|
+
/**
|
|
471
|
+
* Isolated transport. Default "work" so a forgotten lane cannot stall
|
|
472
|
+
* the pump. Poll / heartbeat / trail / bond must pass "pump".
|
|
473
|
+
*/
|
|
474
|
+
lane?: ClientLane;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Race a promise against a wall-clock deadline. Exported for tests. */
|
|
478
|
+
export async function withHardTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
479
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
480
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
481
|
+
timer = setTimeout(() => {
|
|
482
|
+
reject(new DevspecApiError(`DevSpec ${label} timed out after ${timeoutMs}ms`));
|
|
483
|
+
}, timeoutMs);
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
return await Promise.race([promise, timeout]);
|
|
487
|
+
} finally {
|
|
488
|
+
if (timer) clearTimeout(timer);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function invokeOnLane<T>(
|
|
493
|
+
token: string,
|
|
494
|
+
lane: ClientLane,
|
|
495
|
+
connectionCapability: string | undefined,
|
|
496
|
+
timeoutMs: number,
|
|
497
|
+
label: string,
|
|
498
|
+
run: (client: LaneClient) => Promise<T>,
|
|
499
|
+
endpoint?: string,
|
|
500
|
+
): Promise<T> {
|
|
501
|
+
await waitOutCooldown();
|
|
502
|
+
const entry = await acquireClient(token, lane, connectionCapability, endpoint);
|
|
503
|
+
try {
|
|
504
|
+
return await withHardTimeout(run(entry.client), timeoutMs, label);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const mapped = mapClientError(err);
|
|
507
|
+
if (isRecoverableTransportError(mapped)) retireEntry(lane, entry);
|
|
508
|
+
throw mapped;
|
|
509
|
+
} finally {
|
|
510
|
+
releaseClient(entry);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Call a DevSpec MCP tool. Throws DevspecAuthError / DevspecApiError / DevspecToolError.
|
|
516
|
+
* One transparent reconnect-retry on transport failure (the cached client may
|
|
517
|
+
* hold a dead socket after sleep/network change). MCP `isError` is a verb
|
|
518
|
+
* refusal: no close, no transport re-init. Project-scope refusals are different:
|
|
519
|
+
* folder facts go on the first call when `cwd` is known; if that call was still
|
|
520
|
+
* unscoped and the server asks which project, retry once with those facts —
|
|
521
|
+
* same client, extra args, not a reconnect. A work-lane failure never drops
|
|
522
|
+
* the pump client; retiring a lane never close()s while siblings are in flight.
|
|
523
|
+
*/
|
|
524
|
+
export async function callDevspec(
|
|
525
|
+
token: string,
|
|
526
|
+
name: string,
|
|
527
|
+
args: Record<string, unknown>,
|
|
528
|
+
options: CallOptions = {},
|
|
529
|
+
): Promise<unknown> {
|
|
530
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
531
|
+
const lane: ClientLane = options.lane ?? "work";
|
|
532
|
+
let callArgs = withClientVersions(name, args);
|
|
533
|
+
// Scope is conversation-owned. Folder facts are only the unbound fallback.
|
|
534
|
+
if (hasConversationProjectScope() && !options.connectionCapability && name !== "list_projects" && name !== "verify_agent_connection" && name !== "get_personal_instructions" && name !== "update_personal_instructions" && name !== "devspec_help_search") {
|
|
535
|
+
callArgs = conversationScope(callArgs, options.endpoint ?? await resolveEndpoint());
|
|
536
|
+
}
|
|
537
|
+
if (options.cwd && !argsHaveProjectScope(callArgs)) {
|
|
538
|
+
const scope = await folderScopeArgs(options.cwd);
|
|
539
|
+
if (scope.git_remote || scope.pinned_project_id) {
|
|
540
|
+
callArgs = { ...callArgs, ...scope };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
let scopedRetryDone = argsHaveProjectScope(callArgs);
|
|
544
|
+
let rateLimitRetries = 0;
|
|
545
|
+
let lastError: unknown = null;
|
|
546
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
547
|
+
try {
|
|
548
|
+
const raw = await invokeOnLane(
|
|
549
|
+
token,
|
|
550
|
+
lane,
|
|
551
|
+
options.connectionCapability,
|
|
552
|
+
timeoutMs,
|
|
553
|
+
name,
|
|
554
|
+
(client) => {
|
|
555
|
+
options.beforeSend?.();
|
|
556
|
+
return client.callTool({ name, arguments: callArgs }, undefined, { timeout: timeoutMs });
|
|
557
|
+
},
|
|
558
|
+
options.endpoint,
|
|
559
|
+
);
|
|
560
|
+
const toolError = mcpToolErrorText(raw, name);
|
|
561
|
+
if (toolError) throw new DevspecToolError(toolError, raw);
|
|
562
|
+
return raw;
|
|
563
|
+
} catch (err) {
|
|
564
|
+
const mapped = mapClientError(err);
|
|
565
|
+
// Rate limit: honor the server's hint, cool the whole process down,
|
|
566
|
+
// and retry ONCE after waiting — never hammer the window.
|
|
567
|
+
const retryMs = parseRateLimitRetryMs(mapped.message);
|
|
568
|
+
if (retryMs !== null) {
|
|
569
|
+
rateLimitStore().until = Date.now() + retryMs;
|
|
570
|
+
rateLimitRetries += 1;
|
|
571
|
+
if (options.retry === "never" || rateLimitRetries > 1) throw new DevspecRateLimitError(mapped.message, retryMs);
|
|
572
|
+
await new Promise((r) => setTimeout(r, Math.min(retryMs, MAX_COOLDOWN_WAIT_MS)));
|
|
573
|
+
attempt--; // the rate-limit wait-retry doesn't consume a transport attempt
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// Project-scope retry, including MCP isError. Not a transport retry:
|
|
577
|
+
// same client, extra folder facts, never close()/re-init.
|
|
578
|
+
if (
|
|
579
|
+
options.retry !== "never" && !scopedRetryDone &&
|
|
580
|
+
!(mapped instanceof DevspecAuthError) &&
|
|
581
|
+
!argsHaveProjectScope(callArgs) &&
|
|
582
|
+
needsProjectScope(mapped.message)
|
|
583
|
+
) {
|
|
584
|
+
const cwd = options.cwd ?? process.cwd();
|
|
585
|
+
const scope = await folderScopeArgs(cwd);
|
|
586
|
+
if (scope.git_remote || scope.pinned_project_id) {
|
|
587
|
+
scopedRetryDone = true;
|
|
588
|
+
callArgs = {
|
|
589
|
+
...callArgs,
|
|
590
|
+
...scope,
|
|
591
|
+
};
|
|
592
|
+
attempt--; // the scoping retry doesn't consume a transport attempt
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (mapped instanceof DevspecToolError) throw mapped;
|
|
597
|
+
lastError = mapped;
|
|
598
|
+
if (mapped instanceof DevspecAuthError) {
|
|
599
|
+
resetClientCache(lane);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
if (options.retry === "never" || !isRecoverableTransportError(mapped)) throw mapped;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
throw mapClientError(lastError);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* One isolated advisory call. It never touches the pump/work client caches,
|
|
610
|
+
* never waits through a known cooldown, never retries, and closes its own client
|
|
611
|
+
* at the end-to-end deadline. Used by edit-time nudges so discovery cannot delay
|
|
612
|
+
* or abort foreground DevSpec tools.
|
|
613
|
+
*/
|
|
614
|
+
export async function callDevspecAdvisoryOnce(
|
|
615
|
+
token: string,
|
|
616
|
+
name: string,
|
|
617
|
+
args: Record<string, unknown>,
|
|
618
|
+
timeoutMs = 1500,
|
|
619
|
+
): Promise<unknown> {
|
|
620
|
+
const cooldown = rateLimitStore().until - Date.now();
|
|
621
|
+
if (cooldown > 0) throw new DevspecRateLimitError("DevSpec advisory skipped during rate-limit cooldown.", cooldown);
|
|
622
|
+
const url = await resolveEndpoint();
|
|
623
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
624
|
+
requestInit: { headers: { authorization: `Bearer ${token}`, ...pluginHeaders() } },
|
|
625
|
+
});
|
|
626
|
+
const client = new Client({ name: `${CLIENT_NAME}-advisory`, version: CLIENT_VERSION }, { capabilities: {} });
|
|
627
|
+
try {
|
|
628
|
+
const raw = await withHardTimeout((async () => {
|
|
629
|
+
await client.connect(transport);
|
|
630
|
+
return client.callTool({ name, arguments: withClientVersions(name, args) }, undefined, { timeout: timeoutMs });
|
|
631
|
+
})(), timeoutMs, `${name} advisory`);
|
|
632
|
+
const result = raw as { isError?: boolean; content?: Array<{ type?: string; text?: string }> };
|
|
633
|
+
if (result?.isError) {
|
|
634
|
+
const text = (result.content ?? []).filter((c) => c?.type === "text").map((c) => c.text).join(" ");
|
|
635
|
+
throw new DevspecApiError(text || `DevSpec tool ${name} failed.`);
|
|
636
|
+
}
|
|
637
|
+
return raw;
|
|
638
|
+
} catch (error) {
|
|
639
|
+
const mapped = mapClientError(error);
|
|
640
|
+
const retryMs = parseRateLimitRetryMs(mapped.message);
|
|
641
|
+
if (retryMs !== null) {
|
|
642
|
+
rateLimitStore().until = Date.now() + retryMs;
|
|
643
|
+
throw new DevspecRateLimitError(mapped.message, retryMs);
|
|
644
|
+
}
|
|
645
|
+
throw mapped;
|
|
646
|
+
} finally {
|
|
647
|
+
// Cleanup must never extend the advisory deadline; this client is isolated
|
|
648
|
+
// from foreground lanes and close failure has no caller-visible consequence.
|
|
649
|
+
void client.close().catch(() => {});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Read one server-owned MCP resource without exposing a resource browser to
|
|
655
|
+
* the model. Product contracts use this mechanical path at work entry.
|
|
656
|
+
*/
|
|
657
|
+
export async function readDevspecResource(
|
|
658
|
+
token: string,
|
|
659
|
+
uri: string,
|
|
660
|
+
options: Pick<CallOptions, "timeoutMs" | "lane"> = {},
|
|
661
|
+
): Promise<unknown> {
|
|
662
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
663
|
+
const lane = options.lane ?? "work";
|
|
664
|
+
let lastError: unknown = null;
|
|
665
|
+
let rateLimitRetries = 0;
|
|
666
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
667
|
+
try {
|
|
668
|
+
return await invokeOnLane(
|
|
669
|
+
token,
|
|
670
|
+
lane,
|
|
671
|
+
undefined,
|
|
672
|
+
timeoutMs,
|
|
673
|
+
`resource ${uri}`,
|
|
674
|
+
(client) => client.readResource({ uri }, { timeout: timeoutMs }),
|
|
675
|
+
);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const mapped = mapClientError(error);
|
|
678
|
+
const retryMs = parseRateLimitRetryMs(mapped.message);
|
|
679
|
+
if (retryMs !== null) {
|
|
680
|
+
rateLimitStore().until = Date.now() + retryMs;
|
|
681
|
+
rateLimitRetries += 1;
|
|
682
|
+
if (rateLimitRetries > 1) throw new DevspecRateLimitError(mapped.message, retryMs);
|
|
683
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(retryMs, MAX_COOLDOWN_WAIT_MS)));
|
|
684
|
+
attempt--;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
lastError = mapped;
|
|
688
|
+
if (mapped instanceof DevspecAuthError) {
|
|
689
|
+
resetClientCache(lane);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
if (!isRecoverableTransportError(mapped)) throw mapped;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
throw mapClientError(lastError);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Verify a token and return the server's identity payload (unwrapMcpResult
|
|
700
|
+
* shape). Used by /devspec.login and the session-start status check.
|
|
701
|
+
*/
|
|
702
|
+
export async function verifyToken(token: string): Promise<unknown> {
|
|
703
|
+
return callDevspec(token, "verify_agent_connection", {}, { lane: "work", timeoutMs: 15_000 });
|
|
704
|
+
}
|