papilot 0.7.0 → 0.8.1

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 (75) hide show
  1. package/README.md +53 -100
  2. package/README.zh-CN.md +52 -97
  3. package/bin/doctor.mjs +55 -6
  4. package/bin/installation.d.mts +2 -0
  5. package/bin/installation.mjs +7 -1
  6. package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-CMfCtEYQ.js} +54 -3
  7. package/dist/assets/index-BlLyIqT9.js +265 -0
  8. package/dist/assets/index-DVZtyZvu.css +1 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
  11. package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/preview/main.pdf +0 -0
  15. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  16. package/examples/papilot-paper/sections/02-using.tex +3 -1
  17. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  18. package/package.json +18 -8
  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/server/acp.ts +387 -0
  23. package/src/server/agents.ts +220 -43
  24. package/src/server/ai-review-routes.ts +72 -0
  25. package/src/server/ai-review.ts +38 -0
  26. package/src/server/app.ts +56 -14
  27. package/src/server/build-problems.ts +329 -0
  28. package/src/server/changes.ts +25 -2
  29. package/src/server/codex.ts +2 -1
  30. package/src/server/cursor.ts +153 -0
  31. package/src/server/desktop-demo.ts +38 -0
  32. package/src/server/latex-installation.ts +43 -0
  33. package/src/server/manuscript.ts +183 -23
  34. package/src/server/paths.ts +1 -1
  35. package/src/server/reconcile.ts +20 -0
  36. package/src/server/references.ts +70 -0
  37. package/src/server/review.ts +6 -5
  38. package/src/server/skills.ts +5 -8
  39. package/src/server/start.ts +19 -4
  40. package/src/server/store.ts +20 -2
  41. package/src/server/update-install.ts +53 -2
  42. package/src/server/update-worker.ts +6 -6
  43. package/src/server/updates.ts +49 -14
  44. package/src/server/workspace.ts +236 -41
  45. package/src/shared.ts +88 -9
  46. package/src/skill-icons.ts +18 -0
  47. package/src/skill-metadata.ts +45 -0
  48. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +19 -0
  49. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  50. package/vendor/latex-review/dist/web/index.html +2 -2
  51. package/vendor/latex-review/src/core/bib.ts +692 -0
  52. package/vendor/latex-review/src/core/changes.ts +39 -0
  53. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  54. package/vendor/latex-review/src/core/delivery.ts +62 -16
  55. package/vendor/latex-review/src/core/git.ts +2 -1
  56. package/vendor/latex-review/src/core/publish.ts +7 -1
  57. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  58. package/vendor/latex-review/src/core/review.ts +6 -2
  59. package/vendor/latex-review/src/core/store.ts +1 -0
  60. package/vendor/latex-review/src/core/tasks.ts +1 -1
  61. package/vendor/latex-review/src/core/verify.ts +784 -0
  62. package/vendor/latex-review/src/core/view.ts +204 -0
  63. package/vendor/latex-review/src/render/round.ts +11 -0
  64. package/vendor/latex-review/src/server.ts +77 -1
  65. package/vendor/latex-review/src/types.ts +130 -2
  66. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  67. package/vendor/latex-review/src/web/references.tsx +499 -0
  68. package/vendor/latex-review/src/web/review.tsx +263 -133
  69. package/vendor/latex-review/src/web/style.css +285 -0
  70. package/vendor/latex-review/src/web/zh-CN.ts +79 -2
  71. package/dist/assets/index-CIJEYlP7.js +0 -252
  72. package/dist/assets/index-hOJEruFc.css +0 -1
  73. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  74. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  75. package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
package/dist/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-CIJEYlP7.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-hOJEruFc.css">
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BlLyIqT9.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DVZtyZvu.css">
13
13
  </head><body><div id="root"></div></body></html>
@@ -12,7 +12,7 @@
12
12
  Papilot is a personal workspace for revising \LaTeX{} papers with an AI agent
13
13
  and reviewing every resulting change as a PDF difference before it reaches the
14
14
  manuscript. Each editing task runs in an isolated Git worktree, may be driven
15
- by Codex, Claude or Antigravity, and proceeds in review rounds in which the
15
+ by Codex, Claude, Antigravity or Kimi Code, and proceeds in review rounds in which the
16
16
  author accepts, rejects or comments on individual changes. This paper describes
17
17
  the system through a sequence of small formal models. We define edits and
18
18
  review rounds as operators on a document, prove that compatible edits commute
@@ -91,7 +91,8 @@ remote (\cref{sec:git}).
91
91
  \subsection{Agents}
92
92
 
93
93
  Each task runs on one agent: Codex through its app server, Claude through the
94
- Claude Agent SDK, or Antigravity through its command line. Every agent uses its
94
+ Claude Agent SDK, Antigravity through its command line, or Kimi Code through
95
+ ACP. For Kimi, install the CLI and run \texttt{kimi login}. Every agent uses its
95
96
  own login on the host, and an agent that is not installed or signed in is listed
96
97
  as unavailable. When an agent reports usage, the new-task page shows each usage
97
98
  window with how much is used and when it resets (\cref{sec:usage}).
@@ -124,6 +125,7 @@ Variable & Purpose \\
124
125
  \texttt{PAPILOT\_DATA} & state and generated example, \texttt{\textasciitilde/.papilot} by default \\
125
126
  \texttt{PAPILOT\_CODEX\_BIN} & Codex executable \\
126
127
  \texttt{ANTHROPIC\_API\_KEY} & API key for Claude tasks \\
128
+ \texttt{PAPILOT\_KIMI\_BIN} & Kimi Code executable \\
127
129
  \texttt{PAPILOT\_AGY\_BIN} & Antigravity command line \\
128
130
  \texttt{PAPILOT\_AGY\_PROXY} & HTTP proxy used only for Antigravity \\
129
131
  \texttt{PAPILOT\_REVIEW\_ENGINE} & review engine directory \\
@@ -1,7 +1,7 @@
1
1
  \section{Agents and scheduling}
2
2
  \label{sec:agents}
3
3
 
4
- \subsection{One protocol for three agents}
4
+ \subsection{One protocol for every agent}
5
5
 
6
6
  Every agent is driven through the same small protocol: a thread is started or
7
7
  resumed, a turn is started with the author's text, the agent streams items
@@ -40,8 +40,9 @@ task in a recoverable state with a \emph{Check status} action (not shown).}
40
40
  \end{figure}
41
41
 
42
42
  Codex speaks this protocol natively. Claude runs through the Claude Agent SDK,
43
- and Antigravity runs the \texttt{agy} command line once per turn; both are
44
- translated by small adapters into the same requests and notifications. Each
43
+ Antigravity runs the \texttt{agy} command line once per turn, and Kimi Code
44
+ runs \texttt{kimi acp}; adapters translate their interfaces into the same
45
+ requests and notifications. Kimi sessions resume after a restart. Each
45
46
  agent uses its own login on the host, and Papilot never reads, stores or
46
47
  forwards a credential. An agent that cannot ask for permission, such as a
47
48
  headless command line, is told in advance which tools it may not use.
@@ -102,5 +103,7 @@ answers after a mean delay $\Theta$, the expected wall-clock time of the turn is
102
103
  \E[T] = T_w \bigl(1 + \zeta \Theta\bigr),
103
104
  \label{eq:approvals}
104
105
  \end{equation}
105
- which is why Papilot lets agents edit and run sandboxed commands in the task's
106
- worktree without asking, and asks only for what leaves it.
106
+ which motivates reducing unnecessary approval requests. Codex and Claude can
107
+ run sandboxed commands under their permission policies; Kimi uses manual
108
+ approvals for tools on the host, without a Papilot sandbox. Antigravity edits
109
+ files but cannot run shell commands in Papilot.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -12,17 +12,19 @@
12
12
  "codex",
13
13
  "claude",
14
14
  "antigravity",
15
+ "kimi",
16
+ "kimi-code",
15
17
  "writing",
16
18
  "academic",
17
19
  "manuscript"
18
20
  ],
19
- "homepage": "https://github.com/cu1ch3n/papilot#readme",
21
+ "homepage": "https://papilot.ai",
20
22
  "repository": {
21
23
  "type": "git",
22
- "url": "git+https://github.com/cu1ch3n/papilot.git"
24
+ "url": "git+https://github.com/PapilotAI/papilot.git"
23
25
  },
24
26
  "bugs": {
25
- "url": "https://github.com/cu1ch3n/papilot/issues"
27
+ "url": "https://github.com/PapilotAI/papilot/issues"
26
28
  },
27
29
  "license": "MIT",
28
30
  "author": "Cui Chen",
@@ -34,6 +36,8 @@
34
36
  "bin",
35
37
  "src/server",
36
38
  "src/shared.ts",
39
+ "src/skill-icons.ts",
40
+ "src/skill-metadata.ts",
37
41
  "src/notes.ts",
38
42
  "src/note-groups.ts",
39
43
  "src/conversation.ts",
@@ -57,17 +61,21 @@
57
61
  "dev": "tsx watch src/server/main.ts",
58
62
  "start": "tsx src/server/main.ts",
59
63
  "build": "tsc --noEmit && vite build",
60
- "typecheck": "tsc --noEmit",
64
+ "typecheck": "tsc --noEmit && tsc -p desktop/tsconfig.json --noEmit",
61
65
  "test": "tsx --test tests/*.test.ts",
62
66
  "setup:review": "npm --prefix vendor/latex-review ci && npm --prefix vendor/latex-review run build",
63
67
  "demo": "tsx scripts/demo.ts",
64
68
  "doctor": "node bin/papilot.mjs doctor",
65
69
  "service:install": "tsx scripts/service.ts install",
66
70
  "service:stop": "tsx scripts/service.ts stop",
67
- "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
71
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
68
72
  "site": "node scripts/build-site.mjs",
69
- "site:video": "node scripts/build-site.mjs --video",
70
- "prepack": "npm run build && npm run setup:review"
73
+ "test:site": "npm run site && node scripts/check-site.mjs",
74
+ "prepack": "npm run build && npm run setup:review",
75
+ "desktop:compile": "tsc -p desktop/tsconfig.json",
76
+ "desktop": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs dev",
77
+ "desktop:build": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs package",
78
+ "test:desktop": "node scripts/check-desktop.mjs"
71
79
  },
72
80
  "dependencies": {
73
81
  "@fastify/compress": "^9.2.2",
@@ -90,6 +98,8 @@
90
98
  "@types/react-dom": "^19.3.0",
91
99
  "@types/yauzl": "^3.4.0",
92
100
  "@vitejs/plugin-react": "^6.1.2",
101
+ "electron": "45.2.1",
102
+ "electron-builder": "26.18.0",
93
103
  "katex": "^0.16.47",
94
104
  "pdfjs-dist": "^6.4.299",
95
105
  "react": "^19.3.1",
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: AI review
3
+ description: Read the paper and leave PDF comments without editing the manuscript
4
+ hint: What should the reviewer focus on? Leave empty for the whole paper
5
+ order: 8
6
+ output: annotations
7
+ ---
8
+ Review the paper as a constructive academic reviewer. Read it and leave suggestions; do not edit, rewrite, commit, or apply changes to the manuscript.
9
+
10
+ - Follow the author's requested focus, or review the whole paper when none is given.
11
+ - Look for unsupported claims, gaps in reasoning, unclear definitions, inconsistent terminology, and concrete writing issues.
12
+ - Anchor each comment to the smallest relevant passage in the supplied PDF: the specific sentence, claim, or equation at issue, usually 1–3 lines. Avoid whole-paragraph or page-level highlights. If the issue concerns a symbol within a line, name that symbol in the comment.
13
+ - Write one issue per comment, usually in one short sentence: aim for 10–30 English words or 20–60 Chinese characters. State the problem and a concrete next step; add a second short sentence only when needed for accuracy. Example: “This bound is undefined at p = 1; state that case separately.”
14
+ - Omit praise, preambles, repeated quotations, background explanations, full derivations, and rewritten paragraphs. For a complex issue, identify the failing step and needed clarification; leave extended reasoning for follow-up.
15
+ - Prefer a few high-impact, actionable comments to minor or speculative criticism. Flag a repeated issue once at a representative location. Do not invent sources, results, or assumptions, or claim a citation is wrong without evidence.
16
+ - Use the author's requested language, otherwise the manuscript's language.
17
+ - The supplied PDF extraction contains text, equations, and captions, not figure images. Do not claim to have inspected page appearance.
18
+ - Return PDF annotations in the format Papilot supplies, with a single brief sentence summarizing the main priority. Do not repeat the findings in the summary or add a review essay. The author decides which suggestions to use in a separate editing task.
@@ -6,7 +6,7 @@ order: 6
6
6
  ---
7
7
  Fix the problems LaTeX reports when it builds the paper.
8
8
 
9
- - Compile in your worktree (for example with `latexmk`) and read the log. If you cannot run commands, look for the same problems in the source instead.
9
+ - When the request lists problems from the last compilation, with their files and lines, fix those. Otherwise compile in your worktree (for example with `latexmk`) and read the log. If you cannot run commands, look for the same problems in the source instead.
10
10
  - Fix undefined or duplicate labels, references and citations; overfull boxes, by rewording slightly or allowing a break where the line overflows; and package warnings that point to real mistakes.
11
11
  - Do not silence warnings globally: no document-wide `\sloppy`, badness or tolerance changes, and no new packages, unless the request asks for them.
12
12
  - In your summary, list the warnings you fixed and any you left, with the reason.
@@ -6,6 +6,12 @@ export const reviewInstructions = 'Continue this task using the decisions and co
6
6
  // Earlier versions stored the prompt itself as the author's message.
7
7
  export const isFeedbackPrompt = (text: string) => text === reviewInstructions || text === 'Address the comments from this review round. Preserve accepted changes and do not reintroduce rejected edits.';
8
8
 
9
+ // Older AI reviews appended a generated copy of every PDF suggestion to the
10
+ // chat summary. Keep those records intact, but display only their summary.
11
+ export function aiReviewSummary(text: string) {
12
+ return text.split(/\n\n1\. \*\*Page \d+\*\* — /, 1)[0].trimEnd();
13
+ }
14
+
9
15
  // What the agent receives when its own edits stop the review build. The chat
10
16
  // shows the error, never this prompt.
11
17
  export function buildFixPrompt(failure: BuildFailure) {
@@ -0,0 +1,387 @@
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
+ config?: Record<string, string>;
38
+ authMethods?: string[];
39
+ sessionMeta?: Record<string, unknown>;
40
+ }
41
+ export const ACP_PROFILES = {
42
+ // Grok owns authentication and tools. A dedicated process and explicit
43
+ // session metadata keep new and restored sessions out of yolo/auto mode.
44
+ grok: { id: 'grok', name: 'Grok', bin: 'grok', binEnv: 'PAPILOT_GROK_BIN', args: ['--no-auto-update', 'agent', '--no-leader', 'stdio'],
45
+ modes: [], authMethods: ['xai.api_key', 'cached_token'], sessionMeta: { yoloMode: false, autoMode: false },
46
+ setup: 'Install Grok Build CLI from https://x.ai/cli and run grok login on this host, or configure XAI_API_KEY, then select Reconnect. Set PAPILOT_GROK_BIN if grok is not on PATH.' },
47
+ // Copilot's agent mode and permission mode are separate: leaving autopilot
48
+ // does not disable allow-all. Pin both, including when resuming a session.
49
+ copilot: { id: 'copilot', name: 'Copilot', bin: 'copilot', binEnv: 'PAPILOT_COPILOT_BIN', args: ['--acp', '--stdio', '--no-auto-update'],
50
+ modes: ['https://agentclientprotocol.com/protocol/session-modes#agent'], config: { allow_all: 'off' },
51
+ setup: 'Install GitHub Copilot CLI (npm install -g @github/copilot) and run copilot login on this host, then select Reconnect. Set PAPILOT_COPILOT_BIN if copilot is not on PATH.',
52
+ env: () => ({ ...process.env, COPILOT_ALLOW_ALL: 'false', COPILOT_ASSISTED_APPROVAL: 'false' }) },
53
+ kimi: { id: 'kimi', name: 'Kimi', bin: 'kimi', binEnv: 'PAPILOT_KIMI_BIN', args: ['acp'], modes: ['default'],
54
+ 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.' },
55
+ qwen: { id: 'qwen', name: 'Qwen', bin: 'qwen', binEnv: 'PAPILOT_QWEN_BIN', args: ['--acp'], modes: ['auto-edit', 'default'],
56
+ 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.' },
57
+ // DeepSeek Harness has no session modes: its ACP profile sandboxes writes and
58
+ // commands to the session folder and asks before leaving it. Papilot pins
59
+ // that preset so a looser DSH_PERMISSION_MODE in the environment is not used.
60
+ deepseek: { id: 'deepseek', name: 'DeepSeek', bin: 'dsh', binEnv: 'PAPILOT_DSH_BIN', args: ['acp'], modes: [],
61
+ 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.',
62
+ env: () => ({ ...process.env, DSH_PERMISSION_MODE: 'workspace-write' }) },
63
+ // OpenCode runs tools without asking by default. Its highest-precedence
64
+ // config layer makes it ask before shell commands and anything outside the
65
+ // task worktree, while edits in the worktree go ahead for review.
66
+ opencode: { id: 'opencode', name: 'OpenCode', bin: 'opencode', binEnv: 'PAPILOT_OPENCODE_BIN', args: ['acp'], modes: ['build'],
67
+ setup: 'Install OpenCode and connect a provider with opencode auth login, then select Reconnect. Set PAPILOT_OPENCODE_BIN if opencode is not on PATH.',
68
+ env: () => ({ ...process.env, OPENCODE_CONFIG_CONTENT: opencodeConfig(process.env.OPENCODE_CONFIG_CONTENT) }) },
69
+ } satisfies Record<string, AcpProfile>;
70
+ export function opencodeConfig(own?: string) {
71
+ let config: Record<string, unknown> = {};
72
+ try { const parsed = own ? JSON.parse(own) : {}; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed; } catch { /* Replaced below. */ }
73
+ const permission = config.permission && typeof config.permission === 'object' ? config.permission : {};
74
+ return JSON.stringify({ ...config, permission: { ...permission, bash: 'ask', shell: 'ask', edit: 'allow', external_directory: 'ask' } });
75
+ }
76
+
77
+ // ACP sessions are the thread IDs persisted by Papilot. The agent owns
78
+ // credentials and conversation storage; the adapter only translates JSON-RPC
79
+ // over stdio.
80
+ export class AcpAgent extends Adapter {
81
+ private child: ChildProcess | null = null;
82
+ private starting: Promise<void> | null = null;
83
+ private sequence = 0;
84
+ private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout }>();
85
+ private approvals = new Map<string, { rpcId: string | number; turn: Turn; options: { optionId: string; kind: string; name?: string }[]; question: boolean }>();
86
+ private live = new Map<string, LiveTurn>();
87
+ private sessions = new Map<string, Session>();
88
+ private capabilities: any = {};
89
+ private catalog: any[] = [];
90
+ private defaultModel: string | null = null;
91
+ private readonly name: string;
92
+ private readonly binary: string;
93
+ constructor(private profile: AcpProfile, binary?: string, private launch: typeof spawn = spawn) {
94
+ super();
95
+ this.name = profile.name;
96
+ this.binary = binary || process.env[profile.binEnv] || profile.bin;
97
+ }
98
+ detect() { return Boolean(findExecutable(this.binary)); }
99
+
100
+ start(): Promise<void> {
101
+ if (this.ready) return Promise.resolve();
102
+ if (this.starting) return this.starting;
103
+ this.starting = this.connect().finally(() => { this.starting = null; });
104
+ return this.starting;
105
+ }
106
+ private async connect() {
107
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `papilot-${this.profile.id}-`));
108
+ try {
109
+ // The probe folder is removed below, so the agent itself runs from the home folder.
110
+ 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'] });
111
+ this.child = child;
112
+ const failed = (error: Error) => { if (this.child === child) this.disconnect(error); };
113
+ createInterface({ input: child.stdout! }).on('line', line => {
114
+ let message: any;
115
+ try { message = JSON.parse(line); } catch { return; }
116
+ if (this.child === child) this.receive(message);
117
+ });
118
+ child.stderr?.resume();
119
+ child.stdin?.on('error', failed);
120
+ child.once('error', failed);
121
+ child.once('close', (code, signal) => failed(new Error(`${this.name} exited (${signal || code}). ${this.profile.setup}`)));
122
+ // The first start of some agents installs their plugins, so allow it a minute.
123
+ const init = await this.rpc('initialize', { protocolVersion: 1, clientInfo: { name: 'papilot', version: VERSION }, clientCapabilities: {} }, 60000);
124
+ if (init.protocolVersion !== 1) throw new Error(`${this.name} returned an unsupported ACP version. Update it.`);
125
+ this.capabilities = init.agentCapabilities || {};
126
+ if (this.profile.authMethods) {
127
+ const methodId = this.profile.authMethods.find(id => init.authMethods?.some((method: { id: string }) => method.id === id));
128
+ if (!methodId) throw new Error(`${this.name} has no saved login or configured API credentials.`);
129
+ await this.rpc('authenticate', { methodId, _meta: { headless: true } }, 60000);
130
+ }
131
+ // No generation: a disposable session checks configuration and exposes models.
132
+ const probe: Session = await this.rpc('session/new', { cwd, mcpServers: [], ...(this.profile.sessionMeta ? { _meta: this.profile.sessionMeta } : {}) }, 60000);
133
+ this.catalog = [];
134
+ if (this.profile.modes.length) this.mode(probe);
135
+ this.config(probe);
136
+ this.readModels(probe, true);
137
+ const sessionCapabilities = this.capabilities.sessionCapabilities || {};
138
+ if (sessionCapabilities.delete) await this.rpc('session/delete', { sessionId: probe.sessionId });
139
+ else if (sessionCapabilities.close) await this.rpc('session/close', { sessionId: probe.sessionId });
140
+ this.ready = true;
141
+ } catch (error) {
142
+ this.close();
143
+ throw new Error(`${this.name} is unavailable: ${error instanceof Error ? error.message.replace(/\.$/, '') : String(error)}. ${this.profile.setup}`);
144
+ } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
145
+ }
146
+ protected account() { return { account: this.ready ? { type: `Local ${this.name} configuration` } : null, requiresOpenaiAuth: !this.ready }; }
147
+ protected models() { return { data: this.catalog }; }
148
+
149
+ async request(method: string, params: any = {}): Promise<any> {
150
+ if (method === 'thread/start' || method === 'thread/resume') {
151
+ const resumed = method === 'thread/resume';
152
+ const session = await this.session(resumed ? String(params.threadId) : null, params.cwd, params.model);
153
+ const known = this.threads.get(session.sessionId);
154
+ this.threads.set(session.sessionId, { cwd: params.cwd, instructions: params.developerInstructions || '', model: params.model || known?.model || null, started: resumed });
155
+ return { thread: { id: session.sessionId }, model: params.model || this.defaultModel };
156
+ }
157
+ return super.request(method, params);
158
+ }
159
+ private async session(id: string | null, cwd: string, model?: string | null): Promise<Session> {
160
+ let session = id ? this.sessions.get(id) : undefined;
161
+ if (!session) {
162
+ if (id && !this.capabilities.loadSession && !this.capabilities.sessionCapabilities?.resume) throw new Error(`This ${this.name} version cannot resume sessions. Update it.`);
163
+ const result = await this.rpc(id ? this.capabilities.sessionCapabilities?.resume ? 'session/resume' : 'session/load' : 'session/new', { ...(id ? { sessionId: id } : {}), cwd, mcpServers: [], ...(this.profile.sessionMeta ? { _meta: this.profile.sessionMeta } : {}) });
164
+ session = { ...result, sessionId: id || result.sessionId };
165
+ if (!session!.sessionId) throw new Error(`${this.name} did not return a session ID.`);
166
+ // Do not inherit an auto-approve mode from the user's global configuration.
167
+ // Agents list modes as session modes or as a mode configuration option.
168
+ if (this.profile.modes.length) {
169
+ const { mode, option } = this.mode(session!);
170
+ if (session!.modes) await this.rpc('session/set_mode', { sessionId: session!.sessionId, modeId: mode });
171
+ else if (option!.currentValue !== mode) {
172
+ const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option!.id, value: mode });
173
+ if (result?.configOptions) session!.configOptions = result.configOptions;
174
+ }
175
+ }
176
+ for (const [configId, value] of this.config(session!)) {
177
+ const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId, value });
178
+ if (result?.configOptions) session!.configOptions = result.configOptions;
179
+ }
180
+ this.sessions.set(session!.sessionId, session!);
181
+ }
182
+ if (model) {
183
+ const option = session!.configOptions?.find(option => option.category === 'model' || option.id === 'model');
184
+ if (option) {
185
+ const result = await this.rpc('session/set_config_option', { sessionId: session!.sessionId, configId: option.id, value: model });
186
+ if (result.configOptions) session!.configOptions = result.configOptions;
187
+ } else await this.rpc('session/set_model', { sessionId: session!.sessionId, modelId: model });
188
+ }
189
+ this.readModels(session!);
190
+ return session!;
191
+ }
192
+ // The accepted mode a session offers, as a session mode or a mode option.
193
+ private mode(session: Session) {
194
+ const option = session.configOptions?.find(option => option.category === 'mode' || option.id === 'mode');
195
+ const offered = session.modes ? session.modes.availableModes.map(mode => mode.id) : choices(option).map(choice => choice.value);
196
+ const mode = this.profile.modes.find(id => offered.includes(id));
197
+ if (!mode) throw new Error(`${this.name} does not offer manual approvals. Update it.`);
198
+ return { mode, option };
199
+ }
200
+ private config(session: Session) {
201
+ const settings = Object.entries(this.profile.config || {});
202
+ for (const [id, value] of settings) {
203
+ if (!choices(session.configOptions?.find(option => option.id === id)).some(choice => choice.value === value)) {
204
+ throw new Error(`${this.name} does not offer the required ${id} setting. Update it.`);
205
+ }
206
+ }
207
+ return settings;
208
+ }
209
+ private readModels(session: Session, initial = false) {
210
+ const model = session.configOptions?.find(option => option.category === 'model' || option.id === 'model');
211
+ const thinking = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
212
+ // A "default" level is the model default Papilot already offers.
213
+ const levels = choices(thinking).filter(choice => choice.value !== 'default'), level = thinking?.currentValue === 'default' ? null : thinking?.currentValue || null;
214
+ const current = model?.currentValue || session.models?.currentModelId || null;
215
+ if (initial) this.defaultModel = current;
216
+ const available = model ? choices(model) : (session.models?.availableModels || []).map(model => ({ value: model.modelId, name: model.name }));
217
+ for (const option of available) {
218
+ const previous = this.catalog.find(entry => entry.model === option.value);
219
+ const entry = { model: option.value, displayName: option.name, isDefault: option.value === this.defaultModel,
220
+ supportedReasoningEfforts: option.value === current ? levels.map(choice => ({ reasoningEffort: choice.value })) : previous?.supportedReasoningEfforts || [],
221
+ defaultReasoningEffort: option.value === current ? level : previous?.defaultReasoningEffort || null };
222
+ if (previous) Object.assign(previous, entry); else this.catalog.push(entry);
223
+ }
224
+ }
225
+ protected run(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) { void this.execute(turn, thread, text, effort, images); }
226
+ private async execute(turn: Turn, thread: Thread, text: string, effort: string | null, images: string[]) {
227
+ let stopped = false, prompting = false;
228
+ let cancelTimer: NodeJS.Timeout | undefined;
229
+ const live: LiveTurn = { turn, cwd: thread.cwd, text: '', message: randomUUID(), tools: new Map(), received: false, stopped: false };
230
+ turn.stop = () => {
231
+ stopped = true; live.stopped = true;
232
+ this.clearApprovals(turn);
233
+ if (prompting) {
234
+ this.send({ method: 'session/cancel', params: { sessionId: turn.threadId } });
235
+ // A broken server must not leave paused tasks waiting forever.
236
+ cancelTimer ??= setTimeout(() => this.disconnect(new Error(`${this.name} did not acknowledge cancellation. Reconnect to resume.`)), 5000);
237
+ cancelTimer.unref();
238
+ }
239
+ };
240
+ try {
241
+ const session = await this.session(turn.threadId, thread.cwd, thread.model);
242
+ if (effort) {
243
+ const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
244
+ if (!option || !choices(option).some(choice => choice.value === effort)) throw new Error(`${this.name} does not support reasoning effort ${effort} for this model.`);
245
+ await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: effort });
246
+ }
247
+ const prompt: any[] = [{ type: 'text', text: thread.started ? text : `${thread.instructions}\n\n---\n\n${text}` }];
248
+ 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.`);
249
+ for (const file of images) {
250
+ const bytes = await fs.promises.readFile(file), mimeType = imageType(bytes);
251
+ if (!mimeType) throw new Error('This image format is not supported.');
252
+ prompt.push({ type: 'image', data: bytes.toString('base64'), mimeType });
253
+ }
254
+ if (stopped) return this.finish(turn, 'interrupted');
255
+ this.live.set(turn.threadId, live);
256
+ prompting = true;
257
+ // The response arrives at the end of the turn; user approvals can take any amount of time.
258
+ const result = await this.rpc('session/prompt', { sessionId: turn.threadId, prompt }, 0);
259
+ this.threads.set(turn.threadId, { ...thread, started: true });
260
+ this.flush(live);
261
+ if (stopped || result.stopReason === 'cancelled') this.finish(turn, 'interrupted');
262
+ else if (result.stopReason === 'end_turn' && live.received) this.finish(turn, 'completed');
263
+ else if (result.stopReason === 'end_turn') this.finish(turn, 'failed', `${this.name} returned no response. Check its login and model configuration, then retry.`);
264
+ else this.finish(turn, 'failed', `${this.name} stopped: ${result.stopReason || 'no stop reason returned'}.`);
265
+ } catch (error) {
266
+ this.flush(live);
267
+ this.finish(turn, stopped ? 'interrupted' : 'failed', stopped ? undefined : error instanceof Error ? error.message : String(error));
268
+ } finally {
269
+ if (cancelTimer) clearTimeout(cancelTimer);
270
+ this.clearApprovals(turn);
271
+ this.live.delete(turn.threadId);
272
+ }
273
+ }
274
+ private flush(live: LiveTurn) {
275
+ if (live.text.trim()) this.item(live.turn, { id: live.message, type: 'agentMessage', text: live.text });
276
+ live.text = ''; live.message = randomUUID();
277
+ }
278
+ private update(params: any) {
279
+ const live = this.live.get(params.sessionId), update = params.update;
280
+ if (!live || !update) return; // Includes history replay while loading a session.
281
+ if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
282
+ const text = String(update.content.text || ''), shown = live.text.trim();
283
+ live.text += text;
284
+ // Empty and whitespace-only chunks (Qwen sends them) would show as empty
285
+ // messages, so text is streamed from its first visible character.
286
+ if (!live.text.trim()) return;
287
+ live.received = true;
288
+ this.notify('item/agentMessage/delta', { threadId: live.turn.threadId, turnId: live.turn.id, itemId: live.message, delta: shown ? text : live.text });
289
+ } else if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
290
+ live.received = true;
291
+ this.flush(live);
292
+ const tool = { ...live.tools.get(update.toolCallId), ...update };
293
+ live.tools.set(update.toolCallId, tool);
294
+ const kind = toolKind(tool), named = tool.rawInput?.file_path || tool.rawInput?.path;
295
+ const paths = (tool.locations || []).map((location: any) => location.path).concat((tool.content || []).filter((part: any) => part.type === 'diff').map((part: any) => part.path))
296
+ .concat(kind === 'edit' && typeof named === 'string' ? [path.resolve(live.cwd, named)] : []);
297
+ const id = `${live.turn.id}:${tool.toolCallId}`;
298
+ const status = tool.status === 'failed' ? 'failed' : tool.status === 'completed' ? 'completed' : 'inProgress';
299
+ const output = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').join('\n');
300
+ const item: Item = kind === 'execute' ? { id, type: 'commandExecution', command: tool.rawInput?.command || tool.title || `${this.name} command`, aggregatedOutput: output.slice(-40000), status }
301
+ : kind === 'edit' && paths.length ? { id, type: 'fileChange', changes: [...new Set(paths)].map(file => ({ path: file })), status }
302
+ : { id, type: 'toolCall', text: tool.title || `${this.name} tool`, status };
303
+ this.item(live.turn, item, status !== 'inProgress');
304
+ }
305
+ }
306
+ private receive(message: any) {
307
+ if (!message.method) {
308
+ const wait = this.pending.get(message.id);
309
+ if (!wait) return;
310
+ if (wait.timer) clearTimeout(wait.timer);
311
+ this.pending.delete(message.id);
312
+ if (message.error) wait.reject(new Error(message.error.message || `${this.name} request failed.`));
313
+ else wait.resolve(message.result);
314
+ return;
315
+ }
316
+ if (message.method === 'session/update') { this.update(message.params); return; }
317
+ if (message.id === undefined) return;
318
+ const live = this.live.get(message.params?.sessionId);
319
+ if (message.method !== 'session/request_permission' || !live || live.turn.status !== 'inProgress') {
320
+ this.send({ id: message.id, error: { code: -32601, message: 'This client does not support this request, or the turn has ended.' } }); return;
321
+ }
322
+ if (live.stopped) { this.send({ id: message.id, result: { outcome: { outcome: 'cancelled' } } }); return; }
323
+ const id = randomUUID(), update = message.params.toolCall || {};
324
+ const tool = { ...live.tools.get(update.toolCallId), ...update };
325
+ const file = tool.locations?.[0]?.path || tool.rawInput?.file_path || tool.rawInput?.path;
326
+ const edit = toolKind(tool) === 'edit';
327
+ const options = message.params.options || [];
328
+ const question = options.filter((option: any) => option.kind === 'allow_once').length > 1;
329
+ this.approvals.set(id, { rpcId: message.id, turn: live.turn, options, question });
330
+ if (question) {
331
+ // A question arrives as a permission request with several one-time
332
+ // choices (Kimi's AskUserQuestion): the question itself is the tool's
333
+ // text, and a one-time rejection is its Skip choice. An ACP permission
334
+ // answer can only select an option, never carry free text.
335
+ const asked = (tool.content || []).map((part: any) => part.type === 'content' && part.content?.type === 'text' ? part.content.text : '').filter(Boolean).join('\n');
336
+ this.emit('request', { id, method: 'item/tool/requestUserInput', params: { threadId: live.turn.threadId, turnId: live.turn.id,
337
+ 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 })) }] } });
338
+ return;
339
+ }
340
+ this.emit('request', { id, method: edit ? 'item/fileChange/requestApproval' : 'item/commandExecution/requestApproval',
341
+ 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`) } });
342
+ }
343
+ respond(id: string | number, result: any) {
344
+ const wait = this.approvals.get(String(id));
345
+ if (!wait) throw new Error('This request has ended.');
346
+ const kind = result?.decision === 'accept' ? 'allow_once' : result?.decision === 'decline' ? 'reject_once' : null;
347
+ const answer = result?.answers?.choice?.answers?.[0];
348
+ const option = wait.question
349
+ ? wait.options.find(option => (option.kind === 'allow_once' || option.kind === 'reject_once') && (option.name || option.optionId) === answer)
350
+ : wait.options.find(option => option.kind === kind);
351
+ this.approvals.delete(String(id));
352
+ this.send({ id: wait.rpcId, result: { outcome: option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' } } });
353
+ }
354
+ reject(id: string | number, _message: string) { if (this.approvals.has(String(id))) this.respond(id, { decision: 'decline' }); }
355
+ private clearApprovals(turn: Turn) {
356
+ for (const [id, wait] of this.approvals) if (wait.turn === turn) {
357
+ this.respond(id, { decision: 'cancel' });
358
+ this.notify('serverRequest/resolved', { threadId: turn.threadId, requestId: id });
359
+ }
360
+ }
361
+ private send(message: Record<string, unknown>) {
362
+ const child = this.child;
363
+ child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n', error => { if (error && this.child === child) this.disconnect(error); });
364
+ }
365
+ private rpc(method: string, params: unknown, timeout = 20000): Promise<any> {
366
+ if (!this.child) return Promise.reject(new Error(`${this.name} is not connected.`));
367
+ const id = ++this.sequence;
368
+ return new Promise((resolve, reject) => {
369
+ const timer = timeout ? setTimeout(() => { this.pending.delete(id); reject(new Error(`${this.name} request timed out: ${method}.`)); }, timeout) : undefined;
370
+ this.pending.set(id, { resolve, reject, timer });
371
+ this.send({ id, method, params });
372
+ });
373
+ }
374
+ private disconnect(error: Error, notify = true) {
375
+ const child = this.child;
376
+ this.child = null; this.ready = false; this.sessions.clear();
377
+ for (const wait of this.pending.values()) { if (wait.timer) clearTimeout(wait.timer); wait.reject(error); }
378
+ this.pending.clear(); this.approvals.clear();
379
+ child?.stdin?.end(); child?.kill('SIGTERM');
380
+ if (child && notify) this.emit('disconnected', error);
381
+ }
382
+ close() { this.disconnect(new Error(`${this.name} disconnected.`), false); }
383
+ }
384
+
385
+ export class KimiAgent extends AcpAgent {
386
+ constructor(binary?: string, launch?: typeof spawn) { super(ACP_PROFILES.kimi, binary, launch); }
387
+ }