@vintasoftware/pr-review-canvas 0.1.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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
package/src/acpx/acpx.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat's only process boundary: acpx. `AgentRunner` is what the chat manager depends on;
|
|
3
|
+
* this file holds the real adapter and the argument builder. Tests use the in-memory fake in
|
|
4
|
+
* `src/testing/fake-runner.ts` or spawn `src/testing/fake-acpx.mjs` through this adapter.
|
|
5
|
+
*/
|
|
6
|
+
import { type ChildProcess, execFile, type SpawnOptions, spawn } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import {
|
|
9
|
+
type AgentErrorCode,
|
|
10
|
+
type AgentEvent,
|
|
11
|
+
exitCodeMessage,
|
|
12
|
+
exitCodeToAgentCode,
|
|
13
|
+
mapAcpxMessage,
|
|
14
|
+
scrubForLog,
|
|
15
|
+
} from './events.js'
|
|
16
|
+
import { createNdjsonSplitter, NdjsonError } from './ndjson.js'
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
|
|
20
|
+
export const ACPX_BIN = 'acpx'
|
|
21
|
+
|
|
22
|
+
/** How long the runner waits after its own deadline for acpx to stop on its own. */
|
|
23
|
+
export const KILL_GRACE_MS = 3000
|
|
24
|
+
/** A cancel that has not answered by then is given up on, and the child is killed instead. */
|
|
25
|
+
export const CANCEL_TIMEOUT_SEC = 30
|
|
26
|
+
/** The runner's deadline sits this far past acpx's, so acpx reports its own timeout first. */
|
|
27
|
+
export const DEADLINE_SLACK_MS = 15_000
|
|
28
|
+
|
|
29
|
+
export interface AgentRunOptions {
|
|
30
|
+
agent: string
|
|
31
|
+
/** The acpx session name, which is what a chat thread is. */
|
|
32
|
+
session: string
|
|
33
|
+
prompt: string
|
|
34
|
+
cwd: string
|
|
35
|
+
timeoutSec: number
|
|
36
|
+
model?: string | undefined
|
|
37
|
+
maxTurns?: number | undefined
|
|
38
|
+
/**
|
|
39
|
+
* Every acpx line the raw event log may keep. The scrubbing happens here rather than in the
|
|
40
|
+
* caller, so no caller can write a file's content to disk by mistake.
|
|
41
|
+
*/
|
|
42
|
+
onRawLine?: (line: string) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentRun {
|
|
46
|
+
events: AsyncIterable<AgentEvent>
|
|
47
|
+
/** Asks acpx to cancel the turn, then stops the child. Safe to call more than once. */
|
|
48
|
+
cancel(): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentExecResult {
|
|
52
|
+
ok: boolean
|
|
53
|
+
text: string
|
|
54
|
+
code?: AgentErrorCode
|
|
55
|
+
message?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AgentRunner {
|
|
59
|
+
run(options: AgentRunOptions): AgentRun
|
|
60
|
+
/**
|
|
61
|
+
* Creates the named session when it does not exist yet. `prompt -s <name>` refuses an unknown
|
|
62
|
+
* session on some agents (codex says so and exits), so a thread's first turn ensures it.
|
|
63
|
+
*/
|
|
64
|
+
ensureSession(options: { agent: string; session: string; cwd: string; timeoutSec: number }): Promise<void>
|
|
65
|
+
/** One real round trip, used by the settings probe. */
|
|
66
|
+
exec(options: { agent: string; prompt: string; cwd: string; timeoutSec: number }): Promise<AgentExecResult>
|
|
67
|
+
/** The acpx version, or null when acpx is not on PATH. */
|
|
68
|
+
acpxVersion(): Promise<string | null>
|
|
69
|
+
/** Whether one agent's own CLI is installed and logged in. */
|
|
70
|
+
availability(agent: string): Promise<{ installed: boolean; authenticated: boolean; reason?: string }>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The flags every acpx call carries: read-only tool policy, no terminal, JSON on stdout, and
|
|
75
|
+
* file contents kept out of the stream.
|
|
76
|
+
*
|
|
77
|
+
* `--auth-policy fail` is deliberately absent. The spike showed codex refusing to start under
|
|
78
|
+
* it while `codex login status` reported a signed-in account; a real auth failure still arrives
|
|
79
|
+
* as an `AUTH_REQUIRED` error line.
|
|
80
|
+
*/
|
|
81
|
+
export function commonAcpxArgs(cwd: string, timeoutSec: number): string[] {
|
|
82
|
+
return [
|
|
83
|
+
'--cwd',
|
|
84
|
+
cwd,
|
|
85
|
+
'--format',
|
|
86
|
+
'json',
|
|
87
|
+
'--json-strict',
|
|
88
|
+
'--suppress-reads',
|
|
89
|
+
'--approve-reads',
|
|
90
|
+
'--non-interactive-permissions',
|
|
91
|
+
'deny',
|
|
92
|
+
'--no-terminal',
|
|
93
|
+
'--timeout',
|
|
94
|
+
String(timeoutSec),
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildPromptArgs(options: AgentRunOptions): string[] {
|
|
99
|
+
const args = commonAcpxArgs(options.cwd, options.timeoutSec)
|
|
100
|
+
if (options.model !== undefined && options.model !== '') {
|
|
101
|
+
args.push('--model', options.model)
|
|
102
|
+
}
|
|
103
|
+
if (options.maxTurns !== undefined) {
|
|
104
|
+
args.push('--max-turns', String(options.maxTurns))
|
|
105
|
+
}
|
|
106
|
+
args.push(options.agent, '-s', options.session, 'prompt', '-f', '-')
|
|
107
|
+
return args
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildCancelArgs(agent: string, session: string, cwd: string): string[] {
|
|
111
|
+
return [...commonAcpxArgs(cwd, CANCEL_TIMEOUT_SEC), agent, 'cancel', '-s', session]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildEnsureArgs(agent: string, session: string, cwd: string, timeoutSec: number): string[] {
|
|
115
|
+
return [...commonAcpxArgs(cwd, timeoutSec), agent, 'sessions', 'ensure', '-s', session]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildExecArgs(agent: string, cwd: string, timeoutSec: number, prompt: string): string[] {
|
|
119
|
+
return [...commonAcpxArgs(cwd, timeoutSec), agent, 'exec', prompt]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** What `claude auth status` / `codex login status` are called, per agent. */
|
|
123
|
+
const AUTH_CHECKS: Readonly<Record<string, { bin: string; args: string[] }>> = {
|
|
124
|
+
claude: { bin: 'claude', args: ['auth', 'status'] },
|
|
125
|
+
codex: { bin: 'codex', args: ['login', 'status'] },
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Only the one call shape the runner makes, so a test double is a plain function. */
|
|
129
|
+
export type SpawnImpl = (file: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
130
|
+
export type ExecFileImpl = (
|
|
131
|
+
file: string,
|
|
132
|
+
args: string[],
|
|
133
|
+
options: { cwd?: string; timeout?: number; maxBuffer?: number; killSignal?: NodeJS.Signals }
|
|
134
|
+
) => Promise<{ stdout: string; stderr: string }>
|
|
135
|
+
|
|
136
|
+
export interface CreateAgentRunnerOptions {
|
|
137
|
+
bin?: string
|
|
138
|
+
spawnImpl?: SpawnImpl
|
|
139
|
+
execFileImpl?: ExecFileImpl
|
|
140
|
+
/** How far the runner's own deadline sits past acpx's. Tests shorten it. */
|
|
141
|
+
deadlineSlackMs?: number
|
|
142
|
+
/** How long a cancel command is waited for before the child is killed anyway. Tests shorten it. */
|
|
143
|
+
cancelGraceMs?: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A queue an async generator reads from while the child writes into it. */
|
|
147
|
+
function createEventQueue(): {
|
|
148
|
+
push: (event: AgentEvent) => void
|
|
149
|
+
end: () => void
|
|
150
|
+
iterate: () => AsyncGenerator<AgentEvent>
|
|
151
|
+
} {
|
|
152
|
+
const pending: AgentEvent[] = []
|
|
153
|
+
let done = false
|
|
154
|
+
let wake: (() => void) | null = null
|
|
155
|
+
const signal = (): void => {
|
|
156
|
+
const fn = wake
|
|
157
|
+
wake = null
|
|
158
|
+
fn?.()
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
push(event) {
|
|
162
|
+
if (!done) {
|
|
163
|
+
pending.push(event)
|
|
164
|
+
signal()
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
end() {
|
|
168
|
+
done = true
|
|
169
|
+
signal()
|
|
170
|
+
},
|
|
171
|
+
async *iterate() {
|
|
172
|
+
for (;;) {
|
|
173
|
+
// Re-read the queue after every yield: an event can arrive while the consumer holds us.
|
|
174
|
+
while (pending.length > 0) {
|
|
175
|
+
for (const next of pending.splice(0)) {
|
|
176
|
+
yield next
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (done) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
await new Promise<void>(resolve => {
|
|
183
|
+
wake = resolve
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createAgentRunner(opts: CreateAgentRunnerOptions = {}): AgentRunner {
|
|
191
|
+
const bin = opts.bin ?? ACPX_BIN
|
|
192
|
+
const slackMs = opts.deadlineSlackMs ?? DEADLINE_SLACK_MS
|
|
193
|
+
const cancelGraceMs = opts.cancelGraceMs ?? CANCEL_TIMEOUT_SEC * 1000
|
|
194
|
+
const spawnImpl = opts.spawnImpl ?? spawn
|
|
195
|
+
const run: ExecFileImpl =
|
|
196
|
+
opts.execFileImpl ??
|
|
197
|
+
((file, args, options) => execFileAsync(file, args, { ...options, encoding: 'utf8', shell: false }))
|
|
198
|
+
|
|
199
|
+
const execQuiet = async (
|
|
200
|
+
file: string,
|
|
201
|
+
args: string[],
|
|
202
|
+
options: { cwd?: string; timeoutSec?: number; killSignal?: NodeJS.Signals } = {}
|
|
203
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string; error?: unknown }> => {
|
|
204
|
+
const call: { cwd?: string; timeout?: number; maxBuffer?: number; killSignal?: NodeJS.Signals } = {
|
|
205
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
206
|
+
}
|
|
207
|
+
if (options.cwd !== undefined) {
|
|
208
|
+
call.cwd = options.cwd
|
|
209
|
+
}
|
|
210
|
+
if (options.timeoutSec !== undefined) {
|
|
211
|
+
call.timeout = options.timeoutSec * 1000
|
|
212
|
+
}
|
|
213
|
+
if (options.killSignal !== undefined) {
|
|
214
|
+
call.killSignal = options.killSignal
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const { stdout, stderr } = await run(file, args, call)
|
|
218
|
+
return { ok: true, stdout, stderr }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const shaped = err as { stdout?: string; stderr?: string }
|
|
221
|
+
return { ok: false, stdout: shaped.stdout ?? '', stderr: shaped.stderr ?? '', error: err }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
run(options) {
|
|
227
|
+
return startRun(
|
|
228
|
+
bin,
|
|
229
|
+
spawnImpl,
|
|
230
|
+
options,
|
|
231
|
+
// The cancel call gets its own timeout, and SIGKILL when it elapses: a cancel that hangs
|
|
232
|
+
// must neither hold the kill back nor stay behind as a process of its own.
|
|
233
|
+
agentArgs =>
|
|
234
|
+
execQuiet(bin, agentArgs, { cwd: options.cwd, timeoutSec: CANCEL_TIMEOUT_SEC, killSignal: 'SIGKILL' }),
|
|
235
|
+
slackMs,
|
|
236
|
+
cancelGraceMs
|
|
237
|
+
)
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async ensureSession(options) {
|
|
241
|
+
await execQuiet(bin, buildEnsureArgs(options.agent, options.session, options.cwd, options.timeoutSec), {
|
|
242
|
+
cwd: options.cwd,
|
|
243
|
+
timeoutSec: options.timeoutSec + DEADLINE_SLACK_MS / 1000,
|
|
244
|
+
})
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async exec(options) {
|
|
248
|
+
const result = await execQuiet(
|
|
249
|
+
bin,
|
|
250
|
+
buildExecArgs(options.agent, options.cwd, options.timeoutSec, options.prompt),
|
|
251
|
+
{
|
|
252
|
+
cwd: options.cwd,
|
|
253
|
+
timeoutSec: options.timeoutSec + DEADLINE_SLACK_MS / 1000,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
const { text, error, ended } = readExecStream(result.stdout)
|
|
257
|
+
if (error !== null) {
|
|
258
|
+
return { ok: false, text: '', code: error.code, message: error.message }
|
|
259
|
+
}
|
|
260
|
+
if (!result.ok) {
|
|
261
|
+
const code = exitCodeOf(result.error)
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
text: '',
|
|
265
|
+
// A failed call never exits 0, so the table always names a code here.
|
|
266
|
+
code: exitCodeToAgentCode(code) ?? 'AGENT_FAILED',
|
|
267
|
+
message: missingBinary(result.error) ? `${bin} is not installed` : exitCodeMessage(code),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!ended) {
|
|
271
|
+
return { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing' }
|
|
272
|
+
}
|
|
273
|
+
return text === ''
|
|
274
|
+
? { ok: false, text: '', code: 'AGENT_INCOMPLETE', message: 'the agent replied nothing' }
|
|
275
|
+
: { ok: true, text }
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async acpxVersion() {
|
|
279
|
+
const result = await execQuiet(bin, ['--version'], { timeoutSec: 20 })
|
|
280
|
+
return result.ok ? result.stdout.trim() : null
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
async availability(agent) {
|
|
284
|
+
const check = AUTH_CHECKS[agent]
|
|
285
|
+
if (check === undefined) {
|
|
286
|
+
return { installed: false, authenticated: false, reason: `no auth check is known for ${agent}` }
|
|
287
|
+
}
|
|
288
|
+
const result = await execQuiet(check.bin, check.args, { timeoutSec: 30 })
|
|
289
|
+
if (result.ok) {
|
|
290
|
+
return { installed: true, authenticated: true }
|
|
291
|
+
}
|
|
292
|
+
if (missingBinary(result.error)) {
|
|
293
|
+
return { installed: false, authenticated: false, reason: `${check.bin} is not on PATH` }
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
installed: true,
|
|
297
|
+
authenticated: false,
|
|
298
|
+
reason: `\`${check.bin} ${check.args.join(' ')}\` failed; log in and try again`,
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function exitCodeOf(error: unknown): number {
|
|
305
|
+
if (typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'number') {
|
|
306
|
+
return error.code
|
|
307
|
+
}
|
|
308
|
+
return 1
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function missingBinary(error: unknown): boolean {
|
|
312
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The agent's reply, the first error line, and whether the turn finished. acpx can exit 0 with no
|
|
317
|
+
* terminal event, so a stream without one is an unfinished answer rather than a short one.
|
|
318
|
+
*/
|
|
319
|
+
export function readExecStream(stdout: string): {
|
|
320
|
+
text: string
|
|
321
|
+
error: { code: AgentErrorCode; message: string } | null
|
|
322
|
+
ended: boolean
|
|
323
|
+
} {
|
|
324
|
+
let text = ''
|
|
325
|
+
let ended = false
|
|
326
|
+
for (const line of stdout.split('\n')) {
|
|
327
|
+
const trimmed = line.trim()
|
|
328
|
+
if (trimmed === '') {
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
let parsed: unknown
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(trimmed) as unknown
|
|
334
|
+
} catch {
|
|
335
|
+
return {
|
|
336
|
+
text: '',
|
|
337
|
+
ended: false,
|
|
338
|
+
error: { code: 'AGENT_PROTOCOL_INVALID', message: 'the agent wrote a line that is not JSON' },
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const event = mapAcpxMessage(parsed)
|
|
342
|
+
if (event === null) {
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
if (event.type === 'chunk') {
|
|
346
|
+
text += event.text
|
|
347
|
+
}
|
|
348
|
+
if (event.type === 'done') {
|
|
349
|
+
ended = event.stopReason === 'end_turn'
|
|
350
|
+
}
|
|
351
|
+
if (event.type === 'error') {
|
|
352
|
+
return { text: '', ended: false, error: { code: event.code, message: event.message } }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { text: text.trim(), error: null, ended }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Spawns one prompt turn and turns its output into events. */
|
|
359
|
+
function startRun(
|
|
360
|
+
bin: string,
|
|
361
|
+
spawnImpl: SpawnImpl,
|
|
362
|
+
options: AgentRunOptions,
|
|
363
|
+
cancelCall: (args: string[]) => Promise<unknown>,
|
|
364
|
+
deadlineSlackMs: number,
|
|
365
|
+
cancelGraceMs: number
|
|
366
|
+
): AgentRun {
|
|
367
|
+
const queue = createEventQueue()
|
|
368
|
+
const splitter = createNdjsonSplitter()
|
|
369
|
+
let finished = false
|
|
370
|
+
let sawTerminal = false
|
|
371
|
+
let cancelling = false
|
|
372
|
+
/** True once the child process is gone, whatever ended it. */
|
|
373
|
+
let closed = false
|
|
374
|
+
/** @see AgentRun.cancel — set once so a second call does not spawn a second cancel. */
|
|
375
|
+
let cancelPromise: Promise<void> | null = null
|
|
376
|
+
|
|
377
|
+
let child: ChildProcess
|
|
378
|
+
try {
|
|
379
|
+
child = spawnImpl(bin, buildPromptArgs(options), { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
380
|
+
} catch {
|
|
381
|
+
queue.push({ type: 'error', code: 'AGENT_MISSING', message: `${bin} could not be started` })
|
|
382
|
+
queue.end()
|
|
383
|
+
return { events: queue.iterate(), cancel: async () => undefined }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const finish = (event?: AgentEvent): void => {
|
|
387
|
+
if (finished) {
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
finished = true
|
|
391
|
+
if (event !== undefined) {
|
|
392
|
+
queue.push(event)
|
|
393
|
+
}
|
|
394
|
+
clearTimeout(deadline)
|
|
395
|
+
queue.end()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const fail = (code: AgentErrorCode, message: string): void => {
|
|
399
|
+
finish({ type: 'error', code, message })
|
|
400
|
+
child.kill('SIGKILL')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const deadline = setTimeout(
|
|
404
|
+
() => {
|
|
405
|
+
void doCancel()
|
|
406
|
+
fail('AGENT_TIMEOUT', `the agent did not answer within ${options.timeoutSec} seconds`)
|
|
407
|
+
},
|
|
408
|
+
options.timeoutSec * 1000 + deadlineSlackMs
|
|
409
|
+
)
|
|
410
|
+
// A pending deadline must not hold the process open on its own.
|
|
411
|
+
deadline.unref?.()
|
|
412
|
+
|
|
413
|
+
const doCancel = (): Promise<void> => {
|
|
414
|
+
if (cancelPromise !== null) {
|
|
415
|
+
return cancelPromise
|
|
416
|
+
}
|
|
417
|
+
cancelling = true
|
|
418
|
+
cancelPromise = (async () => {
|
|
419
|
+
// The cooperative cancel first: the agent then ends its turn with `cancelled`. A cancel
|
|
420
|
+
// that does not come back in time is given up on, so the kill below always happens.
|
|
421
|
+
await Promise.race([
|
|
422
|
+
cancelCall(buildCancelArgs(options.agent, options.session, options.cwd)).catch(() => undefined),
|
|
423
|
+
new Promise<void>(resolve => {
|
|
424
|
+
const timer = setTimeout(resolve, cancelGraceMs)
|
|
425
|
+
timer.unref?.()
|
|
426
|
+
}),
|
|
427
|
+
])
|
|
428
|
+
// The child is what has to go, not the stream: a turn that already reported `cancelled` can
|
|
429
|
+
// still have a process behind it.
|
|
430
|
+
if (closed) {
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
child.kill('SIGTERM')
|
|
434
|
+
await new Promise<void>(resolve => {
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
child.kill('SIGKILL')
|
|
437
|
+
resolve()
|
|
438
|
+
}, KILL_GRACE_MS)
|
|
439
|
+
timer.unref?.()
|
|
440
|
+
child.once('close', () => {
|
|
441
|
+
clearTimeout(timer)
|
|
442
|
+
resolve()
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
})()
|
|
446
|
+
return cancelPromise
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
child.stdout?.setEncoding('utf8')
|
|
450
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
451
|
+
let messages: unknown[]
|
|
452
|
+
try {
|
|
453
|
+
messages = splitter.push(chunk)
|
|
454
|
+
} catch (err) {
|
|
455
|
+
fail('AGENT_PROTOCOL_INVALID', err instanceof NdjsonError ? err.message : String(err))
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
for (const message of messages) {
|
|
459
|
+
handleMessage(message)
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
let stderr = ''
|
|
464
|
+
child.stderr?.setEncoding('utf8')
|
|
465
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
466
|
+
stderr = `${stderr}${chunk}`.slice(0, 4096)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
const handleMessage = (message: unknown): void => {
|
|
470
|
+
if (options.onRawLine !== undefined) {
|
|
471
|
+
const loggable = scrubForLog(message)
|
|
472
|
+
if (loggable !== null) {
|
|
473
|
+
options.onRawLine(JSON.stringify(loggable))
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const event = mapAcpxMessage(message)
|
|
477
|
+
if (event === null) {
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
481
|
+
sawTerminal = true
|
|
482
|
+
}
|
|
483
|
+
queue.push(event)
|
|
484
|
+
if (event.type === 'done') {
|
|
485
|
+
finish()
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
490
|
+
fail(err.code === 'ENOENT' ? 'AGENT_MISSING' : 'AGENT_FAILED', err.message)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
child.on('close', (code: number | null) => {
|
|
494
|
+
closed = true
|
|
495
|
+
try {
|
|
496
|
+
for (const message of splitter.flush()) {
|
|
497
|
+
handleMessage(message)
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
fail('AGENT_PROTOCOL_INVALID', err instanceof NdjsonError ? err.message : String(err))
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
if (finished) {
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
// A turn the reader stopped ends as cancelled, whatever the signal did to the exit code.
|
|
507
|
+
if (cancelling) {
|
|
508
|
+
finish({ type: 'done', stopReason: 'cancelled' })
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
// The agent already said why it stopped, so the exit code adds nothing.
|
|
512
|
+
if (sawTerminal) {
|
|
513
|
+
finish()
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
const exit = code ?? 1
|
|
517
|
+
const mapped = exitCodeToAgentCode(exit)
|
|
518
|
+
if (mapped !== null) {
|
|
519
|
+
finish({ type: 'error', code: mapped, message: stderr.trim() === '' ? exitCodeMessage(exit) : stderr.trim() })
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
// acpx 0.13.2 exits 0 when its own --timeout elapses, without a terminal event.
|
|
523
|
+
finish({ type: 'error', code: 'AGENT_INCOMPLETE', message: 'the agent stopped before finishing its answer' })
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
child.stdin?.on('error', () => undefined)
|
|
527
|
+
child.stdin?.end(options.prompt)
|
|
528
|
+
|
|
529
|
+
return { events: queue.iterate(), cancel: doCancel }
|
|
530
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AgentAvailability, AgentProbeResult, AgentsResponse, ChatAgent } from '../contract/settings.js'
|
|
2
|
+
import { CHAT_AGENTS } from '../contract/settings.js'
|
|
3
|
+
import type { AgentRunner } from './acpx.js'
|
|
4
|
+
import type { PreflightProbe } from './preflight.js'
|
|
5
|
+
|
|
6
|
+
/** A probe is a real round trip to a model, so its answer is reused for ten minutes. */
|
|
7
|
+
export const PROBE_TTL_MS = 10 * 60 * 1000
|
|
8
|
+
export const PROBE_TIMEOUT_SEC = 120
|
|
9
|
+
export const PROBE_PROMPT = 'Reply OK'
|
|
10
|
+
/** Enough of the reply to see that the agent answered, and no more. */
|
|
11
|
+
export const PROBE_REPLY_MAX = 200
|
|
12
|
+
|
|
13
|
+
export interface AgentDirectory {
|
|
14
|
+
list(opts?: { refresh?: boolean }): Promise<AgentsResponse>
|
|
15
|
+
probe(id: ChatAgent, opts?: { refresh?: boolean }): Promise<AgentProbeResult>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreateAgentDirectoryOptions {
|
|
19
|
+
runner: AgentRunner
|
|
20
|
+
preflight: PreflightProbe
|
|
21
|
+
cwd: string
|
|
22
|
+
now: () => Date
|
|
23
|
+
ttlMs?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createAgentDirectory(opts: CreateAgentDirectoryOptions): AgentDirectory {
|
|
27
|
+
const ttlMs = opts.ttlMs ?? PROBE_TTL_MS
|
|
28
|
+
const probes = new Map<ChatAgent, { at: number; value: AgentProbeResult }>()
|
|
29
|
+
const availability = new Map<ChatAgent, { at: number; value: AgentAvailability }>()
|
|
30
|
+
|
|
31
|
+
const availabilityOf = async (id: ChatAgent, refresh: boolean): Promise<AgentAvailability> => {
|
|
32
|
+
const at = opts.now().getTime()
|
|
33
|
+
const hit = availability.get(id)
|
|
34
|
+
if (!refresh && hit !== undefined && at - hit.at < ttlMs) {
|
|
35
|
+
return hit.value
|
|
36
|
+
}
|
|
37
|
+
const checked = await opts.runner.availability(id)
|
|
38
|
+
const value: AgentAvailability = {
|
|
39
|
+
id,
|
|
40
|
+
available: checked.installed && checked.authenticated,
|
|
41
|
+
installed: checked.installed,
|
|
42
|
+
authenticated: checked.authenticated,
|
|
43
|
+
...(checked.reason === undefined ? {} : { reason: checked.reason }),
|
|
44
|
+
}
|
|
45
|
+
availability.set(id, { at: opts.now().getTime(), value })
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async list(listOpts = {}) {
|
|
51
|
+
const refresh = listOpts.refresh === true
|
|
52
|
+
const acpx = await opts.preflight.get(refresh ? { refresh: true } : {})
|
|
53
|
+
const agents = await Promise.all(CHAT_AGENTS.map(id => availabilityOf(id, refresh)))
|
|
54
|
+
return { acpx: { installed: acpx.installed, version: acpx.version }, agents }
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async probe(id, probeOpts = {}) {
|
|
58
|
+
const at = opts.now().getTime()
|
|
59
|
+
const hit = probes.get(id)
|
|
60
|
+
if (probeOpts.refresh !== true && hit !== undefined && at - hit.at < ttlMs) {
|
|
61
|
+
return { ...hit.value, cached: true }
|
|
62
|
+
}
|
|
63
|
+
const startedAt = opts.now().getTime()
|
|
64
|
+
const result = await opts.runner.exec({
|
|
65
|
+
agent: id,
|
|
66
|
+
prompt: PROBE_PROMPT,
|
|
67
|
+
cwd: opts.cwd,
|
|
68
|
+
timeoutSec: PROBE_TIMEOUT_SEC,
|
|
69
|
+
})
|
|
70
|
+
const finishedAt = opts.now().getTime()
|
|
71
|
+
const value: AgentProbeResult = {
|
|
72
|
+
id,
|
|
73
|
+
ok: result.ok,
|
|
74
|
+
ms: finishedAt - startedAt,
|
|
75
|
+
reply: result.text.slice(0, PROBE_REPLY_MAX),
|
|
76
|
+
...(result.code === undefined ? {} : { code: result.code }),
|
|
77
|
+
...(result.message === undefined ? {} : { message: result.message }),
|
|
78
|
+
at: new Date(finishedAt).toISOString(),
|
|
79
|
+
cached: false,
|
|
80
|
+
}
|
|
81
|
+
probes.set(id, { at: finishedAt, value })
|
|
82
|
+
return value
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|