papilot 0.6.0 → 0.8.0

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.
Files changed (85) hide show
  1. package/README.md +58 -30
  2. package/README.zh-CN.md +58 -30
  3. package/bin/doctor.mjs +53 -6
  4. package/bin/installation.d.mts +8 -0
  5. package/bin/installation.mjs +54 -0
  6. package/bin/papilot.mjs +6 -2
  7. package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
  8. package/dist/assets/index-Cc-awdNn.css +1 -0
  9. package/dist/assets/index-D_VX51no.js +265 -0
  10. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  11. package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
  12. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  13. package/dist/index.html +2 -2
  14. package/examples/papilot-paper/main.tex +1 -1
  15. package/examples/papilot-paper/sections/02-using.tex +3 -1
  16. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  17. package/package.json +15 -7
  18. package/scripts/service.ts +1 -0
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/note-groups.ts +30 -0
  23. package/src/notes.ts +34 -10
  24. package/src/server/acp.ts +354 -0
  25. package/src/server/agents.ts +237 -47
  26. package/src/server/ai-review-routes.ts +72 -0
  27. package/src/server/ai-review.ts +38 -0
  28. package/src/server/annotation-placement.ts +99 -0
  29. package/src/server/app.ts +216 -21
  30. package/src/server/attachments.ts +128 -0
  31. package/src/server/build-problems.ts +329 -0
  32. package/src/server/changes.ts +28 -2
  33. package/src/server/codex.ts +2 -1
  34. package/src/server/cursor.ts +153 -0
  35. package/src/server/diagnostics.ts +45 -0
  36. package/src/server/git-overview.ts +69 -0
  37. package/src/server/imports.ts +205 -0
  38. package/src/server/latex-installation.ts +43 -0
  39. package/src/server/main.ts +8 -42
  40. package/src/server/manuscript.ts +212 -12
  41. package/src/server/note-results.ts +65 -0
  42. package/src/server/paths.ts +1 -1
  43. package/src/server/references.ts +70 -0
  44. package/src/server/review.ts +6 -5
  45. package/src/server/skill-library.ts +113 -0
  46. package/src/server/skill-routes.ts +19 -0
  47. package/src/server/skills.ts +29 -19
  48. package/src/server/start.ts +51 -0
  49. package/src/server/store.ts +44 -4
  50. package/src/server/update-install.ts +124 -0
  51. package/src/server/update-worker.ts +78 -0
  52. package/src/server/updates.ts +152 -0
  53. package/src/server/workspace.ts +450 -60
  54. package/src/shared.ts +120 -10
  55. package/src/skill-icons.ts +18 -0
  56. package/src/skill-metadata.ts +45 -0
  57. package/src/task-caption.ts +20 -0
  58. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  59. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  60. package/vendor/latex-review/dist/web/index.html +2 -2
  61. package/vendor/latex-review/src/core/bib.ts +692 -0
  62. package/vendor/latex-review/src/core/build.ts +5 -1
  63. package/vendor/latex-review/src/core/changes.ts +39 -0
  64. package/vendor/latex-review/src/core/delivery.ts +109 -0
  65. package/vendor/latex-review/src/core/review.ts +12 -4
  66. package/vendor/latex-review/src/core/store.ts +3 -0
  67. package/vendor/latex-review/src/core/tasks.ts +5 -2
  68. package/vendor/latex-review/src/core/verify.ts +784 -0
  69. package/vendor/latex-review/src/core/view.ts +204 -0
  70. package/vendor/latex-review/src/render/round.ts +11 -0
  71. package/vendor/latex-review/src/server.ts +81 -1
  72. package/vendor/latex-review/src/types.ts +141 -0
  73. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  74. package/vendor/latex-review/src/web/i18n.ts +1 -1
  75. package/vendor/latex-review/src/web/overview.tsx +1 -5
  76. package/vendor/latex-review/src/web/references.tsx +499 -0
  77. package/vendor/latex-review/src/web/review.tsx +381 -230
  78. package/vendor/latex-review/src/web/shortcuts.ts +53 -0
  79. package/vendor/latex-review/src/web/style.css +295 -0
  80. package/vendor/latex-review/src/web/zh-CN.ts +95 -26
  81. package/dist/assets/index-BNGqA0_L.css +0 -1
  82. package/dist/assets/index-Bt9UXsGX.js +0 -222
  83. package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
  84. package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
  85. package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
@@ -0,0 +1,354 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createInterface } from 'node:readline';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import type { AgentId } from '../shared.ts';
8
+ import { Adapter, findExecutable, type Item, type Thread, type Turn } from './agents.ts';
9
+ import { imageType } from './attachments.ts';
10
+ import { VERSION } from './paths.ts';
11
+
12
+ interface Choice { value: string; name: string }
13
+ interface ConfigOption { id: string; category?: string; currentValue: string; options: (Choice | { options: Choice[] })[] }
14
+ interface Session { sessionId: string; configOptions?: ConfigOption[]; modes?: { availableModes: { id: string }[] }; models?: { currentModelId: string; availableModels: { modelId: string; name: string }[] } }
15
+ interface LiveTurn { turn: Turn; cwd: string; text: string; message: string; tools: Map<string, any>; received: boolean; stopped: boolean }
16
+ // Some agents report every tool as kind "other" (DeepSeek Harness), so shell
17
+ // commands and file edits are also recognized by the tool's name.
18
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'run_shell_command', 'execute_command', 'terminal']);
19
+ const EDIT_TOOLS = new Set(['write', 'edit', 'multiedit', 'strreplacefile', 'writefile', 'write_file', 'edit_file', 'replace', 'apply_patch']);
20
+ function toolKind(tool: any): string {
21
+ if (tool.kind && tool.kind !== 'other') return tool.kind;
22
+ const names = [tool.name, tool.title].filter(Boolean).map((name: string) => String(name).toLowerCase());
23
+ if (names.some(name => SHELL_TOOLS.has(name)) && tool.rawInput?.command) return 'execute';
24
+ if (names.some(name => EDIT_TOOLS.has(name))) return 'edit';
25
+ return tool.kind || 'other';
26
+ }
27
+ const choices = (option?: ConfigOption): Choice[] => (option?.options || []).flatMap(value => 'options' in value ? value.options : [value]);
28
+
29
+ // How one ACP agent is launched and kept on manual approvals. modes lists the
30
+ // session modes Papilot accepts, best first: a mode that edits the task
31
+ // worktree but asks before anything else. Papilot never inherits an
32
+ // auto-approve mode from the user's own configuration, so an agent that
33
+ // offers none of these modes is refused.
34
+ export interface AcpProfile {
35
+ id: AgentId; name: string; bin: string; binEnv: string; args: string[]; modes: string[]; setup: string;
36
+ env?: () => NodeJS.ProcessEnv;
37
+ }
38
+ export const ACP_PROFILES = {
39
+ kimi: { id: 'kimi', name: 'Kimi', bin: 'kimi', binEnv: 'PAPILOT_KIMI_BIN', args: ['acp'], modes: ['default'],
40
+ setup: 'Install Kimi Code CLI and run kimi login on this host, then select Reconnect. Set PAPILOT_KIMI_BIN if kimi is not on PATH.' },
41
+ qwen: { id: 'qwen', name: 'Qwen', bin: 'qwen', binEnv: 'PAPILOT_QWEN_BIN', args: ['--acp'], modes: ['auto-edit', 'default'],
42
+ setup: 'Install Qwen Code and sign in by running qwen once on this host, then select Reconnect. Set PAPILOT_QWEN_BIN if qwen is not on PATH.' },
43
+ // DeepSeek Harness has no session modes: its ACP profile sandboxes writes and
44
+ // commands to the session folder and asks before leaving it. Papilot pins
45
+ // that preset so a looser DSH_PERMISSION_MODE in the environment is not used.
46
+ deepseek: { id: 'deepseek', name: 'DeepSeek', bin: 'dsh', binEnv: 'PAPILOT_DSH_BIN', args: ['acp'], modes: [],
47
+ setup: 'Install DeepSeek Harness (npm install -g @deepseek-ai/dsh) and set up a model with dsh web or the dsh TUI, then select Reconnect. Set PAPILOT_DSH_BIN if dsh is not on PATH.',
48
+ env: () => ({ ...process.env, DSH_PERMISSION_MODE: 'workspace-write' }) },
49
+ // OpenCode runs tools without asking by default. Its highest-precedence
50
+ // config layer makes it ask before shell commands and anything outside the
51
+ // task worktree, while edits in the worktree go ahead for review.
52
+ opencode: { id: 'opencode', name: 'OpenCode', bin: 'opencode', binEnv: 'PAPILOT_OPENCODE_BIN', args: ['acp'], modes: ['build'],
53
+ setup: 'Install OpenCode and connect a provider with opencode auth login, then select Reconnect. Set PAPILOT_OPENCODE_BIN if opencode is not on PATH.',
54
+ env: () => ({ ...process.env, OPENCODE_CONFIG_CONTENT: opencodeConfig(process.env.OPENCODE_CONFIG_CONTENT) }) },
55
+ } satisfies Record<string, AcpProfile>;
56
+ export function opencodeConfig(own?: string) {
57
+ let config: Record<string, unknown> = {};
58
+ try { const parsed = own ? JSON.parse(own) : {}; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed; } catch { /* Replaced below. */ }
59
+ const permission = config.permission && typeof config.permission === 'object' ? config.permission : {};
60
+ return JSON.stringify({ ...config, permission: { ...permission, bash: 'ask', shell: 'ask', edit: 'allow', external_directory: 'ask' } });
61
+ }
62
+
63
+ // ACP sessions are the thread IDs persisted by Papilot. The agent owns
64
+ // credentials and conversation storage; the adapter only translates JSON-RPC
65
+ // over stdio.
66
+ export class AcpAgent extends Adapter {
67
+ private child: ChildProcess | null = null;
68
+ private starting: Promise<void> | null = null;
69
+ private sequence = 0;
70
+ private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout }>();
71
+ private approvals = new Map<string, { rpcId: string | number; turn: Turn; options: { optionId: string; kind: string; name?: string }[]; question: boolean }>();
72
+ private live = new Map<string, LiveTurn>();
73
+ private sessions = new Map<string, Session>();
74
+ private capabilities: any = {};
75
+ private catalog: any[] = [];
76
+ private defaultModel: string | null = null;
77
+ private readonly name: string;
78
+ private readonly binary: string;
79
+ constructor(private profile: AcpProfile, binary?: string, private launch: typeof spawn = spawn) {
80
+ super();
81
+ this.name = profile.name;
82
+ this.binary = binary || process.env[profile.binEnv] || profile.bin;
83
+ }
84
+ detect() { return Boolean(findExecutable(this.binary)); }
85
+
86
+ start(): Promise<void> {
87
+ if (this.ready) return Promise.resolve();
88
+ if (this.starting) return this.starting;
89
+ this.starting = this.connect().finally(() => { this.starting = null; });
90
+ return this.starting;
91
+ }
92
+ private async connect() {
93
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `papilot-${this.profile.id}-`));
94
+ try {
95
+ // The probe folder is removed below, so the agent itself runs from the home folder.
96
+ const child = this.launch(findExecutable(this.binary) || this.binary, this.profile.args, { cwd: os.homedir(), env: this.profile.env?.() || process.env, stdio: ['pipe', 'pipe', 'pipe'] });
97
+ this.child = child;
98
+ const failed = (error: Error) => { if (this.child === child) this.disconnect(error); };
99
+ createInterface({ input: child.stdout! }).on('line', line => {
100
+ let message: any;
101
+ try { message = JSON.parse(line); } catch { return; }
102
+ if (this.child === child) this.receive(message);
103
+ });
104
+ child.stderr?.resume();
105
+ child.stdin?.on('error', failed);
106
+ child.once('error', failed);
107
+ child.once('close', (code, signal) => failed(new Error(`${this.name} exited (${signal || code}). ${this.profile.setup}`)));
108
+ // The first start of some agents installs their plugins, so allow it a minute.
109
+ const init = await this.rpc('initialize', { protocolVersion: 1, clientInfo: { name: 'papilot', version: VERSION }, clientCapabilities: {} }, 60000);
110
+ if (init.protocolVersion !== 1) throw new Error(`${this.name} returned an unsupported ACP version. Update it.`);
111
+ this.capabilities = init.agentCapabilities || {};
112
+ // No generation: a disposable session checks configuration and exposes models.
113
+ const probe: Session = await this.rpc('session/new', { cwd, mcpServers: [] }, 60000);
114
+ this.catalog = [];
115
+ if (this.profile.modes.length) this.mode(probe);
116
+ this.readModels(probe, true);
117
+ const sessionCapabilities = this.capabilities.sessionCapabilities || {};
118
+ if (sessionCapabilities.delete) await this.rpc('session/delete', { sessionId: probe.sessionId });
119
+ else if (sessionCapabilities.close) await this.rpc('session/close', { sessionId: probe.sessionId });
120
+ this.ready = true;
121
+ } catch (error) {
122
+ this.close();
123
+ throw new Error(`${this.name} is unavailable: ${error instanceof Error ? error.message.replace(/\.$/, '') : String(error)}. ${this.profile.setup}`);
124
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
125
+ }
126
+ protected account() { return { account: this.ready ? { type: `Local ${this.name} configuration` } : null, requiresOpenaiAuth: !this.ready }; }
127
+ protected models() { return { data: this.catalog }; }
128
+
129
+ async request(method: string, params: any = {}): Promise<any> {
130
+ if (method === 'thread/start' || method === 'thread/resume') {
131
+ const resumed = method === 'thread/resume';
132
+ const session = await this.session(resumed ? String(params.threadId) : null, params.cwd, params.model);
133
+ const known = this.threads.get(session.sessionId);
134
+ this.threads.set(session.sessionId, { cwd: params.cwd, instructions: params.developerInstructions || '', model: params.model || known?.model || null, started: resumed });
135
+ return { thread: { id: session.sessionId }, model: params.model || this.defaultModel };
136
+ }
137
+ return super.request(method, params);
138
+ }
139
+ private async session(id: string | null, cwd: string, model?: string | null): Promise<Session> {
140
+ let session = id ? this.sessions.get(id) : undefined;
141
+ if (!session) {
142
+ if (id && !this.capabilities.loadSession && !this.capabilities.sessionCapabilities?.resume) throw new Error(`This ${this.name} version cannot resume sessions. Update it.`);
143
+ const result = await this.rpc(id ? this.capabilities.sessionCapabilities?.resume ? 'session/resume' : 'session/load' : 'session/new', { ...(id ? { sessionId: id } : {}), cwd, mcpServers: [] });
144
+ session = { ...result, sessionId: id || result.sessionId };
145
+ if (!session!.sessionId) throw new Error(`${this.name} did not return a session ID.`);
146
+ // Do not inherit an auto-approve mode from the user's global configuration.
147
+ // Agents list modes as session modes or as a mode configuration option.
148
+ if (this.profile.modes.length) {
149
+ const { mode, option } = this.mode(session!);
150
+ if (session!.modes) await this.rpc('session/set_mode', { sessionId: session!.sessionId, modeId: mode });
151
+ else if (option!.currentValue !== mode) {
152
+ const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option!.id, value: mode });
153
+ if (result?.configOptions) session!.configOptions = result.configOptions;
154
+ }
155
+ }
156
+ this.sessions.set(session!.sessionId, session!);
157
+ }
158
+ if (model) {
159
+ const option = session!.configOptions?.find(option => option.category === 'model' || option.id === 'model');
160
+ if (option) {
161
+ const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option.id, value: model });
162
+ if (result.configOptions) session!.configOptions = result.configOptions;
163
+ } else await this.rpc('session/set_model', { sessionId: session!.sessionId, modelId: model });
164
+ }
165
+ this.readModels(session!);
166
+ return session!;
167
+ }
168
+ // The accepted mode a session offers, as a session mode or a mode option.
169
+ private mode(session: Session) {
170
+ const option = session.configOptions?.find(option => option.category === 'mode' || option.id === 'mode');
171
+ const offered = session.modes ? session.modes.availableModes.map(mode => mode.id) : choices(option).map(choice => choice.value);
172
+ const mode = this.profile.modes.find(id => offered.includes(id));
173
+ if (!mode) throw new Error(`${this.name} does not offer manual approvals. Update it.`);
174
+ return { mode, option };
175
+ }
176
+ private readModels(session: Session, initial = false) {
177
+ const model = session.configOptions?.find(option => option.category === 'model' || option.id === 'model');
178
+ const thinking = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
179
+ // A "default" level is the model default Papilot already offers.
180
+ const levels = choices(thinking).filter(choice => choice.value !== 'default'), level = thinking?.currentValue === 'default' ? null : thinking?.currentValue || null;
181
+ const current = model?.currentValue || session.models?.currentModelId || null;
182
+ if (initial) this.defaultModel = current;
183
+ const available = model ? choices(model) : (session.models?.availableModels || []).map(model => ({ value: model.modelId, name: model.name }));
184
+ for (const option of available) {
185
+ const previous = this.catalog.find(entry => entry.model === option.value);
186
+ const entry = { model: option.value, displayName: option.name, isDefault: option.value === this.defaultModel,
187
+ supportedReasoningEfforts: option.value === current ? levels.map(choice => ({ reasoningEffort: choice.value })) : previous?.supportedReasoningEfforts || [],
188
+ defaultReasoningEffort: option.value === current ? level : previous?.defaultReasoningEffort || null };
189
+ if (previous) Object.assign(previous, entry); else this.catalog.push(entry);
190
+ }
191
+ }
192
+ protected run(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) { void this.execute(turn, thread, text, effort, images); }
193
+ private async execute(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) {
194
+ let stopped = false, prompting = false;
195
+ let cancelTimer: NodeJS.Timeout | undefined;
196
+ const live: LiveTurn = { turn, cwd: thread.cwd, text: '', message: randomUUID(), tools: new Map(), received: false, stopped: false };
197
+ turn.stop = () => {
198
+ stopped = true; live.stopped = true;
199
+ this.clearApprovals(turn);
200
+ if (prompting) {
201
+ this.send({ method: 'session/cancel', params: { sessionId: turn.threadId } });
202
+ // A broken server must not leave paused tasks waiting forever.
203
+ cancelTimer ??= setTimeout(() => this.disconnect(new Error(`${this.name} did not acknowledge cancellation. Reconnect to resume.`)), 5000);
204
+ cancelTimer.unref();
205
+ }
206
+ };
207
+ try {
208
+ const session = await this.session(turn.threadId, thread.cwd, thread.model);
209
+ if (effort) {
210
+ const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
211
+ if (!option || !choices(option).some(choice => choice.value === effort)) throw new Error(`${this.name} does not support reasoning effort ${effort} for this model.`);
212
+ await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: effort });
213
+ }
214
+ const prompt: any[] = [{ type: 'text', text: thread.started ? text : `${thread.instructions}\n\n---\n\n${text}` }];
215
+ if (images.length && !this.capabilities.promptCapabilities?.image) throw new Error(`This ${this.name} version does not support image input. Update it, or send the images to another agent.`);
216
+ for (const file of images) {
217
+ const bytes = await fs.promises.readFile(file), mimeType = imageType(bytes);
218
+ if (!mimeType) throw new Error('This image format is not supported.');
219
+ prompt.push({ type: 'image', data: bytes.toString('base64'), mimeType });
220
+ }
221
+ if (stopped) return this.finish(turn, 'interrupted');
222
+ this.live.set(turn.threadId, live);
223
+ prompting = true;
224
+ // The response arrives at the end of the turn; user approvals can take any amount of time.
225
+ const result = await this.rpc('session/prompt', { sessionId: turn.threadId, prompt }, 0);
226
+ this.threads.set(turn.threadId, { ...thread, started: true });
227
+ this.flush(live);
228
+ if (stopped || result.stopReason === 'cancelled') this.finish(turn, 'interrupted');
229
+ else if (result.stopReason === 'end_turn' && live.received) this.finish(turn, 'completed');
230
+ else if (result.stopReason === 'end_turn') this.finish(turn, 'failed', `${this.name} returned no response. Check its login and model configuration, then retry.`);
231
+ else this.finish(turn, 'failed', `${this.name} stopped: ${result.stopReason || 'no stop reason returned'}.`);
232
+ } catch (error) {
233
+ this.flush(live);
234
+ this.finish(turn, stopped ? 'interrupted' : 'failed', stopped ? undefined : error instanceof Error ? error.message : String(error));
235
+ } finally {
236
+ if (cancelTimer) clearTimeout(cancelTimer);
237
+ this.clearApprovals(turn);
238
+ this.live.delete(turn.threadId);
239
+ }
240
+ }
241
+ private flush(live: LiveTurn) {
242
+ if (live.text.trim()) this.item(live.turn, { id: live.message, type: 'agentMessage', text: live.text });
243
+ live.text = ''; live.message = randomUUID();
244
+ }
245
+ private update(params: any) {
246
+ const live = this.live.get(params.sessionId), update = params.update;
247
+ if (!live || !update) return; // Includes history replay while loading a session.
248
+ if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
249
+ const text = String(update.content.text || ''), shown = live.text.trim();
250
+ live.text += text;
251
+ // Empty and whitespace-only chunks (Qwen sends them) would show as empty
252
+ // messages, so text is streamed from its first visible character.
253
+ if (!live.text.trim()) return;
254
+ live.received = true;
255
+ this.notify('item/agentMessage/delta', { threadId: live.turn.threadId, turnId: live.turn.id, itemId: live.message, delta: shown ? text : live.text });
256
+ } else if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
257
+ live.received = true;
258
+ this.flush(live);
259
+ const tool = { ...live.tools.get(update.toolCallId), ...update };
260
+ live.tools.set(update.toolCallId, tool);
261
+ const kind = toolKind(tool), named = tool.rawInput?.file_path || tool.rawInput?.path;
262
+ const paths = (tool.locations || []).map((location: any) => location.path).concat((tool.content || []).filter((part: any) => part.type === 'diff').map((part: any) => part.path))
263
+ .concat(kind === 'edit' && typeof named === 'string' ? [path.resolve(live.cwd, named)] : []);
264
+ const id = `${live.turn.id}:${tool.toolCallId}`;
265
+ const status = tool.status === 'failed' ? 'failed' : tool.status === 'completed' ? 'completed' : 'inProgress';
266
+ const output = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').join('\n');
267
+ const item: Item = kind === 'execute' ? { id, type: 'commandExecution', command: tool.rawInput?.command || tool.title || `${this.name} command`, aggregatedOutput: output.slice(-40000), status }
268
+ : kind === 'edit' && paths.length ? { id, type: 'fileChange', changes: [...new Set(paths)].map(file => ({ path: file })), status }
269
+ : { id, type: 'toolCall', text: tool.title || `${this.name} tool`, status };
270
+ this.item(live.turn, item, status !== 'inProgress');
271
+ }
272
+ }
273
+ private receive(message: any) {
274
+ if (!message.method) {
275
+ const wait = this.pending.get(message.id);
276
+ if (!wait) return;
277
+ if (wait.timer) clearTimeout(wait.timer);
278
+ this.pending.delete(message.id);
279
+ if (message.error) wait.reject(new Error(message.error.message || `${this.name} request failed.`));
280
+ else wait.resolve(message.result);
281
+ return;
282
+ }
283
+ if (message.method === 'session/update') { this.update(message.params); return; }
284
+ if (message.id === undefined) return;
285
+ const live = this.live.get(message.params?.sessionId);
286
+ if (message.method !== 'session/request_permission' || !live || live.turn.status !== 'inProgress') {
287
+ this.send({ id: message.id, error: { code: -32601, message: 'This client does not support this request, or the turn has ended.' } }); return;
288
+ }
289
+ if (live.stopped) { this.send({ id: message.id, result: { outcome: { outcome: 'cancelled' } } }); return; }
290
+ const id = randomUUID(), update = message.params.toolCall || {};
291
+ const tool = { ...live.tools.get(update.toolCallId), ...update };
292
+ const file = tool.locations?.[0]?.path || tool.rawInput?.file_path || tool.rawInput?.path;
293
+ const edit = toolKind(tool) === 'edit';
294
+ const options = message.params.options || [];
295
+ const question = options.filter((option: any) => option.kind === 'allow_once').length > 1;
296
+ this.approvals.set(id, { rpcId: message.id, turn: live.turn, options, question });
297
+ if (question) {
298
+ // A question arrives as a permission request with several one-time
299
+ // choices (Kimi's AskUserQuestion): the question itself is the tool's
300
+ // text, and a one-time rejection is its Skip choice. An ACP permission
301
+ // answer can only select an option, never carry free text.
302
+ const asked = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').filter(Boolean).join('\n');
303
+ this.emit('request', { id, method: 'item/tool/requestUserInput', params: { threadId: live.turn.threadId, turnId: live.turn.id,
304
+ questions: [{ id: 'choice', question: asked || tool.title || `${this.name} needs your input.`, isOther: false, options: options.filter((option: any) => option.kind === 'allow_once' || option.kind === 'reject_once').map((option: any) => ({ label: option.name || option.optionId })) }] } });
305
+ return;
306
+ }
307
+ this.emit('request', { id, method: edit ? 'item/fileChange/requestApproval' : 'item/commandExecution/requestApproval',
308
+ params: { threadId: live.turn.threadId, turnId: live.turn.id, reason: tool.title || `${this.name} requests permission.`, command: tool.rawInput?.command || (edit && file ? `Edit ${file}` : tool.title || `${this.name} tool`) } });
309
+ }
310
+ respond(id: string | number, result: any) {
311
+ const wait = this.approvals.get(String(id));
312
+ if (!wait) throw new Error('This request has ended.');
313
+ const kind = result?.decision === 'accept' ? 'allow_once' : result?.decision === 'decline' ? 'reject_once' : null;
314
+ const answer = result?.answers?.choice?.answers?.[0];
315
+ const option = wait.question
316
+ ? wait.options.find(option => (option.kind === 'allow_once' || option.kind === 'reject_once') && (option.name || option.optionId) === answer)
317
+ : wait.options.find(option => option.kind === kind);
318
+ this.approvals.delete(String(id));
319
+ this.send({ id: wait.rpcId, result: { outcome: option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' } } });
320
+ }
321
+ reject(id: string | number, _message: string) { if (this.approvals.has(String(id))) this.respond(id, { decision: 'decline' }); }
322
+ private clearApprovals(turn: Turn) {
323
+ for (const [id, wait] of this.approvals) if (wait.turn === turn) {
324
+ this.respond(id, { decision: 'cancel' });
325
+ this.notify('serverRequest/resolved', { threadId: turn.threadId, requestId: id });
326
+ }
327
+ }
328
+ private send(message: Record<string, unknown>) {
329
+ const child = this.child;
330
+ child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n', error => { if (error && this.child === child) this.disconnect(error); });
331
+ }
332
+ private rpc(method: string, params: unknown, timeout = 20000): Promise<any> {
333
+ if (!this.child) return Promise.reject(new Error(`${this.name} is not connected.`));
334
+ const id = ++this.sequence;
335
+ return new Promise((resolve, reject) => {
336
+ const timer = timeout ? setTimeout(() => { this.pending.delete(id); reject(new Error(`${this.name} request timed out: ${method}.`)); }, timeout) : undefined;
337
+ this.pending.set(id, { resolve, reject, timer });
338
+ this.send({ id, method, params });
339
+ });
340
+ }
341
+ private disconnect(error: Error, notify = true) {
342
+ const child = this.child;
343
+ this.child = null; this.ready = false; this.sessions.clear();
344
+ for (const wait of this.pending.values()) { if (wait.timer) clearTimeout(wait.timer); wait.reject(error); }
345
+ this.pending.clear(); this.approvals.clear();
346
+ child?.stdin?.end(); child?.kill('SIGTERM');
347
+ if (child && notify) this.emit('disconnected', error);
348
+ }
349
+ close() { this.disconnect(new Error(`${this.name} disconnected.`), false); }
350
+ }
351
+
352
+ export class KimiAgent extends AcpAgent {
353
+ constructor(binary?: string, launch?: typeof spawn) { super(ACP_PROFILES.kimi, binary, launch); }
354
+ }