@kpritam/grimoire-adapter-spawn-agent 0.1.7
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/LICENSE +21 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/errorMap.d.ts +15 -0
- package/dist/errorMap.js +194 -0
- package/dist/eventFormatter.d.ts +64 -0
- package/dist/eventFormatter.js +262 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/layer.d.ts +21 -0
- package/dist/layer.js +131 -0
- package/dist/probe.d.ts +65 -0
- package/dist/probe.js +225 -0
- package/package.json +68 -0
- package/src/errorMap.ts +224 -0
- package/src/eventFormatter.ts +313 -0
- package/src/index.ts +14 -0
- package/src/layer.ts +189 -0
- package/src/probe.ts +272 -0
package/dist/probe.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { ProviderUnavailable } from "@kpritam/grimoire-core";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { AgentNotInstalledError, AgentUnauthenticatedError, adapters, detectAvailableAgents } from "spawn-agent";
|
|
5
|
+
/**
|
|
6
|
+
* Build the spawn-agent adapter for a given provider id, applying
|
|
7
|
+
* provider-specific workarounds on top of the built-in defaults.
|
|
8
|
+
*
|
|
9
|
+
* Two classes of bug exist in spawn-agent v0.0.1:
|
|
10
|
+
*
|
|
11
|
+
* A) npm-resolution failure (copilot, gemini)
|
|
12
|
+
* The built-in adapter calls `resolvePackageBin("<pkg>")` in both
|
|
13
|
+
* `checkInstalled` and `resolve`. That traverses the local
|
|
14
|
+
* `node_modules` chain, which works for shim packages that are
|
|
15
|
+
* spawn-agent dependencies (`@agentclientprotocol/claude-agent-acp`,
|
|
16
|
+
* `@zed-industries/codex-acp`) but silently breaks for external CLIs
|
|
17
|
+
* that are distributed as npm but installed globally
|
|
18
|
+
* (`@github/copilot`, `@google/gemini-cli`). The fix: replace
|
|
19
|
+
* `checkInstalled` with binary detection and set `binPath` so
|
|
20
|
+
* `resolve()` calls the native binary directly.
|
|
21
|
+
*
|
|
22
|
+
* B) stdout-presence false negative (cursor, opencode)
|
|
23
|
+
* `checkAuthenticated` and `resolve` require both exit-0 AND
|
|
24
|
+
* non-empty stdout from an auth command. When auth is established via
|
|
25
|
+
* an API key env-var the CLI exits 0 but may write only to stderr,
|
|
26
|
+
* leaving stdout empty — causing a false "not authenticated". The
|
|
27
|
+
* fix: check exit code only, which is what the CLIs use to signal
|
|
28
|
+
* success.
|
|
29
|
+
*
|
|
30
|
+
* These are workarounds that belong upstream. Remove them once
|
|
31
|
+
* spawn-agent ships corrected adapters.
|
|
32
|
+
*/
|
|
33
|
+
export const buildAdapter = (id) => {
|
|
34
|
+
// --- class A: npm-resolution ---
|
|
35
|
+
if (id === "copilot") {
|
|
36
|
+
const builtin = adapters.builtInAdapter("copilot", {
|
|
37
|
+
binPath: "copilot"
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
...builtin,
|
|
41
|
+
checkInstalled: async () => detectAvailableAgents().includes("copilot")
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (id === "gemini") {
|
|
45
|
+
const builtin = adapters.builtInAdapter("gemini", {
|
|
46
|
+
binPath: "gemini"
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
...builtin,
|
|
50
|
+
checkInstalled: async () => detectAvailableAgents().includes("gemini")
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// --- class B: wrong auth subcommand (cursor) ---
|
|
54
|
+
if (id === "cursor") {
|
|
55
|
+
const builtin = adapters.builtInAdapter("cursor", {});
|
|
56
|
+
// spawn-agent calls `agent auth whoami` but the cursor CLI has no `auth`
|
|
57
|
+
// subcommand — that string is passed as a prompt to the agent process,
|
|
58
|
+
// which always exits 1. The correct command is `agent whoami`.
|
|
59
|
+
const checkCursorAuth = () => {
|
|
60
|
+
try {
|
|
61
|
+
const result = spawnSync("agent", ["whoami"], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
env: process.env,
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
65
|
+
});
|
|
66
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
...builtin,
|
|
74
|
+
checkAuthenticated: async () => checkCursorAuth(),
|
|
75
|
+
resolve: async () => {
|
|
76
|
+
const version = spawnSync("agent", ["--version"], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
env: process.env,
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
80
|
+
});
|
|
81
|
+
if (version.status !== 0 || version.error != null) {
|
|
82
|
+
throw new AgentNotInstalledError("cursor", "Cursor agent CLI is not installed. Install from https://cursor.com/docs/cli/acp.", "https://cursor.com/docs/cli/acp");
|
|
83
|
+
}
|
|
84
|
+
if (!checkCursorAuth()) {
|
|
85
|
+
throw new AgentUnauthenticatedError("cursor", "Cursor agent is not authenticated. Run `agent login` or set CURSOR_API_KEY.", "agent login");
|
|
86
|
+
}
|
|
87
|
+
return { bin: "agent", args: ["acp"], env: {} };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (id === "opencode") {
|
|
92
|
+
const builtin = adapters.builtInAdapter("opencode", {});
|
|
93
|
+
const checkOpencodeAuth = () => {
|
|
94
|
+
try {
|
|
95
|
+
const result = spawnSync("opencode", ["auth", "list"], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
env: process.env,
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
99
|
+
});
|
|
100
|
+
return result.status === 0;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
...builtin,
|
|
108
|
+
checkAuthenticated: async () => checkOpencodeAuth(),
|
|
109
|
+
resolve: async () => {
|
|
110
|
+
const version = spawnSync("opencode", ["--version"], {
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
env: process.env,
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
114
|
+
});
|
|
115
|
+
if (version.status !== 0 || version.error != null) {
|
|
116
|
+
throw new AgentNotInstalledError("opencode", "OpenCode is not installed. Install it with `npm install -g opencode-ai`.", "npm install -g opencode-ai");
|
|
117
|
+
}
|
|
118
|
+
if (!checkOpencodeAuth()) {
|
|
119
|
+
throw new AgentUnauthenticatedError("opencode", "OpenCode is not authenticated. Run `opencode auth login` and try again.", "opencode auth login");
|
|
120
|
+
}
|
|
121
|
+
return { bin: "opencode", args: ["acp"], env: {} };
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return adapters.builtInAdapter(id, {});
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Is this agent's CLI on the host right now? Prefers the adapter's
|
|
129
|
+
* own `checkInstalled()` hook (each built-in adapter knows exactly
|
|
130
|
+
* what to look for — package on disk, binary on PATH, etc.) and falls
|
|
131
|
+
* back to spawn-agent's aggregate `detectAvailableAgents()` when an
|
|
132
|
+
* adapter omits the hook.
|
|
133
|
+
*
|
|
134
|
+
* Never throws — any upstream exception collapses to `false`.
|
|
135
|
+
*/
|
|
136
|
+
const isInstalled = (id) => Effect.tryPromise({
|
|
137
|
+
try: async () => {
|
|
138
|
+
const adapter = buildAdapter(id);
|
|
139
|
+
if (adapter.checkInstalled !== undefined) {
|
|
140
|
+
return await adapter.checkInstalled();
|
|
141
|
+
}
|
|
142
|
+
return detectAvailableAgents().includes(id);
|
|
143
|
+
},
|
|
144
|
+
catch: () => new Error("probe failed")
|
|
145
|
+
}).pipe(Effect.orElseSucceed(() => false));
|
|
146
|
+
/**
|
|
147
|
+
* Is the agent actually authenticated (valid token, logged in, API
|
|
148
|
+
* key present, etc.)? Delegates to the adapter's `checkAuthenticated()`
|
|
149
|
+
* hook — the same logic spawn-agent uses before spawning the CLI —
|
|
150
|
+
* so probe UX matches runtime UX. When the hook is missing (e.g. Pi,
|
|
151
|
+
* Codex), we treat "installed" as "good enough" so we don't falsely
|
|
152
|
+
* mark those providers as unauthenticated.
|
|
153
|
+
*
|
|
154
|
+
* Never throws — any upstream exception collapses to `false`.
|
|
155
|
+
*/
|
|
156
|
+
const isAuthenticated = (id) => Effect.tryPromise({
|
|
157
|
+
try: async () => {
|
|
158
|
+
const adapter = buildAdapter(id);
|
|
159
|
+
if (adapter.checkAuthenticated !== undefined) {
|
|
160
|
+
return await adapter.checkAuthenticated();
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
catch: () => new Error("auth probe failed")
|
|
165
|
+
}).pipe(Effect.orElseSucceed(() => false));
|
|
166
|
+
/**
|
|
167
|
+
* Quick "is the binary installed?" check used by `grimoire doctor`
|
|
168
|
+
* and the `AgentRunner.probe` port. Defers to the adapter hook so
|
|
169
|
+
* binary/package-resolution rules stay in one place upstream.
|
|
170
|
+
*/
|
|
171
|
+
export const probeAgentBinary = (id) => Effect.gen(function* () {
|
|
172
|
+
const installed = yield* isInstalled(id);
|
|
173
|
+
if (installed)
|
|
174
|
+
return { available: true };
|
|
175
|
+
return { available: false, reason: `${id} CLI not found or not resolvable` };
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* Best-effort auth gate for `grimoire doctor`. Combines install +
|
|
179
|
+
* auth checks so callers get a single yes/no on "can this agent
|
|
180
|
+
* actually run right now?"
|
|
181
|
+
*
|
|
182
|
+
* Fails with `ProviderUnavailable` so workflows can distinguish an
|
|
183
|
+
* environmental problem (operator action required) from a hard
|
|
184
|
+
* `CliAgentError` from `runner.run`.
|
|
185
|
+
*/
|
|
186
|
+
export const authenticateAgent = (id) => Effect.gen(function* () {
|
|
187
|
+
const probe = yield* probeAgentBinary(id);
|
|
188
|
+
if (!probe.available) {
|
|
189
|
+
return yield* Effect.fail(new ProviderUnavailable({
|
|
190
|
+
provider: id,
|
|
191
|
+
reason: probe.reason ?? `${id} CLI not found or not resolvable`
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
const authed = yield* isAuthenticated(id);
|
|
195
|
+
if (authed)
|
|
196
|
+
return;
|
|
197
|
+
return yield* Effect.fail(new ProviderUnavailable({
|
|
198
|
+
provider: id,
|
|
199
|
+
reason: `${id} CLI is installed but not authenticated`
|
|
200
|
+
}));
|
|
201
|
+
});
|
|
202
|
+
/**
|
|
203
|
+
* One-shot probe used by `grimoire agent probe`. Combines install +
|
|
204
|
+
* auth into the row shape the workflow expects. Crucially, this tells
|
|
205
|
+
* the truth on the `authenticated` column — historically the probe
|
|
206
|
+
* reported `authenticated: yes` any time the binary was on PATH, which
|
|
207
|
+
* masked exactly the class of problem operators run the command to
|
|
208
|
+
* find.
|
|
209
|
+
*/
|
|
210
|
+
export const probeAgent = (id) => Effect.gen(function* () {
|
|
211
|
+
const probe = yield* probeAgentBinary(id);
|
|
212
|
+
if (!probe.available) {
|
|
213
|
+
return {
|
|
214
|
+
available: false,
|
|
215
|
+
authenticated: false,
|
|
216
|
+
...(probe.reason !== undefined ? { detail: probe.reason } : {})
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const authed = yield* isAuthenticated(id);
|
|
220
|
+
return {
|
|
221
|
+
available: true,
|
|
222
|
+
authenticated: authed,
|
|
223
|
+
...(authed ? {} : { detail: `${id} is installed but not authenticated` })
|
|
224
|
+
};
|
|
225
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kpritam/grimoire-adapter-spawn-agent",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Grimoire AgentRunner adapter backed by `spawn-agent`. Drives any locally-installed coding-agent CLI (Claude Code, Codex, Cursor, GitHub Copilot, Gemini, OpenCode, Factory Droid, Pi) over the Agent Client Protocol.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/kpritam/grimoire/tree/main/packages/adapter-spawn-agent#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kpritam/grimoire.git",
|
|
10
|
+
"directory": "packages/adapter-spawn-agent"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kpritam/grimoire/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"grimoire",
|
|
17
|
+
"spawn-agent",
|
|
18
|
+
"agent-client-protocol",
|
|
19
|
+
"acp",
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"copilot",
|
|
24
|
+
"cursor",
|
|
25
|
+
"gemini",
|
|
26
|
+
"opencode",
|
|
27
|
+
"droid",
|
|
28
|
+
"pi"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"module": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"import": "./dist/index.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"effect": "4.0.0-beta.51",
|
|
53
|
+
"spawn-agent": "^0.0.1",
|
|
54
|
+
"@kpritam/grimoire-core": "0.1.7"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@effect/vitest": "4.0.0-beta.51",
|
|
58
|
+
"@types/node": "^25.7.0",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
60
|
+
"vitest": "^4.1.6"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly false --incremental false --composite false --sourceMap false",
|
|
64
|
+
"check": "tsc -p tsconfig.json --noEmit --emitDeclarationOnly false --incremental false --composite false --sourceMap false",
|
|
65
|
+
"clean": "rm -rf dist",
|
|
66
|
+
"test": "vitest run --passWithNoTests"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/errorMap.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { CliAgentError, type CliAgentFailureReason } from "@kpritam/grimoire-core"
|
|
2
|
+
import {
|
|
3
|
+
type AdapterNotFoundError,
|
|
4
|
+
type AgentConnectionClosedError,
|
|
5
|
+
type AgentInactivityError,
|
|
6
|
+
type AgentInitError,
|
|
7
|
+
type AgentNotInstalledError,
|
|
8
|
+
type AgentSessionCreateError,
|
|
9
|
+
type AgentSessionLoadError,
|
|
10
|
+
type AgentSpawnError,
|
|
11
|
+
type AgentStdinClosedError,
|
|
12
|
+
type AgentStreamError,
|
|
13
|
+
type AgentUnauthenticatedError,
|
|
14
|
+
type CapabilityNotSupportedError,
|
|
15
|
+
type InvalidPromptContentError,
|
|
16
|
+
type ProtocolVersionMismatchError,
|
|
17
|
+
SpawnAgentError
|
|
18
|
+
} from "spawn-agent"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stringify an unknown cause into a single, compact message. The result
|
|
22
|
+
* is safe to put into `CliAgentError.message` / `stderr` without leaking
|
|
23
|
+
* huge payloads.
|
|
24
|
+
*/
|
|
25
|
+
const stringifyCause = (cause: unknown): string => {
|
|
26
|
+
if (cause instanceof Error) return cause.message
|
|
27
|
+
if (typeof cause === "string") return cause
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(cause)
|
|
30
|
+
} catch {
|
|
31
|
+
return String(cause)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compose the stderr payload for a `CliAgentError` from a spawn-agent
|
|
37
|
+
* exception. We always lead with the upstream message, then append any
|
|
38
|
+
* actionable remediation the upstream error carries — install URL,
|
|
39
|
+
* `loginCommand`, missing package name. This is the text an operator
|
|
40
|
+
* reads when a run fails, so it should tell them exactly what to do.
|
|
41
|
+
*/
|
|
42
|
+
const composeStderr = (message: string, remediation: string | undefined): string =>
|
|
43
|
+
remediation === undefined || remediation.length === 0 ? message : `${message}\n${remediation}`
|
|
44
|
+
|
|
45
|
+
interface MappedError {
|
|
46
|
+
readonly reason: CliAgentFailureReason
|
|
47
|
+
readonly remediation?: string
|
|
48
|
+
readonly exitCode?: number | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pure translator from spawn-agent's error hierarchy to Grimoire's
|
|
53
|
+
* {@link CliAgentError.reason} taxonomy plus user-facing remediation.
|
|
54
|
+
*
|
|
55
|
+
* The `switch` is exhaustive on `SpawnAgentError._tag`; adding a new
|
|
56
|
+
* upstream tag becomes a compile-time error here (`assertExhaustive`)
|
|
57
|
+
* rather than a silent slide into `"spawn"`. Retry semantics (see
|
|
58
|
+
* `isTransientCliFailure` in core) follow the mapped `reason`, so
|
|
59
|
+
* misclassifying an error here directly affects runtime behavior.
|
|
60
|
+
*/
|
|
61
|
+
const classifySpawnAgentError = (error: SpawnAgentError): MappedError => {
|
|
62
|
+
switch (error._tag) {
|
|
63
|
+
// --- Terminal user-setup failures (never retry) ---
|
|
64
|
+
case "NotInstalled": {
|
|
65
|
+
const e = error as AgentNotInstalledError
|
|
66
|
+
return {
|
|
67
|
+
reason: "not-found",
|
|
68
|
+
...(e.install !== undefined ? { remediation: `Install: ${e.install}` } : {})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
case "AdapterNotFound": {
|
|
72
|
+
const e = error as AdapterNotFoundError
|
|
73
|
+
return {
|
|
74
|
+
reason: "not-found",
|
|
75
|
+
remediation: `Install the missing adapter package: ${e.packageName}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
case "Unauthenticated": {
|
|
79
|
+
const e = error as AgentUnauthenticatedError
|
|
80
|
+
return {
|
|
81
|
+
reason: "auth",
|
|
82
|
+
...(e.loginCommand !== undefined ? { remediation: `Run: ${e.loginCommand}` } : {})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
case "CapabilityNotSupported": {
|
|
86
|
+
const e = error as CapabilityNotSupportedError
|
|
87
|
+
return {
|
|
88
|
+
reason: "spawn",
|
|
89
|
+
remediation: `Agent does not support capability: ${e.capability}`
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
case "InvalidContent": {
|
|
93
|
+
const e = error as InvalidPromptContentError
|
|
94
|
+
return {
|
|
95
|
+
reason: "parse",
|
|
96
|
+
remediation: `Content type "${e.contentType}" needs "${e.capability}" capability`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Usage/quota — distinct so callers can stop retrying ---
|
|
101
|
+
case "UsageLimit":
|
|
102
|
+
return {
|
|
103
|
+
reason: "non-zero-exit",
|
|
104
|
+
remediation: "Usage limits reached — wait for your quota to reset or switch provider."
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Timeouts (transient, retryable) ---
|
|
108
|
+
case "Inactivity": {
|
|
109
|
+
const e = error as AgentInactivityError
|
|
110
|
+
return {
|
|
111
|
+
reason: "timeout",
|
|
112
|
+
remediation: `Idle for ${Math.round(e.elapsedMs / 1000)}s (session ${e.sessionId})`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
case "InitTimeout":
|
|
116
|
+
return { reason: "timeout" }
|
|
117
|
+
|
|
118
|
+
// --- Signal kills & connection closes (retryable as killed-by-signal) ---
|
|
119
|
+
case "ConnectionClosed": {
|
|
120
|
+
const e = error as AgentConnectionClosedError
|
|
121
|
+
// A SIGTERM/SIGKILL is almost always transient — broken pipe, OS
|
|
122
|
+
// cleanup, a hung child cleared out. A clean non-zero exit could
|
|
123
|
+
// also be transient; treat it as `non-zero-exit` (retryable) when
|
|
124
|
+
// `exitCode` is non-null, else as `killed-by-signal`.
|
|
125
|
+
return {
|
|
126
|
+
reason: e.signal !== null ? "killed-by-signal" : "non-zero-exit",
|
|
127
|
+
exitCode: e.exitCode,
|
|
128
|
+
...(e.stderrTail.length > 0
|
|
129
|
+
? { remediation: `Last stderr: ${e.stderrTail.slice(-500)}` }
|
|
130
|
+
: {})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Subprocess / ACP init / session failures (not retryable) ---
|
|
135
|
+
case "Spawn": {
|
|
136
|
+
const e = error as AgentSpawnError
|
|
137
|
+
return { reason: "spawn", remediation: stringifyCause(e.cause) }
|
|
138
|
+
}
|
|
139
|
+
case "Init": {
|
|
140
|
+
const e = error as AgentInitError
|
|
141
|
+
return { reason: "spawn", remediation: stringifyCause(e.cause) }
|
|
142
|
+
}
|
|
143
|
+
case "SessionCreate": {
|
|
144
|
+
const e = error as AgentSessionCreateError
|
|
145
|
+
return { reason: "spawn", remediation: stringifyCause(e.cause) }
|
|
146
|
+
}
|
|
147
|
+
case "SessionLoad": {
|
|
148
|
+
const e = error as AgentSessionLoadError
|
|
149
|
+
return { reason: "spawn", remediation: stringifyCause(e.cause) }
|
|
150
|
+
}
|
|
151
|
+
case "Stream": {
|
|
152
|
+
const e = error as AgentStreamError
|
|
153
|
+
return { reason: "spawn", remediation: stringifyCause(e.cause) }
|
|
154
|
+
}
|
|
155
|
+
case "StdinClosed": {
|
|
156
|
+
const e = error as AgentStdinClosedError
|
|
157
|
+
return { reason: "killed-by-signal", remediation: stringifyCause(e.cause) }
|
|
158
|
+
}
|
|
159
|
+
case "ProtocolVersion": {
|
|
160
|
+
const e = error as ProtocolVersionMismatchError
|
|
161
|
+
return {
|
|
162
|
+
reason: "spawn",
|
|
163
|
+
remediation: `Client supports up to v${e.clientVersion}, agent requires v${e.agentVersion}`
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
case "Cancelled": {
|
|
167
|
+
// Cancellation is operator-initiated — don't retry.
|
|
168
|
+
return { reason: "killed-by-signal" }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
default: {
|
|
172
|
+
// Exhaustiveness check — if spawn-agent adds a new tag, this
|
|
173
|
+
// will fail to compile, forcing us to update the mapping.
|
|
174
|
+
const _exhaustive: never = error._tag as never
|
|
175
|
+
void _exhaustive
|
|
176
|
+
return { reason: "spawn" }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Single entry point used by the adapter's connect/run/close paths.
|
|
183
|
+
* Converts any upstream throw into a `CliAgentError` with a structured
|
|
184
|
+
* `reason`, populated `exitCode` where available, and a `stderr`
|
|
185
|
+
* composed of upstream message + actionable remediation.
|
|
186
|
+
*
|
|
187
|
+
* NEVER throws — the returned `CliAgentError` is always well-formed.
|
|
188
|
+
*/
|
|
189
|
+
export const mapSpawnAgentError = (id: string, cause: unknown): CliAgentError => {
|
|
190
|
+
if (cause instanceof SpawnAgentError) {
|
|
191
|
+
const mapped = classifySpawnAgentError(cause)
|
|
192
|
+
const stderr = composeStderr(cause.message, mapped.remediation)
|
|
193
|
+
return new CliAgentError({
|
|
194
|
+
agent: id,
|
|
195
|
+
exitCode: mapped.exitCode ?? null,
|
|
196
|
+
reason: mapped.reason,
|
|
197
|
+
message: cause.message,
|
|
198
|
+
stderr
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
const message = stringifyCause(cause)
|
|
202
|
+
return new CliAgentError({
|
|
203
|
+
agent: id,
|
|
204
|
+
exitCode: null,
|
|
205
|
+
reason: "spawn",
|
|
206
|
+
message,
|
|
207
|
+
stderr: message
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build a `CliAgentError` for the adapter's own wall-clock timeout —
|
|
213
|
+
* distinct from spawn-agent's `AgentInactivityError` (idle timeout).
|
|
214
|
+
*/
|
|
215
|
+
export const wallTimeoutError = (id: string, timeoutMs: number): CliAgentError => {
|
|
216
|
+
const message = `${id} exceeded wall timeout of ${timeoutMs}ms`
|
|
217
|
+
return new CliAgentError({
|
|
218
|
+
agent: id,
|
|
219
|
+
exitCode: null,
|
|
220
|
+
reason: "timeout",
|
|
221
|
+
message,
|
|
222
|
+
stderr: message
|
|
223
|
+
})
|
|
224
|
+
}
|