@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. 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
+ }