@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/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
+ }
@@ -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
+ }