@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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -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
+ }