acrylblends 0.9.1 → 0.10.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/README.md +1 -1
- package/dist/index.js.map +1 -1
- package/lib/door/page.js +2 -2
- package/package.json +1 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +4 -1
- package/plugins/agent/agent.ts +59 -13
- package/plugins/agent/model.ts +2 -0
- package/plugins/agent/tools.ts +5 -4
- package/plugins/agent-panel/client/main.ts +70 -2
- package/plugins/agent-panel/drivers.ts +83 -0
- package/plugins/agent-panel/headless.ts +154 -0
- package/plugins/agent-panel/index.ts +42 -122
- package/plugins/agent-panel/plugin.json +1 -1
- package/plugins/driver-pty/client/terminal.ts +188 -0
- package/plugins/driver-pty/client/vendor/LICENSE +21 -0
- package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
- package/plugins/driver-pty/client/vendor/VERSION +4 -0
- package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
- package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
- package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
- package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
- package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
- package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
- package/plugins/driver-pty/index.ts +249 -0
- package/plugins/driver-pty/plugin.json +13 -0
- package/plugins/driver-pty/spawn.ts +36 -0
- package/plugins/driver-pty/terminal.ts +84 -0
package/plugins/agent/agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import type {} from './memory.ts'
|
|
3
|
-
import type {} from './model.ts'
|
|
3
|
+
import type { Step } from './model.ts'
|
|
4
4
|
import type {} from './tools.ts'
|
|
5
5
|
|
|
6
6
|
/** One tool call made during a run. */
|
|
@@ -20,8 +20,14 @@ export type RunResult = (
|
|
|
20
20
|
| { readonly status: 'cancelled' }
|
|
21
21
|
) & { readonly steps: number; readonly trace: readonly ToolCall[]; readonly task?: string }
|
|
22
22
|
|
|
23
|
+
/** What a caller may pass to one run. */
|
|
24
|
+
export interface RunOptions {
|
|
25
|
+
/** Aborting it stops the run: the model call or tool in flight is dropped and the run ends `{ status: 'cancelled' }`. */
|
|
26
|
+
readonly signal?: AbortSignal
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
export interface Agent {
|
|
24
|
-
run(question: string): Promise<RunResult>
|
|
30
|
+
run(question: string, options?: RunOptions): Promise<RunResult>
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** The step budget of one run, provided inside the run's own fiber. */
|
|
@@ -62,6 +68,19 @@ declare module '@deepseek-ai/cordis' {
|
|
|
62
68
|
|
|
63
69
|
const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
64
70
|
|
|
71
|
+
/** Thrown inside a run when its signal aborts; the run turns it into `cancelled`. */
|
|
72
|
+
class Cancelled extends Error {}
|
|
73
|
+
|
|
74
|
+
/** The promise, or a rejection with `Cancelled` as soon as the signal aborts (a model or tool that ignores the signal cannot hold the run). */
|
|
75
|
+
function until<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
76
|
+
if (signal.aborted) return Promise.reject(new Cancelled())
|
|
77
|
+
return new Promise<T>((resolve, reject) => {
|
|
78
|
+
const onAbort = () => reject(new Cancelled())
|
|
79
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
80
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* The agent. Every run is its own child fiber that holds the run's budget and is disposed when the run ends, however it ends: a
|
|
67
86
|
* scope per task, written with plain Cordis plugins. A tool that throws does not end the run: the model sees the error as the result.
|
|
@@ -74,11 +93,15 @@ export const agent = {
|
|
|
74
93
|
const limit = config?.steps ?? 6
|
|
75
94
|
let runs = 0
|
|
76
95
|
ctx.provide('agent', {
|
|
77
|
-
async run(question) {
|
|
96
|
+
async run(question, options) {
|
|
78
97
|
runs += 1
|
|
79
98
|
const trace: ToolCall[] = []
|
|
80
99
|
const recorder = (ctx.get as (name: string) => unknown)('tasks') as TaskRecorder | undefined
|
|
81
100
|
const record = recorder?.start('agent-run', { question })
|
|
101
|
+
// The run stops when its task is cancelled or the caller's signal aborts.
|
|
102
|
+
const signal = AbortSignal.any(
|
|
103
|
+
[record?.signal, options?.signal].filter((s): s is AbortSignal => s !== undefined),
|
|
104
|
+
)
|
|
82
105
|
// Assigned inside the run's fiber; `as` keeps TypeScript from narrowing it to this first value.
|
|
83
106
|
let result = { status: 'budget_exceeded', steps: 0, trace } as RunResult
|
|
84
107
|
const task = ctx.plugin({
|
|
@@ -89,17 +112,28 @@ export const agent = {
|
|
|
89
112
|
const budget = scope.budget
|
|
90
113
|
let toolResult: unknown
|
|
91
114
|
while (budget.steps < budget.limit) {
|
|
92
|
-
if (
|
|
115
|
+
if (signal.aborted) {
|
|
116
|
+
result = { status: 'cancelled', steps: budget.steps, trace }
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
let step: Step
|
|
120
|
+
try {
|
|
121
|
+
step = await until(
|
|
122
|
+
scope.model.complete({
|
|
123
|
+
question,
|
|
124
|
+
step: budget.steps,
|
|
125
|
+
toolResult,
|
|
126
|
+
trace: [...trace],
|
|
127
|
+
tools: scope.tools.list(),
|
|
128
|
+
signal,
|
|
129
|
+
}),
|
|
130
|
+
signal,
|
|
131
|
+
)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof Cancelled)) throw error
|
|
93
134
|
result = { status: 'cancelled', steps: budget.steps, trace }
|
|
94
135
|
return
|
|
95
136
|
}
|
|
96
|
-
const step = await scope.model.complete({
|
|
97
|
-
question,
|
|
98
|
-
step: budget.steps,
|
|
99
|
-
toolResult,
|
|
100
|
-
trace: [...trace],
|
|
101
|
-
tools: scope.tools.list(),
|
|
102
|
-
})
|
|
103
137
|
budget.steps += 1
|
|
104
138
|
if (step.tool === undefined) {
|
|
105
139
|
scope.memory.remember(`${question} => ${step.text}`)
|
|
@@ -110,7 +144,10 @@ export const agent = {
|
|
|
110
144
|
? recorder?.start(`tool:${step.tool}`, step.input, { parent: record.id })
|
|
111
145
|
: undefined
|
|
112
146
|
try {
|
|
113
|
-
toolResult = await
|
|
147
|
+
toolResult = await until(
|
|
148
|
+
scope.tools.call(step.tool, step.input, { signal }),
|
|
149
|
+
signal,
|
|
150
|
+
)
|
|
114
151
|
trace.push({
|
|
115
152
|
tool: step.tool,
|
|
116
153
|
input: step.input,
|
|
@@ -119,6 +156,12 @@ export const agent = {
|
|
|
119
156
|
})
|
|
120
157
|
call?.complete(toolResult)
|
|
121
158
|
} catch (error) {
|
|
159
|
+
if (error instanceof Cancelled) {
|
|
160
|
+
trace.push({ tool: step.tool, input: step.input, error: 'cancelled' })
|
|
161
|
+
call?.fail('the run was cancelled')
|
|
162
|
+
result = { status: 'cancelled', steps: budget.steps, trace }
|
|
163
|
+
return
|
|
164
|
+
}
|
|
122
165
|
toolResult = { error: message(error) }
|
|
123
166
|
trace.push({
|
|
124
167
|
tool: step.tool,
|
|
@@ -143,7 +186,10 @@ export const agent = {
|
|
|
143
186
|
}
|
|
144
187
|
if (result.status === 'completed')
|
|
145
188
|
record?.complete(result.output, { steps: result.steps, trace })
|
|
146
|
-
else if (result.status === '
|
|
189
|
+
else if (result.status === 'cancelled') {
|
|
190
|
+
if (!record?.signal.aborted)
|
|
191
|
+
record?.fail('the run was cancelled', { steps: result.steps, trace })
|
|
192
|
+
} else if (result.status === 'budget_exceeded')
|
|
147
193
|
record?.fail('the run used its whole step budget', { steps: result.steps, trace })
|
|
148
194
|
return record ? { ...result, task: record.id } : result
|
|
149
195
|
},
|
package/plugins/agent/model.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface ModelInput {
|
|
|
22
22
|
}>
|
|
23
23
|
/** The tools the model may call. */
|
|
24
24
|
readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
|
|
25
|
+
/** Aborts when the run is cancelled: a provider that can stop its request passes this on. */
|
|
26
|
+
readonly signal?: AbortSignal
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** What the agent needs from a model. Replace this plugin with a real provider when you have one. */
|
package/plugins/agent/tools.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
3
3
|
/** One tool: what it does (for the model) and how to run it. */
|
|
4
4
|
export interface Tool {
|
|
5
5
|
readonly description: string
|
|
6
|
-
run
|
|
6
|
+
/** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. */
|
|
7
|
+
run(input: unknown, options?: { signal?: AbortSignal }): unknown | Promise<unknown>
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
/** The tool registry. A plugin registers its tools inside `ctx.effect`, so they leave with it. */
|
|
@@ -11,7 +12,7 @@ export interface Tools {
|
|
|
11
12
|
/** Register a tool. Returns the function that removes it. */
|
|
12
13
|
register(name: string, tool: Tool): () => void
|
|
13
14
|
list(): Array<{ name: string; description: string }>
|
|
14
|
-
call(name: string, input: unknown): Promise<unknown>
|
|
15
|
+
call(name: string, input: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -31,10 +32,10 @@ export function tools(ctx: Context) {
|
|
|
31
32
|
},
|
|
32
33
|
list: () =>
|
|
33
34
|
[...table.entries()].map(([name, tool]) => ({ name, description: tool.description })),
|
|
34
|
-
async call(name, input) {
|
|
35
|
+
async call(name, input, options) {
|
|
35
36
|
const tool = table.get(name)
|
|
36
37
|
if (!tool) throw new Error(`unknown tool "${name}"`)
|
|
37
|
-
return await tool.run(input)
|
|
38
|
+
return await tool.run(input, options)
|
|
38
39
|
},
|
|
39
40
|
}
|
|
40
41
|
ctx.provide('tools', registry)
|
|
@@ -4,7 +4,7 @@ const PREFIX = '/api/agent-panel'
|
|
|
4
4
|
|
|
5
5
|
interface Session {
|
|
6
6
|
token: string
|
|
7
|
-
drivers: string
|
|
7
|
+
drivers: Array<{ name: string; kind: 'chat' | 'terminal' }>
|
|
8
8
|
}
|
|
9
9
|
interface PanelEvent {
|
|
10
10
|
event: string
|
|
@@ -42,6 +42,8 @@ section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw)
|
|
|
42
42
|
section.shown { display: flex }
|
|
43
43
|
header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
|
|
44
44
|
header strong { flex: 1 }
|
|
45
|
+
#term { flex: 1; min-height: 0; background: #000 }
|
|
46
|
+
#term[hidden], form[hidden], #log[hidden] { display: none }
|
|
45
47
|
#log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
|
|
46
48
|
.msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
|
|
47
49
|
.me { background: #dbeafe; align-self: flex-end }
|
|
@@ -65,8 +67,13 @@ async function start(): Promise<void> {
|
|
|
65
67
|
shadow.innerHTML = `<style>${css}</style>
|
|
66
68
|
<button class="open" type="button">Ask the agent</button>
|
|
67
69
|
<section aria-label="Agent panel">
|
|
68
|
-
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers
|
|
70
|
+
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers
|
|
71
|
+
.map((d) => `<option>${d.name}</option>`)
|
|
72
|
+
.join(
|
|
73
|
+
'',
|
|
74
|
+
)}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
|
|
69
75
|
<div id="log" role="log"></div>
|
|
76
|
+
<div id="term" hidden></div>
|
|
70
77
|
<form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
|
|
71
78
|
<div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
|
|
72
79
|
</section>`
|
|
@@ -77,6 +84,54 @@ async function start(): Promise<void> {
|
|
|
77
84
|
const send = $<HTMLButtonElement>('.send')
|
|
78
85
|
const stop = $<HTMLButtonElement>('.stop')
|
|
79
86
|
const driver = $<HTMLSelectElement>('select')
|
|
87
|
+
const term = $<HTMLElement>('#term')
|
|
88
|
+
const termStop = $<HTMLButtonElement>('.termstop')
|
|
89
|
+
termStop.addEventListener('click', () => void terminal?.stop())
|
|
90
|
+
const form = $<HTMLFormElement>('form')
|
|
91
|
+
const kindOf = (name: string): 'chat' | 'terminal' =>
|
|
92
|
+
session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
|
|
93
|
+
let terminal: { detach(): void; stop(): Promise<void> } | undefined
|
|
94
|
+
// A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
|
|
95
|
+
const choose = async (): Promise<void> => {
|
|
96
|
+
terminal?.detach()
|
|
97
|
+
terminal = undefined
|
|
98
|
+
const isTerminal = kindOf(driver.value) === 'terminal'
|
|
99
|
+
log.hidden = isTerminal
|
|
100
|
+
form.hidden = isTerminal
|
|
101
|
+
term.hidden = !isTerminal
|
|
102
|
+
termStop.hidden = !isTerminal
|
|
103
|
+
if (!isTerminal) return
|
|
104
|
+
try {
|
|
105
|
+
const url = '/api/driver-pty/client/terminal.ts'
|
|
106
|
+
const { openTerminal } = (await import(url)) as {
|
|
107
|
+
openTerminal(options: {
|
|
108
|
+
container: HTMLElement
|
|
109
|
+
token: string
|
|
110
|
+
driver: string
|
|
111
|
+
status(text: string): void
|
|
112
|
+
onChange(change: { extension: string; action: string }): void
|
|
113
|
+
}): Promise<{ detach(): void; stop(): Promise<void> }>
|
|
114
|
+
}
|
|
115
|
+
terminal = await openTerminal({
|
|
116
|
+
container: term,
|
|
117
|
+
token: session.token,
|
|
118
|
+
driver: driver.value,
|
|
119
|
+
status: (text) => line(text, 'tool'),
|
|
120
|
+
onChange: (change) => {
|
|
121
|
+
line(`plugin ${change.extension}: ${change.action}`, 'tool')
|
|
122
|
+
line('Reloading the page to show it.', 'tool')
|
|
123
|
+
setTimeout(() => location.reload(), 900)
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
} catch (error) {
|
|
127
|
+
line(
|
|
128
|
+
`The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
'err',
|
|
130
|
+
)
|
|
131
|
+
log.hidden = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
driver.addEventListener('change', () => void choose())
|
|
80
135
|
const show = (open: boolean): void => {
|
|
81
136
|
panel.classList.toggle('shown', open)
|
|
82
137
|
if (open) input.focus()
|
|
@@ -154,6 +209,19 @@ async function start(): Promise<void> {
|
|
|
154
209
|
}
|
|
155
210
|
})
|
|
156
211
|
document.body.append(host)
|
|
212
|
+
// A terminal that was open before a reload is still running on the server: show it again.
|
|
213
|
+
try {
|
|
214
|
+
const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {
|
|
215
|
+
driver?: string
|
|
216
|
+
} | null
|
|
217
|
+
if (remembered?.driver && kindOf(remembered.driver) === 'terminal') {
|
|
218
|
+
driver.value = remembered.driver
|
|
219
|
+
show(true)
|
|
220
|
+
void choose()
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// No storage: nothing to show again.
|
|
224
|
+
}
|
|
157
225
|
}
|
|
158
226
|
|
|
159
227
|
void start()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drivers the agent panel can offer, in one registry (the `drivers` service the panel provides). A driver is one of two kinds of session:
|
|
3
|
+
*
|
|
4
|
+
* - `chat`: a message in, the panel's own events out (`text`, `tool`, `error`). The app's agent, the headless coding agents and a harness chat are chat drivers.
|
|
5
|
+
* - `terminal`: bytes in, bytes out, a real pseudo-terminal (the `driver-pty` plugin). Nothing is forced to speak bytes.
|
|
6
|
+
*
|
|
7
|
+
* The panel lists every registered driver and picks the view by `kind`. A plugin that provides drivers injects `drivers`: with no panel mounted it is pending,
|
|
8
|
+
* and a panel without such a plugin simply lists none of its drivers.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
|
|
12
|
+
/** One event of a chat run. The panel adds `change` (a plugin was installed or removed) and `done` itself. */
|
|
13
|
+
export type ChatEvent =
|
|
14
|
+
| { readonly event: 'text'; readonly data: { readonly text: string } }
|
|
15
|
+
| { readonly event: 'tool'; readonly data: Record<string, unknown> }
|
|
16
|
+
| { readonly event: 'error'; readonly data: { readonly message: string } }
|
|
17
|
+
|
|
18
|
+
export interface ChatDriver {
|
|
19
|
+
readonly kind: 'chat'
|
|
20
|
+
readonly name: string
|
|
21
|
+
/** Runs one message. Aborting `signal` stops the run. */
|
|
22
|
+
start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A live pseudo-terminal session. */
|
|
26
|
+
export interface TerminalSession {
|
|
27
|
+
readonly id: string
|
|
28
|
+
onData(listener: (chunk: string) => void): () => void
|
|
29
|
+
onExit(listener: (code: number | null) => void): () => void
|
|
30
|
+
write(data: string): void
|
|
31
|
+
resize(cols: number, rows: number): void
|
|
32
|
+
/** Resolves when the process (group) is gone. */
|
|
33
|
+
kill(): Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TerminalDriver {
|
|
37
|
+
readonly kind: 'terminal'
|
|
38
|
+
readonly name: string
|
|
39
|
+
start(options: {
|
|
40
|
+
cwd: string
|
|
41
|
+
env: NodeJS.ProcessEnv
|
|
42
|
+
cols: number
|
|
43
|
+
rows: number
|
|
44
|
+
}): TerminalSession
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Driver = ChatDriver | TerminalDriver
|
|
48
|
+
|
|
49
|
+
export interface Drivers {
|
|
50
|
+
/** Add a driver. Returns the function that removes it; a plugin registers inside `ctx.effect` so the driver leaves with it. */
|
|
51
|
+
register(driver: Driver): () => void
|
|
52
|
+
list(): Driver[]
|
|
53
|
+
get(name: string): Driver | undefined
|
|
54
|
+
/** Whether a request may use the drivers' endpoints: it comes from the app's own page and carries the panel's token. */
|
|
55
|
+
authorized(headers: Headers): boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare module '@deepseek-ai/cordis' {
|
|
59
|
+
interface Context {
|
|
60
|
+
drivers: Drivers
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createDrivers(authorized: (headers: Headers) => boolean = () => true): Drivers {
|
|
65
|
+
const table = new Map<string, Driver>()
|
|
66
|
+
return {
|
|
67
|
+
register(driver) {
|
|
68
|
+
if (table.has(driver.name)) throw new Error(`driver "${driver.name}" is already registered`)
|
|
69
|
+
table.set(driver.name, driver)
|
|
70
|
+
return () => void (table.get(driver.name) === driver && table.delete(driver.name))
|
|
71
|
+
},
|
|
72
|
+
list: () => [...table.values()],
|
|
73
|
+
get: (name) => table.get(name),
|
|
74
|
+
authorized,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Provide the registry on `ctx` (the panel does). */
|
|
79
|
+
export function provideDrivers(ctx: Context, authorized: (headers: Headers) => boolean): Drivers {
|
|
80
|
+
const registry = createDrivers(authorized)
|
|
81
|
+
;(ctx as Context).provide('drivers', registry)
|
|
82
|
+
return registry
|
|
83
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { ChatDriver, ChatEvent } from './drivers.ts'
|
|
5
|
+
|
|
6
|
+
export type HeadlessName = 'claude' | 'codex' | 'pi'
|
|
7
|
+
|
|
8
|
+
/** The command line of each coding agent started headless in the app folder (the same ones the product test uses). */
|
|
9
|
+
export function command(
|
|
10
|
+
driver: HeadlessName,
|
|
11
|
+
message: string,
|
|
12
|
+
root: string,
|
|
13
|
+
): { file: string; args: string[] } {
|
|
14
|
+
switch (driver) {
|
|
15
|
+
case 'claude':
|
|
16
|
+
return {
|
|
17
|
+
file: 'claude',
|
|
18
|
+
args: [
|
|
19
|
+
'-p',
|
|
20
|
+
message,
|
|
21
|
+
'--permission-mode',
|
|
22
|
+
'acceptEdits',
|
|
23
|
+
'--allowedTools',
|
|
24
|
+
'Read,Edit,Write,Glob,Grep,Bash(npm *),Bash(npx *),Bash(node *),Bash(.acryl/bin/acryl *),Bash(curl *)',
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
case 'codex':
|
|
28
|
+
return {
|
|
29
|
+
file: 'codex',
|
|
30
|
+
args: [
|
|
31
|
+
'exec',
|
|
32
|
+
'--skip-git-repo-check',
|
|
33
|
+
'-s',
|
|
34
|
+
'workspace-write',
|
|
35
|
+
'-c',
|
|
36
|
+
'sandbox_workspace_write.network_access=true',
|
|
37
|
+
'-C',
|
|
38
|
+
root,
|
|
39
|
+
message,
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
case 'pi':
|
|
43
|
+
return {
|
|
44
|
+
file: 'pi',
|
|
45
|
+
args: [
|
|
46
|
+
'-p',
|
|
47
|
+
message,
|
|
48
|
+
'--provider',
|
|
49
|
+
'deepseek',
|
|
50
|
+
'--model',
|
|
51
|
+
'deepseek-v4-pro',
|
|
52
|
+
'--no-session',
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function onPath(program: string): boolean {
|
|
59
|
+
const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [program], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
})
|
|
62
|
+
return found.status === 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The headless programs this machine can offer: installed (pi also needs its key in the environment). */
|
|
66
|
+
export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
|
|
67
|
+
const found: HeadlessName[] = []
|
|
68
|
+
for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
|
|
69
|
+
if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
|
|
70
|
+
if (onPath(driver)) found.push(driver)
|
|
71
|
+
}
|
|
72
|
+
return found
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The app's own agent as a chat driver: its tool calls, then its answer. Aborting the signal cancels the run. */
|
|
76
|
+
export function agentDriver(ctx: Context): ChatDriver {
|
|
77
|
+
return {
|
|
78
|
+
kind: 'chat',
|
|
79
|
+
name: 'agent',
|
|
80
|
+
async *start({ message, signal }) {
|
|
81
|
+
const result = await ctx.agent.run(message, { signal })
|
|
82
|
+
for (const call of result.trace)
|
|
83
|
+
yield {
|
|
84
|
+
event: 'tool',
|
|
85
|
+
data: {
|
|
86
|
+
tool: call.tool,
|
|
87
|
+
input: call.input,
|
|
88
|
+
...(call.error ? { error: call.error } : { result: call.result }),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
yield {
|
|
92
|
+
event: 'text',
|
|
93
|
+
data: {
|
|
94
|
+
text:
|
|
95
|
+
result.status === 'completed'
|
|
96
|
+
? result.output
|
|
97
|
+
: result.status === 'cancelled'
|
|
98
|
+
? 'The run was cancelled.'
|
|
99
|
+
: `The run stopped after ${result.steps} steps without an answer.`,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A coding agent run headless in the app folder: its stdout is the answer, its stderr the tool lines. Aborting the signal ends the program. */
|
|
107
|
+
export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
108
|
+
return {
|
|
109
|
+
kind: 'chat',
|
|
110
|
+
name,
|
|
111
|
+
async *start({ message, signal }) {
|
|
112
|
+
const { file, args } = command(name, message, root)
|
|
113
|
+
const child = spawn(file, args, {
|
|
114
|
+
cwd: root,
|
|
115
|
+
env: process.env,
|
|
116
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
117
|
+
})
|
|
118
|
+
const queue: ChatEvent[] = []
|
|
119
|
+
let wake: (() => void) | undefined
|
|
120
|
+
let ended = false
|
|
121
|
+
const push = (event: ChatEvent): void => {
|
|
122
|
+
queue.push(event)
|
|
123
|
+
wake?.()
|
|
124
|
+
}
|
|
125
|
+
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true })
|
|
126
|
+
child.stdout.on('data', (chunk: Buffer) =>
|
|
127
|
+
push({ event: 'text', data: { text: chunk.toString() } }),
|
|
128
|
+
)
|
|
129
|
+
child.stderr.on('data', (chunk: Buffer) =>
|
|
130
|
+
push({ event: 'tool', data: { tool: name, info: chunk.toString().slice(0, 500) } }),
|
|
131
|
+
)
|
|
132
|
+
child.on('error', () => {
|
|
133
|
+
ended = true
|
|
134
|
+
push({ event: 'error', data: { message: `${name} could not be started` } })
|
|
135
|
+
})
|
|
136
|
+
child.on('exit', (code) => {
|
|
137
|
+
ended = true
|
|
138
|
+
if (code !== 0 && !signal.aborted)
|
|
139
|
+
push({ event: 'error', data: { message: `${name} ended with exit code ${code ?? 1}` } })
|
|
140
|
+
else wake?.()
|
|
141
|
+
})
|
|
142
|
+
try {
|
|
143
|
+
for (;;) {
|
|
144
|
+
const next = queue.shift()
|
|
145
|
+
if (next) yield next
|
|
146
|
+
else if (ended) return
|
|
147
|
+
else await new Promise<void>((resolve) => (wake = resolve))
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
if (child.exitCode === null) child.kill('SIGTERM')
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|