thincoder 0.12.10 → 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 +19 -2
- 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 +209 -87
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +12 -11
- package/src/agent-tools/subagent.mjs +73 -9
- package/src/agent.mjs +5 -5
- package/src/config.mjs +3 -0
- 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/tools/system.mjs +13 -5
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/cmd-shell.mjs +104 -0
- package/src/tui/cmd-submodel.mjs +151 -0
- package/src/tui/index.mjs +4 -3
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/pickers.mjs +32 -1
- package/src/tui/render-conversation.mjs +103 -37
- package/src/tui/render.mjs +24 -5
- package/src/tui/slash-commands.mjs +10 -0
- package/src/tui/tool-summaries.mjs +113 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
|
|
|
20
20
|
- **Memory system**: three layers (personal/project/team), FTS5 + vector RRF hybrid retrieval, git-friendly markdown format
|
|
21
21
|
- **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
|
|
22
22
|
- **Session persistence** ⭐0.5.0: up to 5 archive slots, `/session` to switch anytime, tool results visible after restore. Process-level isolation — multiple instances in the same directory each get their own session slot
|
|
23
|
-
- **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation
|
|
23
|
+
- **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation; per-subagent model override (`subagent` tool `model` arg or `agent.subagentModel` config — e.g. discuss with `glm-5.2`, let `deepseek-v4-flash` implement)
|
|
24
24
|
- **Plan Mode**: read-only exploration + design, implement after user approval
|
|
25
25
|
- **AUTO mode**: `/auto` full authorization, no confirmations on long tasks
|
|
26
26
|
- **Task tracking**: `task` tool breaks down multi-step work, status bar ✓n/m live progress, auto-filters completed items
|
|
@@ -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
|
|
@@ -85,7 +93,7 @@ thincoder upgrade
|
|
|
85
93
|
|
|
86
94
|
Running from source: replace `thincoder` above with `node bin/thincoder.mjs`.
|
|
87
95
|
|
|
88
|
-
Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
|
|
96
|
+
Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/submodel` (subagent models per type — picker over global + explore/plan/coder/eng-coder slots, or `/submodel <type> <provider:model>` directly), `/shell` (platform-aware picker of available shells — e.g. `/shell` → pick Git Bash/pwsh, or `/shell "C:\Program Files\Git\bin\bash.exe"`, `/shell reset`; fixes win11 cmd encoding/command issues), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
|
|
89
97
|
|
|
90
98
|
Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`), `THINCODER_BASE_URL`, `THINCODER_MODEL`, `SILICONFLOW_API_KEY`.
|
|
91
99
|
|
|
@@ -112,6 +120,7 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
|
|
|
112
120
|
},
|
|
113
121
|
],
|
|
114
122
|
"activeProvider": "deepseek", // currently active provider name
|
|
123
|
+
"shell": null, // bash tool shell (win11: e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default — cmd on Windows (UTF-8 forced per command), /bin/sh elsewhere. TUI: /shell
|
|
115
124
|
"embedding": {
|
|
116
125
|
// optional: without it, retrieval is pure FTS
|
|
117
126
|
"baseURL": "https://api.siliconflow.cn/v1",
|
|
@@ -120,6 +129,8 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
|
|
|
120
129
|
},
|
|
121
130
|
"agent": {
|
|
122
131
|
"maxTurns": 100, // tool-loop cap
|
|
132
|
+
"subagentModel": null, // default subagent provider/model override: "provider:model" | provider name | model name; null = inherit parent provider. Per-call: subagent tool `model` arg
|
|
133
|
+
"subagentModels": {}, // per-type override: { "explore": "...", "plan": "...", "coder": "...", "eng-coder": "..." }; priority: tool model arg > this > subagentModel > parent provider
|
|
123
134
|
"compactThreshold": 100000, // context compaction threshold (approx. tokens)
|
|
124
135
|
},
|
|
125
136
|
"memory": {
|
|
@@ -209,6 +220,12 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
209
220
|
|
|
210
221
|
## Changelog
|
|
211
222
|
|
|
223
|
+
### 0.12.11 (2026-08)
|
|
224
|
+
- **Subagent model per type** — `subagent` tool `model` arg, `config.agent.subagentModels` (per explore/plan/coder/eng-coder) and `config.agent.subagentModel` (global fallback); priority: tool arg > type > global > parent provider. `/submodel` TUI command: picker over 5 slots (global + 4 roles) with provider→model selection, or direct args (`/submodel coder deepseek:deepseek-v4-flash`).
|
|
225
|
+
- **Configurable bash shell** — `config.shell` or `/shell` TUI command: platform-aware picker (Windows: pwsh/Git Bash/WSL bash; POSIX: bash/zsh/fish — availability-detected, custom path supported). Windows default cmd now forces UTF-8 per command (`chcp 65001`) — fixes garbled Chinese output on win11.
|
|
226
|
+
- **Markdown table alignment fix** — `stringWidth` strips ANSI (zero display width) and rendered table rows are padded back to the computed width; inline markers (`` `code` ``, `**bold**`) no longer shift the borders.
|
|
227
|
+
- **sliceByWidth keeps ANSI sequences whole** — never slices mid-escape-sequence.
|
|
228
|
+
|
|
212
229
|
### 0.12.10 (2026-08)
|
|
213
230
|
- **Code-quality pass (advisor subsystem):**
|
|
214
231
|
- **Drop 11 unused exports** — internal-use symbols no longer leak through the module API (advisor table headers/constants, plan reminders, token-UUID helper, shrinkOversized).
|
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
|
+
}
|