thincoder 0.12.11 → 0.12.13

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/README.md CHANGED
@@ -43,6 +43,14 @@ Three layers, all "query if present, skip if absent", unified hybrid retrieval:
43
43
  - **Dual-track accumulation**: conventions written manually (`memory_put`), experience extracted from sessions via `/extract` — **the LLM proposes candidates, a human confirms each y/n** before anything is stored; never fully automatic
44
44
  - **Retrieval isolation**: the Project layer is isolated by project path — project A's memories never leak into project B
45
45
 
46
+ - **Agent Client Protocol** ⭐: `thincoder acp` exposes the agent over [ACP](https://agentclientprotocol.com/) v1 on stdio — one terminal login drives sessions from **Zed**, **JetBrains** AI chat, or **Paseo**:
47
+ - Streaming replies + thinking chunks; tool approval dialogs in the IDE
48
+ - IDE-native diffs — `write`/`edit` route through the editor buffer
49
+ - Persisted sessions: list / load (history replay) / resume / delete
50
+ - Per-session config: model / thinking / mode
51
+ - Setup: [docs/guides/ides.md](docs/guides/ides.md)
52
+
53
+
46
54
  ## Requirements
47
55
 
48
56
  - Node.js >= 24
package/bin/thincoder.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * thincoder memory <sub> Memory management: list / search / put / remove
8
8
  * thincoder upgrade Update to the latest version from npm
9
9
  * thincoder completion <sh> Shell completion: bash / zsh / fish
10
+ * thincoder acp Agent Client Protocol server (stdio, for Zed/JetBrains/Paseo)
10
11
  * thincoder -v Print version
11
12
  * thincoder --help Print help
12
13
  */
@@ -40,6 +41,7 @@ const USAGE = `thincoder - thin coding agent
40
41
  Usage:
41
42
  thincoder Launch the interactive TUI
42
43
  thincoder chat [--auto] <prompt> One-shot agent run (tools enabled), streams reply to stdout; --auto approves all tool calls
44
+ thincoder acp Agent Client Protocol server (stdio — Zed/JetBrains/Paseo drive sessions)
43
45
  thincoder memory list [--type=<t>] List memory entries
44
46
  thincoder memory search <query> Search memory
45
47
  thincoder memory put --type=<t> --title=<t> --content=<c> [--tags=<t>]
@@ -278,7 +280,7 @@ switch (command) {
278
280
  distill) COMPREPLY=( \\$(compgen -W "--yes --scope=" -- "\\$cur") ) ;;
279
281
  completion) COMPREPLY=( \\$(compgen -W "bash zsh fish" -- "\\$cur") ) ;;
280
282
  *)
281
- COMPREPLY=( \\$(compgen -W "chat memory sync reindex distill upgrade completion -v --version -h --help" -- "\\$cur") ) ;;
283
+ COMPREPLY=( \\$(compgen -W "chat acp memory sync reindex distill upgrade completion -v --version -h --help" -- "\\$cur") ) ;;
282
284
  esac
283
285
  }
284
286
  complete -F _thincoder thincoder
@@ -297,6 +299,7 @@ _thincoder() {
297
299
  cmd)
298
300
  _values 'command' \\
299
301
  'chat[One-shot agent run with tools]' \\
302
+ 'acp[Agent Client Protocol server for IDEs]' \\
300
303
  'memory[Manage long-term memory]' \\
301
304
  'sync[Sync team memory repo]' \\
302
305
  'reindex[Rebuild local index from markdown]' \\
@@ -331,6 +334,7 @@ complete -c thincoder -a reindex -d 'Rebuild local index from markdown'
331
334
  complete -c thincoder -a distill -d 'Extract knowledge from session'
332
335
  complete -c thincoder -a upgrade -d 'Update to latest version'
333
336
  complete -c thincoder -a completion -d 'Shell completion'
337
+ complete -c thincoder -a acp -d 'Agent Client Protocol server for IDEs'
334
338
 
335
339
  # Flags
336
340
  complete -c thincoder -s v -l version -d 'Print version'
@@ -388,6 +392,12 @@ complete -c thincoder -n '__fish_seen_subcommand_from completion' -a fish -d 'Fi
388
392
  break
389
393
  }
390
394
 
395
+ case "acp": {
396
+ const { runAcpServer } = await import("../src/acp.mjs")
397
+ await runAcpServer()
398
+ break
399
+ }
400
+
391
401
  case "--help":
392
402
  case "-h": {
393
403
  process.stdout.write(USAGE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,229 @@
1
+ /**
2
+ * bridge.mjs — map runAgent callbacks to ACP session/update notifications
3
+ * and reverse-RPC (M2: tools, permissions, fs routing).
4
+ *
5
+ * Wire shapes verified against the ACP schema v1 + kimi acp-adapter:
6
+ * - agent text → `agent_message_chunk` { content: { type: "text", text } }
7
+ * - thinking → `agent_thought_chunk` (same content shape)
8
+ * - tool start → `tool_call` { toolCallId, title, kind, status: "in_progress", rawInput, content }
9
+ * - tool result → `tool_call_update` { toolCallId, status: "completed"|"failed", content } (REPLACE semantics)
10
+ * - usage → `usage_update` { usage }
11
+ * - permission → reverse-RPC request `session/request_permission`
12
+ * { sessionId, options, toolCall } → client responds with
13
+ * { outcome: { outcome: "selected", optionId } | { outcome: "cancelled" } }
14
+ * - fs routing → reverse-RPC `fs/read_text_file` / `fs/write_text_file`
15
+ *
16
+ * toolCallId is generated per session (t1, t2, …) — thincoder's model-level
17
+ * tool ids are not guaranteed unique across turns, ACP ids must be.
18
+ *
19
+ * End-of-turn is NOT a notification: `session/prompt` resolves with
20
+ * `{ stopReason: "end_turn" }` (kimi session.ts parity).
21
+ */
22
+ import { join } from "node:path"
23
+
24
+ /** ACP ToolKind inference (schema v1 enum) — best-effort, clients render by kind. */
25
+ function inferToolKind(name) {
26
+ const base = name.includes("/") ? name.split("/").pop() : name
27
+ if (["write", "edit", "apply_patch", "insert_after", "hashline_edit"].includes(base)) return "edit"
28
+ if (base === "delete") return "delete"
29
+ if (base === "bash") return "execute"
30
+ if (["read", "glob", "grep", "ls", "code_search", "doc_search", "repo_outline"].includes(base)) return "read"
31
+ if (base === "fetch" || base === "websearch") return "fetch"
32
+ return "other"
33
+ }
34
+
35
+ /** Permission options surfaced to the client (kimi canonical ids, order load-bearing). */
36
+ const PERMISSION_OPTIONS = [
37
+ { optionId: "approve_once", name: "Approve once", kind: "allow_once" },
38
+ { optionId: "approve_always", name: "Approve for this session", kind: "allow_always" },
39
+ { optionId: "reject", name: "Reject", kind: "reject_once" },
40
+ ]
41
+
42
+ /** Map a client permission response to a boolean (unknown → reject, safety-first). */
43
+ function permissionToBoolean(response) {
44
+ const outcome = response?.outcome
45
+ if (!outcome || outcome.outcome === "cancelled") return false
46
+ if (outcome.optionId === "approve_once" || outcome.optionId === "approve" || outcome.optionId === "approve_always" || outcome.optionId === "approve_for_session") return true
47
+ return false
48
+ }
49
+
50
+ /**
51
+ * Build the runAgent callbacks for an ACP session.
52
+ * @param {{ sessionId: string, notify: (m, p) => void, request: (m, p, o?) => Promise<any>, log?: (s) => void }} deps
53
+ */
54
+ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }) {
55
+ const update = (sessionUpdate, extra = {}) =>
56
+ notify("session/update", { sessionId, update: { sessionUpdate, ...extra } })
57
+ let toolSeq = 0
58
+ const toolIds = new Map() // active tool name → current ACP toolCallId (defined before the literal — no expando)
59
+
60
+ const toolCallId = () => `t${++toolSeq}`
61
+ const contentBlock = (text) => ({ type: "content", content: { type: "text", text } })
62
+ const pathOf = (args) => {
63
+ const p = args?.path ?? args?.filePath
64
+ return typeof p === "string" && p ? p : null
65
+ }
66
+
67
+ const callbacks = {
68
+ onToken: (text) => update("agent_message_chunk", { content: { type: "text", text } }),
69
+ onReasoning: (text) => update("agent_thought_chunk", { content: { type: "text", text } }),
70
+ onUsage: (usage) => update("usage_update", { usage }),
71
+ onWait: ({ phase, seconds }) => log(`[rate-limit] ${phase} waiting ~${seconds}s`),
72
+ onCompress: () => log("[context] auto-compacted"),
73
+
74
+ onToolCall: (name, args) => {
75
+ const id = toolCallId()
76
+ toolIds.set(name, id)
77
+ update("tool_call", {
78
+ toolCallId: id,
79
+ title: name,
80
+ kind: inferToolKind(name),
81
+ status: "in_progress",
82
+ rawInput: args ?? {},
83
+ content: [contentBlock(JSON.stringify(args ?? {}))],
84
+ })
85
+ },
86
+
87
+ onToolResult: (name, result) => {
88
+ const id = toolIds.get(name) ?? toolCallId()
89
+ toolIds.delete(name)
90
+ update("tool_call_update", {
91
+ toolCallId: id,
92
+ status: "completed",
93
+ content: [contentBlock(String(result ?? ""))],
94
+ })
95
+ },
96
+
97
+ /**
98
+ * Permission gate (dispatch.mjs onPermissionRequest): reverse-RPC to the
99
+ * client. Any transport failure → reject (safety-first, kimi parity).
100
+ */
101
+ onPermissionRequest: async (name, args) => {
102
+ const toolCall = {
103
+ toolCallId: toolIds.get(name) ?? toolCallId(),
104
+ title: name,
105
+ content: [contentBlock(`Requesting approval to run ${name}`), contentBlock(JSON.stringify(args ?? {}))],
106
+ }
107
+ try {
108
+ const response = await request("session/request_permission", {
109
+ sessionId,
110
+ options: PERMISSION_OPTIONS,
111
+ toolCall,
112
+ }, { timeoutMs: 300000 }) // user deliberation can take a while; 5 min
113
+ return permissionToBoolean(response)
114
+ } catch (e) {
115
+ log(`[acp] request_permission failed; rejecting: ${e.message}`)
116
+ return false
117
+ }
118
+ },
119
+
120
+ /**
121
+ * fs reverse-RPC router (dispatch.mjs toolRouter, M2):
122
+ * - write → fs/write_text_file (full content, no read-back)
123
+ * - edit → fs/read_text_file → local single-replacement → fs/write_text_file
124
+ * - apply_patch → local (unified-diff application is not routed in M2)
125
+ * - delete, reads → local
126
+ */
127
+ toolRouter: async (name, args) => {
128
+ const base = name.includes("/") ? name.split("/").pop() : name
129
+ const path = pathOf(args)
130
+ if (base === "write" && path) {
131
+ if (typeof args?.content !== "string") {
132
+ return { handled: true, result: `Error: write content must be a string (got ${typeof args?.content})` }
133
+ }
134
+ const content = args.content
135
+ try {
136
+ await request("fs/write_text_file", { sessionId, path, content }, { timeoutMs: 30000 })
137
+ return { handled: true, result: `OK: wrote ${path} via IDE` }
138
+ } catch (e) {
139
+ return { handled: true, result: `Error: fs/write_text_file failed: ${e.message}` }
140
+ }
141
+ }
142
+ if (base === "edit" && path && typeof args?.old_string === "string" && typeof args?.new_string === "string") {
143
+ try {
144
+ const read = await request("fs/read_text_file", { sessionId, path }, { timeoutMs: 30000 })
145
+ const current = read?.text ?? read?.content ?? ""
146
+ const idx = current.indexOf(args.old_string)
147
+ if (idx === -1) {
148
+ return { handled: true, result: `Error: old_string not found in ${path} (read via IDE buffer)` }
149
+ }
150
+ const next = current.slice(0, idx) + args.new_string + current.slice(idx + args.old_string.length)
151
+ await request("fs/write_text_file", { sessionId, path, content: next }, { timeoutMs: 30000 })
152
+ return { handled: true, result: `OK: edited ${path} via IDE (1 replacement)` }
153
+ } catch (e) {
154
+ return { handled: true, result: `Error: edit via IDE failed: ${e.message}` }
155
+ }
156
+ }
157
+ return { handled: false } // read-only tools, delete, apply_patch stay local
158
+ },
159
+ }
160
+ return callbacks
161
+ }
162
+
163
+
164
+ /**
165
+ * Replay a stored human-line history as session/update notifications (session/load).
166
+ * role → event mapping (design §4.5):
167
+ * user → user_message_chunk
168
+ * assistant → agent_message_chunk (no tool_calls) | tool_call cards (with tool_calls)
169
+ * tool → tool_call_update following its assistant message
170
+ * Machine-only lines ([System reminder:/[User interrupt:, transient) are never stored
171
+ * in the human line (saveSession filters them), so nothing to skip here.
172
+ */
173
+ export function replayHistory({ sessionId, notify, history, log = () => {} }) {
174
+ const update = (sessionUpdate, extra = {}) =>
175
+ notify("session/update", { sessionId, update: { sessionUpdate, ...extra } })
176
+ // Shared content extraction: string → single text block; array → text blocks
177
+ // (images skipped with a log). textOf derives from the same source.
178
+ const contentBlocks = (m) => {
179
+ const items = []
180
+ if (typeof m?.content === "string") items.push({ type: "text", text: m.content })
181
+ else if (Array.isArray(m?.content)) {
182
+ for (const b of m.content) {
183
+ if (typeof b === "string") items.push({ type: "text", text: b })
184
+ else if (b?.type === "text") items.push({ type: "text", text: b.text })
185
+ else if (b?.type === "image") log(`[acp] replay: image block skipped (${sessionId})`)
186
+ }
187
+ }
188
+ return items
189
+ }
190
+ const textOf = (m) => contentBlocks(m).map((b) => b.text).join("\n")
191
+
192
+ let pendingToolCalls = [] // { id, title, kind } of the current assistant tool_calls batch
193
+ let toolSeq = 0
194
+ for (const m of history ?? []) {
195
+ if (m?.role === "user") {
196
+ pendingToolCalls = []
197
+ for (const b of contentBlocks(m)) update("user_message_chunk", { content: b })
198
+ } else if (m?.role === "assistant") {
199
+ const calls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0 ? m.tool_calls : null
200
+ if (calls) {
201
+ // One tool_call notification PER tool in the batch — clients correlate
202
+ // later tool_call_updates by toolCallId; an orphan update would be ignored.
203
+ pendingToolCalls = calls.map((tc, i) => {
204
+ const id = `t${++toolSeq}`
205
+ const title = tc?.name ?? "tool"
206
+ update("tool_call", {
207
+ toolCallId: id,
208
+ title,
209
+ kind: inferToolKind(title),
210
+ status: "in_progress",
211
+ content: contentBlocks(m).map((b) => ({ type: "content", content: b })),
212
+ })
213
+ return { id, title }
214
+ })
215
+ } else {
216
+ const items = contentBlocks(m)
217
+ for (const b of items) update("agent_message_chunk", { content: b })
218
+ pendingToolCalls = []
219
+ }
220
+ } else if (m?.role === "tool" && pendingToolCalls.length > 0) {
221
+ const call = pendingToolCalls.shift()
222
+ update("tool_call_update", {
223
+ toolCallId: call.id,
224
+ status: "completed",
225
+ content: [{ type: "content", content: { type: "text", text: textOf(m).slice(0, 2000) } }],
226
+ })
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * session.mjs — one ACP session = one thincoder agent instance.
3
+ *
4
+ * - `run(input)` serializes prompts through a per-session FIFO promise chain
5
+ * (concurrent prompts queue; each runs after the previous turn's end_turn).
6
+ * - `cancel()` aborts the in-flight turn via a REAL AbortController — the
7
+ * provider layer composes `AbortSignal.any([signal, timeout])` (core.mjs:280,
8
+ * anthropic.mjs:67, google.mjs:98), which requires a real AbortSignal; a
9
+ * plain object would throw TypeError on every LLM call.
10
+ * - The controller is rebuilt after every turn, so one cancel only affects the
11
+ * in-flight turn; the next queued prompt starts with a clean signal.
12
+ * - `run` is injectable for tests (defaults to the real runAgent).
13
+ */
14
+ import { runAgent } from "../agent.mjs"
15
+ import { buildAcpCallbacks } from "./bridge.mjs"
16
+
17
+ export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent }) {
18
+ let controller = new AbortController()
19
+ const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log })
20
+ let queue = Promise.resolve()
21
+ let busy = false
22
+
23
+ return {
24
+ id,
25
+ agent,
26
+ get busy() { return busy },
27
+ run(input) {
28
+ const task = queue.then(async () => {
29
+ busy = true
30
+ try {
31
+ return await run(agent, input, callbacks, { signal: controller.signal })
32
+ } finally {
33
+ busy = false
34
+ // Fresh controller per turn: cancel() only affects the in-flight turn.
35
+ controller = new AbortController()
36
+ }
37
+ })
38
+ // Keep the chain alive even when a turn rejects (the next prompt still runs).
39
+ queue = task.then(() => {}, () => {})
40
+ return task
41
+ },
42
+ cancel() {
43
+ controller.abort({ interrupt: true, message: "cancelled by client" })
44
+ },
45
+ }
46
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * transport.mjs — ACP NDJSON JSON-RPC 2.0 layer over stdio (zero dependencies).
3
+ *
4
+ * Wire format: one JSON object per line on stdin/stdout. stdout carries ONLY
5
+ * protocol JSON (logs go to stderr — kimi log-guard parity). Error codes follow
6
+ * JSON-RPC 2.0: -32600 parse, -32601 method not found, -32602 invalid params,
7
+ * -32603 internal, -32000 authRequired (ACP extension).
8
+ *
9
+ * `write` is injectable for tests (defaults to process.stdout.write). `start()`
10
+ * wires stdin + graceful shutdown (SIGINT/SIGTERM drain in-flight requests).
11
+ */
12
+ import { createInterface } from "node:readline"
13
+
14
+ export const ACP_ERRORS = {
15
+ PARSE: { code: -32600, message: "Parse error" },
16
+ METHOD_NOT_FOUND: { code: -32601, message: "Method not found" },
17
+ INVALID_PARAMS: { code: -32602, message: "Invalid params" },
18
+ INTERNAL: { code: -32603, message: "Internal error" },
19
+ AUTH_REQUIRED: { code: -32000, message: "authRequired" },
20
+ }
21
+
22
+ /**
23
+ * Create an ACP server.
24
+ * @param {Record<string, (params, ctx) => Promise<any>|any>} handlers — method → handler.
25
+ * Handler return value becomes `result`; `{ error: ACP_ERRORS.X }` becomes an error response;
26
+ * a thrown error becomes -32603.
27
+ * @param {{ write?: (s: string) => void, log?: (s: string) => void }} [opts]
28
+ */
29
+ export function createAcpServer(handlers, { write = (s) => process.stdout.write(s), log = () => {} } = {}) {
30
+ const state = { closed: false, inputClosed: false, inflight: new Set() }
31
+ const pending = new Map() // agent-initiated requests awaiting a client response (request_permission, fs/*)
32
+ let nextReqId = 1
33
+ const send = (obj) => { if (!state.closed) write(JSON.stringify(obj) + "\n") }
34
+ const notify = (method, params) => send({ jsonrpc: "2.0", method, params })
35
+
36
+ /**
37
+ * Agent-initiated JSON-RPC request: send with an id and await the client's
38
+ * response. Used for `session/request_permission` and `fs/read_text_file` /
39
+ * `fs/write_text_file` (reverse-RPC). Times out defensively — a silent
40
+ * client must never hang the agent loop.
41
+ */
42
+ function request(method, params, { timeoutMs = 60000 } = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ const id = `rpc-${nextReqId++}`
45
+ const timer = setTimeout(() => {
46
+ pending.delete(id)
47
+ reject(new Error(`ACP client did not respond to ${method} within ${timeoutMs}ms`))
48
+ }, timeoutMs)
49
+ pending.set(id, { resolve, reject, timer })
50
+ send({ jsonrpc: "2.0", id, method, params })
51
+ })
52
+ }
53
+
54
+ // stdin EOF only means "no more requests" — in-flight handlers must still
55
+ // deliver their responses (e.g. session/new building an agent). Closed only
56
+ // after the last handler settles.
57
+ const drainIfDone = () => { if (state.inputClosed && state.inflight.size === 0) state.closed = true }
58
+
59
+ // Inbound requests are serialized (FIFO): ACP sessions have ordering
60
+ // dependencies (prompt must follow new), and a naive client may fire lines
61
+ // back-to-back without awaiting responses. Each line's handler is awaited
62
+ // before the next line is processed — ordering is guaranteed end-to-end.
63
+ // EXCEPTION: responses to agent-initiated requests (request_permission /
64
+ // fs/*) resolve the pending waiter IMMEDIATELY, outside the queue — the
65
+ // prompt handler awaiting them blocks the queue, so queuing them would
66
+ // deadlock (client response waits for queue, queue waits for handler).
67
+ let inbound = Promise.resolve()
68
+ function handleLine(line) {
69
+ let msg
70
+ try { msg = JSON.parse(line) } catch {
71
+ // Malformed line. If it LOOKS like a response to an agent-initiated
72
+ // request (has an "rpc-" id), reject the waiter NOW — the prompt handler
73
+ // awaiting it would otherwise hang until the timeout.
74
+ const m = /"id"\s*:\s*"?(rpc-\d+)"?/.exec(line)
75
+ if (m && pending.has(m[1])) {
76
+ const waiter = pending.get(m[1])
77
+ pending.delete(m[1])
78
+ clearTimeout(waiter.timer)
79
+ waiter.reject(new Error("ACP client sent a malformed response"))
80
+ return
81
+ }
82
+ // Otherwise → queued path emits the parse error in order.
83
+ }
84
+ if (msg && typeof msg === "object" && msg.method === undefined && msg.id !== undefined) {
85
+ resolveClientResponse(msg)
86
+ return
87
+ }
88
+ const task = inbound.then(() => processLine(line)).catch(() => {})
89
+ inbound = task
90
+ state.inflight.add(task)
91
+ task.finally(() => { state.inflight.delete(task); drainIfDone() }).catch(() => {})
92
+ return task
93
+ }
94
+
95
+ function resolveClientResponse(msg) {
96
+ const waiter = pending.get(String(msg.id))
97
+ if (!waiter) return
98
+ pending.delete(String(msg.id))
99
+ clearTimeout(waiter.timer)
100
+ if (msg.error) waiter.reject(Object.assign(new Error(msg.error.message ?? "ACP client error"), { code: msg.error.code }))
101
+ else waiter.resolve(msg.result ?? {})
102
+ }
103
+
104
+ async function processLine(line) {
105
+ let msg
106
+ try {
107
+ msg = JSON.parse(line)
108
+ } catch {
109
+ send({ jsonrpc: "2.0", id: null, error: ACP_ERRORS.PARSE })
110
+ return
111
+ }
112
+ if (!msg || typeof msg !== "object") return
113
+ // Responses were already handled out-of-band in handleLine; anything left
114
+ // here with no method is a stray notification — ignore silently.
115
+ if (msg.method === undefined) return
116
+ const handler = handlers[msg.method]
117
+ if (!handler) {
118
+ if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: ACP_ERRORS.METHOD_NOT_FOUND })
119
+ return
120
+ }
121
+ try {
122
+ const result = await handler(msg.params ?? {}, { notify, send, log })
123
+ if (msg.id !== undefined) {
124
+ if (result && typeof result === "object" && result.error) {
125
+ send({ jsonrpc: "2.0", id: msg.id, error: result.error })
126
+ } else {
127
+ send({ jsonrpc: "2.0", id: msg.id, result })
128
+ }
129
+ }
130
+ } catch (e) {
131
+ log(`[acp] handler error: ${e?.message ?? e}`)
132
+ if (msg.id !== undefined) {
133
+ send({ jsonrpc: "2.0", id: msg.id, error: { ...ACP_ERRORS.INTERNAL, message: e?.message ?? String(e) } })
134
+ }
135
+ }
136
+ }
137
+
138
+ /** Wire stdin + shutdown handlers. Returns the notify fn for out-of-band pushes. */
139
+ function start() {
140
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
141
+ rl.on("line", (l) => { if (l.trim()) handleLine(l) })
142
+ rl.on("close", () => { state.inputClosed = true; drainIfDone() })
143
+ const shutdown = () => {
144
+ log("[acp] shutting down — draining in-flight requests")
145
+ Promise.all([...state.inflight]).then(() => { state.closed = true; process.exit(0) })
146
+ setTimeout(() => process.exit(0), 2000).unref()
147
+ }
148
+ process.on("SIGINT", shutdown)
149
+ process.on("SIGTERM", shutdown)
150
+ return { notify, shutdown }
151
+ }
152
+
153
+ // handleLine/_state exposed for tests (drive without a real stdin).
154
+ return { start, notify, request, handleLine, _state: state }
155
+ }