@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as process from "node:process";
|
|
3
|
+
/* ---------- ANSI palette ---------- */
|
|
4
|
+
const IS_TTY = process.stdout.isTTY === true;
|
|
5
|
+
const ansi = (code) => (s) => IS_TTY ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
6
|
+
const bold = ansi("1");
|
|
7
|
+
const yellow = ansi("33");
|
|
8
|
+
const red = ansi("31");
|
|
9
|
+
/* grimoire brand colors (256-color approximation) */
|
|
10
|
+
const amber = ansi("38;5;179"); /* #C87837 warm primary */
|
|
11
|
+
const amberBright = ansi("38;5;215"); /* #E09050 high accent */
|
|
12
|
+
const teal = ansi("38;5;37"); /* #2DB89E secondary accent */
|
|
13
|
+
const warmGray = ansi("38;5;101"); /* #9A8878 muted warm */
|
|
14
|
+
/* ---------- Tool-kind ↔ glyph & colour ---------- */
|
|
15
|
+
/**
|
|
16
|
+
* Per-{@link ToolKind} icon. Designed to read at a glance with grimoire
|
|
17
|
+
* personality: mystical glyphs that feel archival and incantatory.
|
|
18
|
+
*/
|
|
19
|
+
const TOOL_ICON = {
|
|
20
|
+
read: "📖",
|
|
21
|
+
edit: "✍ ",
|
|
22
|
+
delete: "✘ ",
|
|
23
|
+
move: "↻ ",
|
|
24
|
+
search: "🔍",
|
|
25
|
+
execute: "⚡",
|
|
26
|
+
think: "✨",
|
|
27
|
+
fetch: "🌐",
|
|
28
|
+
switch_mode: "⇄ ",
|
|
29
|
+
other: "⚙ "
|
|
30
|
+
};
|
|
31
|
+
const TOOL_COLOR = {
|
|
32
|
+
read: warmGray,
|
|
33
|
+
edit: amberBright,
|
|
34
|
+
delete: red,
|
|
35
|
+
move: amber,
|
|
36
|
+
search: warmGray,
|
|
37
|
+
execute: amberBright,
|
|
38
|
+
think: warmGray,
|
|
39
|
+
fetch: warmGray,
|
|
40
|
+
switch_mode: amber,
|
|
41
|
+
other: warmGray
|
|
42
|
+
};
|
|
43
|
+
const iconFor = (kind) => TOOL_ICON[kind ?? "other"];
|
|
44
|
+
const colorFor = (kind) => TOOL_COLOR[kind ?? "other"];
|
|
45
|
+
/* ---------- Title compaction ---------- */
|
|
46
|
+
const MAX_LABEL = 80;
|
|
47
|
+
const MAX_PLAN_ENTRY = 64;
|
|
48
|
+
const truncate = (s, max) => s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
49
|
+
const stripBold = (s) => s.replace(/\*\*/g, "");
|
|
50
|
+
/**
|
|
51
|
+
* Verbs the upstream agent (Claude Code, Codex, etc.) prepends to a tool
|
|
52
|
+
* title — e.g. `"Read foo/bar.md"`. Our icon already carries the action
|
|
53
|
+
* meaning, so we strip these leading words to keep the line short.
|
|
54
|
+
*/
|
|
55
|
+
const VERB_PREFIX_RE = /^(Read|Write|Edit|Update|Create|Delete|Remove|Move|Rename|Find|Search|Grep|Bash|Shell|Run|Execute|Fetch|Browse|Open)\s+/;
|
|
56
|
+
/**
|
|
57
|
+
* Render the basename of a path string. Strips trailing markers like
|
|
58
|
+
* `(line 1)` / `(1-50)` that some agents append to read titles. POSIX
|
|
59
|
+
* basename rules — works fine on Windows paths because we already
|
|
60
|
+
* normalise to forward slashes for display.
|
|
61
|
+
*/
|
|
62
|
+
const basenameOf = (filePath) => {
|
|
63
|
+
const trimmed = filePath.trim();
|
|
64
|
+
if (trimmed.length === 0)
|
|
65
|
+
return trimmed;
|
|
66
|
+
const m = /^(.*?)(\s+\(.*\))?$/.exec(trimmed);
|
|
67
|
+
const core = m?.[1] ?? trimmed;
|
|
68
|
+
const suffix = m?.[2] ?? "";
|
|
69
|
+
const normalised = core.replaceAll("\\", "/");
|
|
70
|
+
const base = path.posix.basename(normalised);
|
|
71
|
+
return `${base.length > 0 ? base : core}${suffix}`;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* For Bash/exec titles (which carry the actual command), keep the head
|
|
75
|
+
* of the command but drop trailing redirections / pipes / `&& echo …`
|
|
76
|
+
* noise that bloats the line without adding signal.
|
|
77
|
+
*/
|
|
78
|
+
const compactCommand = (cmd) => {
|
|
79
|
+
const head = cmd.split(/\s*\|\|\s*|\s*\|\s*|\s*&&\s*|\s*;\s*|\s*\d?>\s*/)[0] ?? cmd;
|
|
80
|
+
return truncate(head.trim(), MAX_LABEL);
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Compact a tool title down to a single, scannable label.
|
|
84
|
+
*
|
|
85
|
+
* - file-tools (`read`, `edit`, `delete`, `move`): basename only — the
|
|
86
|
+
* directory is usually obvious from the surrounding tool calls and
|
|
87
|
+
* the full path duplicates information already in the title prefix.
|
|
88
|
+
* - `execute`: drop pipe/redirect/chain noise so a quick `npm test`
|
|
89
|
+
* doesn't read like `npm test 2>&1 | tee log && echo done`.
|
|
90
|
+
* - `search`/`fetch`/`think`/`switch_mode`/`other`: keep as-is, just
|
|
91
|
+
* truncated to {@link MAX_LABEL}.
|
|
92
|
+
*/
|
|
93
|
+
export const compactToolTitle = (rawTitle, kind) => {
|
|
94
|
+
let title = rawTitle.trim();
|
|
95
|
+
if (title.length === 0)
|
|
96
|
+
return "";
|
|
97
|
+
const verb = VERB_PREFIX_RE.exec(title);
|
|
98
|
+
if (verb !== null)
|
|
99
|
+
title = title.slice(verb[0].length);
|
|
100
|
+
if (kind === "read" || kind === "edit" || kind === "delete" || kind === "move") {
|
|
101
|
+
return basenameOf(title);
|
|
102
|
+
}
|
|
103
|
+
if (kind === "execute") {
|
|
104
|
+
return compactCommand(title);
|
|
105
|
+
}
|
|
106
|
+
return truncate(title, MAX_LABEL);
|
|
107
|
+
};
|
|
108
|
+
export const newFormatState = () => ({ inProse: false });
|
|
109
|
+
/**
|
|
110
|
+
* Render one {@link AgentEvent} into a single line ready for stdout, or
|
|
111
|
+
* `null` to drop the event entirely.
|
|
112
|
+
*
|
|
113
|
+
* Behaviour notes:
|
|
114
|
+
*
|
|
115
|
+
* - `text-delta` is streamed verbatim with no trailing newline so the
|
|
116
|
+
* agent's own line breaks render naturally. Earlier revisions added
|
|
117
|
+
* a `\n` per chunk, which fragmented every paragraph into one-token
|
|
118
|
+
* slivers.
|
|
119
|
+
* - `tool-call` uses {@link compactToolTitle} + a {@link ToolKind}-coded
|
|
120
|
+
* icon so the operator can scan a long log for the action (read vs
|
|
121
|
+
* write vs shell) without parsing words.
|
|
122
|
+
* - `tool-call-update` is suppressed unless `verbose` (debug log level)
|
|
123
|
+
* except for failures, which always surface in red.
|
|
124
|
+
* - `plan` previews up to three entries inline so the operator can see
|
|
125
|
+
* what the agent intends to do without scrolling back to a separate
|
|
126
|
+
* pane.
|
|
127
|
+
* - High-frequency events (`usage`, `mode-changed`, `available-commands`)
|
|
128
|
+
* stay hidden outside debug.
|
|
129
|
+
*/
|
|
130
|
+
export const formatEvent = (event, verbose, state) => {
|
|
131
|
+
if (event.type === "text-delta") {
|
|
132
|
+
if (event.text.length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
state.inProse = true;
|
|
135
|
+
return { text: event.text, newline: false, leadNewline: false };
|
|
136
|
+
}
|
|
137
|
+
const lead = state.inProse;
|
|
138
|
+
state.inProse = false;
|
|
139
|
+
switch (event.type) {
|
|
140
|
+
case "thinking-delta": {
|
|
141
|
+
const text = stripBold(event.text).trim();
|
|
142
|
+
if (text.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
return {
|
|
145
|
+
text: warmGray(`✨ ${truncate(text, MAX_LABEL)}`),
|
|
146
|
+
newline: true,
|
|
147
|
+
leadNewline: lead
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
case "tool-call": {
|
|
151
|
+
const icon = iconFor(event.kind);
|
|
152
|
+
const color = colorFor(event.kind);
|
|
153
|
+
const subject = compactToolTitle(event.tool, event.kind);
|
|
154
|
+
const line = subject.length > 0 ? `${icon} ${color(bold(subject))}` : color(icon);
|
|
155
|
+
return { text: line, newline: true, leadNewline: lead };
|
|
156
|
+
}
|
|
157
|
+
case "tool-call-update": {
|
|
158
|
+
if (event.status === "failed") {
|
|
159
|
+
const tail = event.title !== undefined && event.title.length > 0 ? ` ${event.title}` : "";
|
|
160
|
+
return { text: red(`✗ ${tail}`), newline: true, leadNewline: lead };
|
|
161
|
+
}
|
|
162
|
+
if (!verbose)
|
|
163
|
+
return null;
|
|
164
|
+
const status = event.status ?? "update";
|
|
165
|
+
const tail = event.title !== undefined && event.title.length > 0 ? ` ${event.title}` : "";
|
|
166
|
+
return { text: warmGray(` └─ ${status}${tail}`), newline: true, leadNewline: lead };
|
|
167
|
+
}
|
|
168
|
+
case "tool-call-cancelled":
|
|
169
|
+
return {
|
|
170
|
+
text: yellow(`⊘ ${event.tool ?? "tool"} cancelled`),
|
|
171
|
+
newline: true,
|
|
172
|
+
leadNewline: lead
|
|
173
|
+
};
|
|
174
|
+
case "plan": {
|
|
175
|
+
if (event.entries.length === 0)
|
|
176
|
+
return null;
|
|
177
|
+
const head = bold(amber(`📚 Ritual (${event.entries.length} steps)`));
|
|
178
|
+
const preview = event.entries
|
|
179
|
+
.slice(0, 3)
|
|
180
|
+
.map((e, i) => ` ${amber(bold((i + 1).toString()))}. ${truncate(e.content, MAX_PLAN_ENTRY)}`)
|
|
181
|
+
.join("\n");
|
|
182
|
+
const more = event.entries.length > 3
|
|
183
|
+
? `\n ${warmGray(`└─ +${event.entries.length - 3} more steps`)}`
|
|
184
|
+
: "";
|
|
185
|
+
return { text: `${head}\n${preview}${more}`, newline: true, leadNewline: lead };
|
|
186
|
+
}
|
|
187
|
+
case "permission-request":
|
|
188
|
+
return {
|
|
189
|
+
text: amber(`🔐 ${event.request.tool ?? "?"} permitted`),
|
|
190
|
+
newline: true,
|
|
191
|
+
leadNewline: lead
|
|
192
|
+
};
|
|
193
|
+
case "finish": {
|
|
194
|
+
const isSuccess = event.stopReason === "end_turn";
|
|
195
|
+
if (isSuccess) {
|
|
196
|
+
const celebrationLines = [
|
|
197
|
+
amber(`✨ Cast complete — tome inscribed`),
|
|
198
|
+
teal(` The grimoire's pages are sealed with knowledge`)
|
|
199
|
+
];
|
|
200
|
+
return {
|
|
201
|
+
text: `\n${celebrationLines.join("\n")}\n`,
|
|
202
|
+
newline: true,
|
|
203
|
+
leadNewline: lead
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
text: `\n${amber(`◆ ${event.stopReason}`)}\n`,
|
|
208
|
+
newline: true,
|
|
209
|
+
leadNewline: lead
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
case "usage":
|
|
213
|
+
if (!verbose)
|
|
214
|
+
return null;
|
|
215
|
+
return {
|
|
216
|
+
text: warmGray(`[usage] ${event.usage.used}/${event.usage.size}`),
|
|
217
|
+
newline: true,
|
|
218
|
+
leadNewline: lead
|
|
219
|
+
};
|
|
220
|
+
case "mode-changed":
|
|
221
|
+
if (!verbose)
|
|
222
|
+
return null;
|
|
223
|
+
return { text: warmGray(`[mode] ${event.modeId}`), newline: true, leadNewline: lead };
|
|
224
|
+
case "available-commands":
|
|
225
|
+
if (!verbose)
|
|
226
|
+
return null;
|
|
227
|
+
return {
|
|
228
|
+
text: warmGray(`[commands] ${event.commands.map((c) => c.name).join(", ")}`),
|
|
229
|
+
newline: true,
|
|
230
|
+
leadNewline: lead
|
|
231
|
+
};
|
|
232
|
+
case "config-options":
|
|
233
|
+
case "session-info":
|
|
234
|
+
case "raw":
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/* ---------- Stream sink ---------- */
|
|
239
|
+
/**
|
|
240
|
+
* Write one event's formatted line to stdout, flushing any pending
|
|
241
|
+
* newline first. Centralising this keeps the streaming loop in
|
|
242
|
+
* `layer.ts` free of formatting concerns.
|
|
243
|
+
*/
|
|
244
|
+
export const writeEvent = (event, verbose, state) => {
|
|
245
|
+
const line = formatEvent(event, verbose, state);
|
|
246
|
+
if (line === null)
|
|
247
|
+
return;
|
|
248
|
+
if (line.leadNewline)
|
|
249
|
+
process.stdout.write("\n");
|
|
250
|
+
process.stdout.write(line.newline ? `${line.text}\n` : line.text);
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* Flush a final newline if the stream ended mid-prose. Call once after
|
|
254
|
+
* the event loop exits so the next CLI line doesn't run on from the
|
|
255
|
+
* agent's last sentence.
|
|
256
|
+
*/
|
|
257
|
+
export const finishStream = (state) => {
|
|
258
|
+
if (state.inProse) {
|
|
259
|
+
process.stdout.write("\n");
|
|
260
|
+
state.inProse = false;
|
|
261
|
+
}
|
|
262
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@kpritam/grimoire-adapter-spawn-agent` — concrete `AgentRunner`
|
|
3
|
+
* implementation backed by the upstream
|
|
4
|
+
* [`spawn-agent`](https://npmjs.com/package/spawn-agent) package.
|
|
5
|
+
*
|
|
6
|
+
* `spawn-agent` runs every supported coding-agent CLI (Claude Code,
|
|
7
|
+
* Codex, Copilot, Cursor, Gemini, OpenCode, Factory Droid, Pi) as a
|
|
8
|
+
* subprocess and talks to it over the Agent Client Protocol (ACP). The
|
|
9
|
+
* wire format and event surface is uniform across every agent — we
|
|
10
|
+
* just adapt that into the Effect-based {@link AgentRunner} port the
|
|
11
|
+
* rest of Grimoire depends on.
|
|
12
|
+
*/
|
|
13
|
+
export { SpawnAgentRunnerLayer } from "./layer.js";
|
|
14
|
+
export { authenticateAgent, probeAgent, probeAgentBinary } from "./probe.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@kpritam/grimoire-adapter-spawn-agent` — concrete `AgentRunner`
|
|
3
|
+
* implementation backed by the upstream
|
|
4
|
+
* [`spawn-agent`](https://npmjs.com/package/spawn-agent) package.
|
|
5
|
+
*
|
|
6
|
+
* `spawn-agent` runs every supported coding-agent CLI (Claude Code,
|
|
7
|
+
* Codex, Copilot, Cursor, Gemini, OpenCode, Factory Droid, Pi) as a
|
|
8
|
+
* subprocess and talks to it over the Agent Client Protocol (ACP). The
|
|
9
|
+
* wire format and event surface is uniform across every agent — we
|
|
10
|
+
* just adapt that into the Effect-based {@link AgentRunner} port the
|
|
11
|
+
* rest of Grimoire depends on.
|
|
12
|
+
*/
|
|
13
|
+
export { SpawnAgentRunnerLayer } from "./layer.js";
|
|
14
|
+
export { authenticateAgent, probeAgent, probeAgentBinary } from "./probe.js";
|
package/dist/layer.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AgentRunner, Config } from "@kpritam/grimoire-core";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
/**
|
|
4
|
+
* Build a single `AgentRunner` Layer from `Config`. The bound agent is
|
|
5
|
+
* whatever `provider.id` selects; switching providers is a config edit
|
|
6
|
+
* (no code changes needed). Other Grimoire subsystems depend only on
|
|
7
|
+
* the `AgentRunner` port — see `core/services/AgentRunner.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Each `run` call:
|
|
10
|
+
* 1. Opens a fresh `SpawnAgent` subprocess via `spawn-agent`.
|
|
11
|
+
* 2. Creates one ACP session for the requested `cwd`.
|
|
12
|
+
* 3. Streams the prompt through `agent.prompt(...)`. Events are
|
|
13
|
+
* logged to stdout as they arrive (when `logStream` is on); the
|
|
14
|
+
* final turn is mapped to `AgentReply`.
|
|
15
|
+
* 4. Closes the agent on the way out (always, via `Effect.acquireUseRelease`).
|
|
16
|
+
*
|
|
17
|
+
* Wall-clock timeouts come from `config.agentTimeoutMs`; idle / no-update
|
|
18
|
+
* timeouts come from `config.agentIdleTimeoutMs` and are forwarded to
|
|
19
|
+
* `spawn-agent`'s built-in inactivity watchdog.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SpawnAgentRunnerLayer: Layer.Layer<AgentRunner, never, Config>;
|
package/dist/layer.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { AgentRunner, Config } from "@kpritam/grimoire-core";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import { SpawnAgent } from "spawn-agent";
|
|
5
|
+
import { mapSpawnAgentError, wallTimeoutError } from "./errorMap.js";
|
|
6
|
+
import { finishStream, newFormatState, writeEvent } from "./eventFormatter.js";
|
|
7
|
+
import { authenticateAgent, buildAdapter, probeAgentBinary } from "./probe.js";
|
|
8
|
+
/**
|
|
9
|
+
* Translate Grimoire's typed {@link AgentOptions} block into
|
|
10
|
+
* `spawn-agent`'s {@link SpawnAgentConnectOptions}. One entry point —
|
|
11
|
+
* no per-provider shaping, no unsafe casts, no stringly-typed access.
|
|
12
|
+
*
|
|
13
|
+
* Note: Grimoire's `mcp.install` list is an *install spec* consumed by
|
|
14
|
+
* `@kpritam/grimoire-adapter-agent-install`, not a session-level ACP
|
|
15
|
+
* `McpServer` wire shape. They are deliberately not forwarded here;
|
|
16
|
+
* MCP servers are installed into the agent's own config before the
|
|
17
|
+
* cast starts, so spawn-agent sees them through the agent itself.
|
|
18
|
+
*/
|
|
19
|
+
const buildConnectOptions = (options, cwd, defaultInactivityMs, logStream) => {
|
|
20
|
+
const explicitInactivity = options["inactivity-timeout-ms"];
|
|
21
|
+
const additionalDirectories = options["additional-directories"];
|
|
22
|
+
const systemPrompt = options["system-prompt"];
|
|
23
|
+
return {
|
|
24
|
+
cwd,
|
|
25
|
+
permission: options.permission ?? "auto-allow",
|
|
26
|
+
inactivityTimeoutMs: typeof explicitInactivity === "number" ? explicitInactivity : defaultInactivityMs,
|
|
27
|
+
...(additionalDirectories !== undefined && additionalDirectories.length > 0
|
|
28
|
+
? { additionalDirectories: [...additionalDirectories] }
|
|
29
|
+
: {}),
|
|
30
|
+
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
31
|
+
...(logStream ? { onStderr: (line) => process.stderr.write(`${line}\n`) } : {})
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Shape a spawn-agent {@link TurnResult} into Grimoire's
|
|
36
|
+
* {@link AgentReply}. Forwards token usage + stop reason for
|
|
37
|
+
* observability and stashes the full turn under `raw` so downstream
|
|
38
|
+
* telemetry layers can attach token counts / session ids to spans.
|
|
39
|
+
*/
|
|
40
|
+
const toAgentReply = (result) => ({
|
|
41
|
+
content: result.text,
|
|
42
|
+
...(result.stopReason !== undefined ? { stopReason: String(result.stopReason) } : {}),
|
|
43
|
+
...(result.usage !== undefined
|
|
44
|
+
? {
|
|
45
|
+
usage: {
|
|
46
|
+
// spawn-agent's UsageReport is { size, used, cost? } — the
|
|
47
|
+
// cumulative counters for the whole session, not per-turn
|
|
48
|
+
// input/output split. We forward the `used` tokens into
|
|
49
|
+
// `outputTokens` as the most-honest approximation and leave
|
|
50
|
+
// `inputTokens` absent until upstream exposes the split.
|
|
51
|
+
outputTokens: result.usage.used
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
: {}),
|
|
55
|
+
raw: result
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Build a single `AgentRunner` Layer from `Config`. The bound agent is
|
|
59
|
+
* whatever `provider.id` selects; switching providers is a config edit
|
|
60
|
+
* (no code changes needed). Other Grimoire subsystems depend only on
|
|
61
|
+
* the `AgentRunner` port — see `core/services/AgentRunner.ts`.
|
|
62
|
+
*
|
|
63
|
+
* Each `run` call:
|
|
64
|
+
* 1. Opens a fresh `SpawnAgent` subprocess via `spawn-agent`.
|
|
65
|
+
* 2. Creates one ACP session for the requested `cwd`.
|
|
66
|
+
* 3. Streams the prompt through `agent.prompt(...)`. Events are
|
|
67
|
+
* logged to stdout as they arrive (when `logStream` is on); the
|
|
68
|
+
* final turn is mapped to `AgentReply`.
|
|
69
|
+
* 4. Closes the agent on the way out (always, via `Effect.acquireUseRelease`).
|
|
70
|
+
*
|
|
71
|
+
* Wall-clock timeouts come from `config.agentTimeoutMs`; idle / no-update
|
|
72
|
+
* timeouts come from `config.agentIdleTimeoutMs` and are forwarded to
|
|
73
|
+
* `spawn-agent`'s built-in inactivity watchdog.
|
|
74
|
+
*/
|
|
75
|
+
export const SpawnAgentRunnerLayer = Layer.effect(AgentRunner, Effect.gen(function* () {
|
|
76
|
+
const config = yield* Config;
|
|
77
|
+
// Compile-time check: every `AgentId` Grimoire allows must be a
|
|
78
|
+
// valid `SupportedAgentId` upstream. If spawn-agent drops an id
|
|
79
|
+
// without us trimming `AGENT_IDS`, this line fails to compile,
|
|
80
|
+
// surfacing the drift at build time rather than runtime.
|
|
81
|
+
const agentId = config.provider.id;
|
|
82
|
+
const inactivityTimeoutMs = config.agentIdleTimeoutMs;
|
|
83
|
+
const wallTimeoutMs = config.agentTimeoutMs;
|
|
84
|
+
const providerOptions = config.providerOptions;
|
|
85
|
+
const verbose = config.logging?.level === "debug";
|
|
86
|
+
// Use a provider-specific adapter rather than the bare string id so
|
|
87
|
+
// provider-level workarounds (e.g. copilot binary-path override) are
|
|
88
|
+
// active during the actual connect, not just during probe/doctor.
|
|
89
|
+
const adapter = buildAdapter(agentId);
|
|
90
|
+
const runOnce = (prompt, cwd, logStream) => Effect.acquireUseRelease(Effect.tryPromise({
|
|
91
|
+
try: () => SpawnAgent.connect(adapter, buildConnectOptions(providerOptions, cwd, inactivityTimeoutMs, logStream)),
|
|
92
|
+
catch: (cause) => mapSpawnAgentError(agentId, cause)
|
|
93
|
+
}), (agent) => Effect.tryPromise({
|
|
94
|
+
try: async () => {
|
|
95
|
+
const sessionId = await agent.createSession({ cwd });
|
|
96
|
+
const modelPref = providerOptions.model !== undefined
|
|
97
|
+
? { modelPreference: { configId: "model", value: providerOptions.model } }
|
|
98
|
+
: {};
|
|
99
|
+
const stream = agent.prompt(sessionId, { prompt, ...modelPref });
|
|
100
|
+
const state = logStream ? newFormatState() : null;
|
|
101
|
+
try {
|
|
102
|
+
for await (const event of stream) {
|
|
103
|
+
if (state !== null)
|
|
104
|
+
writeEvent(event, verbose, state);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
if (state !== null)
|
|
109
|
+
finishStream(state);
|
|
110
|
+
}
|
|
111
|
+
const result = await stream.completion;
|
|
112
|
+
return toAgentReply(result);
|
|
113
|
+
},
|
|
114
|
+
catch: (cause) => mapSpawnAgentError(agentId, cause)
|
|
115
|
+
}), (agent) => Effect.promise(() => agent.close().catch(() => undefined)).pipe(Effect.ignore));
|
|
116
|
+
const runWithWallTimeout = (prompt, cwd, logStream) => wallTimeoutMs > 0
|
|
117
|
+
? runOnce(prompt, cwd, logStream).pipe(Effect.timeoutOrElse({
|
|
118
|
+
duration: `${wallTimeoutMs} millis`,
|
|
119
|
+
orElse: () => Effect.fail(wallTimeoutError(agentId, wallTimeoutMs))
|
|
120
|
+
}))
|
|
121
|
+
: runOnce(prompt, cwd, logStream);
|
|
122
|
+
const service = {
|
|
123
|
+
id: agentId,
|
|
124
|
+
probe: probeAgentBinary(agentId),
|
|
125
|
+
authenticate: authenticateAgent(agentId),
|
|
126
|
+
run: (prompt, cwd, opts) => runWithWallTimeout(prompt, cwd, opts?.logStream ?? config.logStream).pipe(Effect.withSpan(`grimoire.${agentId}.run`, {
|
|
127
|
+
attributes: { "agent.id": agentId }
|
|
128
|
+
}))
|
|
129
|
+
};
|
|
130
|
+
return AgentRunner.of(service);
|
|
131
|
+
}));
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AgentId } from "@kpritam/grimoire-core";
|
|
2
|
+
import { ProviderUnavailable } from "@kpritam/grimoire-core";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { type AgentAdapter } 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 declare const buildAdapter: (id: AgentId) => AgentAdapter;
|
|
34
|
+
/**
|
|
35
|
+
* Quick "is the binary installed?" check used by `grimoire doctor`
|
|
36
|
+
* and the `AgentRunner.probe` port. Defers to the adapter hook so
|
|
37
|
+
* binary/package-resolution rules stay in one place upstream.
|
|
38
|
+
*/
|
|
39
|
+
export declare const probeAgentBinary: (id: AgentId) => Effect.Effect<{
|
|
40
|
+
readonly available: boolean;
|
|
41
|
+
readonly reason?: string;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort auth gate for `grimoire doctor`. Combines install +
|
|
45
|
+
* auth checks so callers get a single yes/no on "can this agent
|
|
46
|
+
* actually run right now?"
|
|
47
|
+
*
|
|
48
|
+
* Fails with `ProviderUnavailable` so workflows can distinguish an
|
|
49
|
+
* environmental problem (operator action required) from a hard
|
|
50
|
+
* `CliAgentError` from `runner.run`.
|
|
51
|
+
*/
|
|
52
|
+
export declare const authenticateAgent: (id: AgentId) => Effect.Effect<void, ProviderUnavailable>;
|
|
53
|
+
/**
|
|
54
|
+
* One-shot probe used by `grimoire agent probe`. Combines install +
|
|
55
|
+
* auth into the row shape the workflow expects. Crucially, this tells
|
|
56
|
+
* the truth on the `authenticated` column — historically the probe
|
|
57
|
+
* reported `authenticated: yes` any time the binary was on PATH, which
|
|
58
|
+
* masked exactly the class of problem operators run the command to
|
|
59
|
+
* find.
|
|
60
|
+
*/
|
|
61
|
+
export declare const probeAgent: (id: AgentId) => Effect.Effect<{
|
|
62
|
+
readonly available: boolean;
|
|
63
|
+
readonly authenticated: boolean;
|
|
64
|
+
readonly detail?: string | undefined;
|
|
65
|
+
}>;
|