thincoder 0.12.11 → 0.12.12
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 +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +205 -83
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +5 -5
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
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
|
@@ -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
|
+
}
|