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