@astrale-os/cli 0.7.0-alpha.0 → 0.8.1-alpha.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 +7 -3
- package/dist/astrale.js +128 -98
- package/package.json +11 -5
- package/src/commands/studio.ts +13 -0
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/server.ts +4 -37
- package/studio/client/dist/assets/elk-api-DVw5WDTH.js +1 -0
- package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
- package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
- package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
- package/studio/client/dist/assets/index-Dl709Ra1.js +8 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/studio/tsconfig.json +23 -0
- package/viewer/tsconfig.json +13 -0
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
- package/studio/client/dist/assets/index-huaFafBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- package/studio/server/state/harness-token.ts +0 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { ClaudeCodeHarness } from './adapter'
|
|
7
|
+
|
|
8
|
+
const roots: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function fakeClaude(root: string): string {
|
|
15
|
+
const file = join(root, 'fake-claude')
|
|
16
|
+
writeFileSync(
|
|
17
|
+
file,
|
|
18
|
+
`#!/usr/bin/env bun
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const { spawn } = require('node:child_process')
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
if (args[0] === '--version') {
|
|
23
|
+
console.log('2.1.test')
|
|
24
|
+
process.exit(0)
|
|
25
|
+
}
|
|
26
|
+
if (process.env.FAKE_CLAUDE_LOG) {
|
|
27
|
+
fs.appendFileSync(process.env.FAKE_CLAUDE_LOG, JSON.stringify({ args }) + '\\n')
|
|
28
|
+
}
|
|
29
|
+
const modelAt = args.indexOf('--model')
|
|
30
|
+
const model = modelAt >= 0 ? args[modelAt + 1] + '-resolved' : 'native-default'
|
|
31
|
+
const resumedAt = args.indexOf('--resume')
|
|
32
|
+
const session = resumedAt >= 0 ? args[resumedAt + 1] : 'fresh-session'
|
|
33
|
+
const send = (event) => process.stdout.write(JSON.stringify(event) + '\\n')
|
|
34
|
+
send({
|
|
35
|
+
type: 'system',
|
|
36
|
+
subtype: 'init',
|
|
37
|
+
session_id: session,
|
|
38
|
+
model,
|
|
39
|
+
permissionMode: 'bypassPermissions',
|
|
40
|
+
apiKeySource: 'none',
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
tools: ['Read', 'Edit'],
|
|
43
|
+
mcp_servers: [],
|
|
44
|
+
slash_commands: [],
|
|
45
|
+
agents: [],
|
|
46
|
+
})
|
|
47
|
+
let prompt = ''
|
|
48
|
+
process.stdin.setEncoding('utf8')
|
|
49
|
+
process.stdin.on('data', (chunk) => { prompt += chunk })
|
|
50
|
+
process.stdin.on('end', () => {
|
|
51
|
+
if (prompt === '.') return setInterval(() => {}, 1000)
|
|
52
|
+
if (process.env.FAKE_CLAUDE_MODE === 'no-result') process.exit(0)
|
|
53
|
+
send({ type: 'assistant', message: { content: [{ type: 'text', text: 'answer' }] } })
|
|
54
|
+
if (process.env.FAKE_CLAUDE_MODE === 'text-then-crash') {
|
|
55
|
+
console.error('crashed after text')
|
|
56
|
+
process.exit(7)
|
|
57
|
+
}
|
|
58
|
+
if (process.env.FAKE_CLAUDE_MODE === 'result-error') {
|
|
59
|
+
send({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'failed' })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (process.env.FAKE_CLAUDE_MODE === 'hang-ignore-term') {
|
|
63
|
+
process.on('SIGTERM', () => {})
|
|
64
|
+
const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], {
|
|
65
|
+
stdio: 'ignore',
|
|
66
|
+
})
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
process.env.FAKE_CLAUDE_PID_LOG,
|
|
69
|
+
JSON.stringify({ parent: process.pid, child: child.pid }),
|
|
70
|
+
)
|
|
71
|
+
return setInterval(() => {}, 1000)
|
|
72
|
+
}
|
|
73
|
+
send({
|
|
74
|
+
type: 'result',
|
|
75
|
+
subtype: 'success',
|
|
76
|
+
result: 'done',
|
|
77
|
+
session_id: session,
|
|
78
|
+
num_turns: 1,
|
|
79
|
+
usage: { input_tokens: 2, output_tokens: 3 },
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
`,
|
|
83
|
+
)
|
|
84
|
+
chmodSync(file, 0o755)
|
|
85
|
+
return file
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function invocations(log: string): { args: string[] }[] {
|
|
89
|
+
return readFileSync(log, 'utf8')
|
|
90
|
+
.trim()
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((line) => JSON.parse(line))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise<void> {
|
|
96
|
+
const deadline = Date.now() + timeoutMs
|
|
97
|
+
while (!predicate()) {
|
|
98
|
+
if (Date.now() >= deadline) throw new Error('condition did not become true before timeout')
|
|
99
|
+
await Bun.sleep(20)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function processExists(pid: number): boolean {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0)
|
|
106
|
+
return true
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('Claude loadout probing reports and passes the Studio model override', async () => {
|
|
113
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-claude-model-probe-'))
|
|
114
|
+
roots.push(root)
|
|
115
|
+
const log = join(root, 'claude.jsonl')
|
|
116
|
+
const harness = new ClaudeCodeHarness(fakeClaude(root))
|
|
117
|
+
|
|
118
|
+
const loadout = await harness.loadout(root, {
|
|
119
|
+
model: 'sonnet',
|
|
120
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(loadout).toMatchObject({
|
|
124
|
+
ok: true,
|
|
125
|
+
model: 'sonnet-resolved',
|
|
126
|
+
modelSource: 'studio',
|
|
127
|
+
})
|
|
128
|
+
const args = invocations(log)[0].args
|
|
129
|
+
const modelAt = args.indexOf('--model')
|
|
130
|
+
expect(args.slice(modelAt, modelAt + 2)).toEqual(['--model', 'sonnet'])
|
|
131
|
+
|
|
132
|
+
await harness.loadout(root, {
|
|
133
|
+
model: 'sonnet',
|
|
134
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
135
|
+
})
|
|
136
|
+
expect(invocations(log)).toHaveLength(1)
|
|
137
|
+
await harness.loadout(root, {
|
|
138
|
+
model: 'sonnet',
|
|
139
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
140
|
+
refresh: true,
|
|
141
|
+
})
|
|
142
|
+
expect(invocations(log)).toHaveLength(2)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('Claude turns and Ask calls carry selected models and native effort modes', async () => {
|
|
146
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-claude-model-turns-'))
|
|
147
|
+
roots.push(root)
|
|
148
|
+
const log = join(root, 'claude.jsonl')
|
|
149
|
+
const harness = new ClaudeCodeHarness(fakeClaude(root))
|
|
150
|
+
const signal = new AbortController().signal
|
|
151
|
+
|
|
152
|
+
const normal = await harness.run({
|
|
153
|
+
root,
|
|
154
|
+
prompt: 'normal turn',
|
|
155
|
+
model: 'sonnet',
|
|
156
|
+
effort: 'ultracode',
|
|
157
|
+
access: 'full',
|
|
158
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
159
|
+
signal,
|
|
160
|
+
onEvent: () => {},
|
|
161
|
+
})
|
|
162
|
+
expect(normal).toMatchObject({
|
|
163
|
+
sessionId: 'fresh-session',
|
|
164
|
+
finalText: 'done',
|
|
165
|
+
isError: false,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const resumed = await harness.run({
|
|
169
|
+
root,
|
|
170
|
+
prompt: 'resumed turn',
|
|
171
|
+
sessionId: 'parent-session',
|
|
172
|
+
model: 'opus',
|
|
173
|
+
effort: 'max',
|
|
174
|
+
access: 'full',
|
|
175
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
176
|
+
signal,
|
|
177
|
+
onEvent: () => {},
|
|
178
|
+
})
|
|
179
|
+
expect(resumed).toMatchObject({
|
|
180
|
+
sessionId: 'parent-session',
|
|
181
|
+
finalText: 'done',
|
|
182
|
+
isError: false,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const fresh = await harness.ask({
|
|
186
|
+
root,
|
|
187
|
+
prompt: 'fresh ask',
|
|
188
|
+
model: 'fable',
|
|
189
|
+
effort: 'max',
|
|
190
|
+
access: 'workspace',
|
|
191
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
192
|
+
signal,
|
|
193
|
+
onDelta: () => {},
|
|
194
|
+
})
|
|
195
|
+
const forked = await harness.ask({
|
|
196
|
+
root,
|
|
197
|
+
prompt: 'forked ask',
|
|
198
|
+
sessionId: 'parent-session',
|
|
199
|
+
model: 'sonnet',
|
|
200
|
+
effort: 'max',
|
|
201
|
+
access: 'workspace',
|
|
202
|
+
env: { FAKE_CLAUDE_LOG: log },
|
|
203
|
+
signal,
|
|
204
|
+
onDelta: () => {},
|
|
205
|
+
})
|
|
206
|
+
expect(fresh).toEqual({ text: 'done', isError: false })
|
|
207
|
+
expect(forked).toEqual({ text: 'done', isError: false })
|
|
208
|
+
|
|
209
|
+
const [normalArgs, resumedArgs, freshArgs, forkArgs] = invocations(log).map((entry) => entry.args)
|
|
210
|
+
expect(normalArgs).not.toContain('--resume')
|
|
211
|
+
expect(
|
|
212
|
+
normalArgs.slice(normalArgs.indexOf('--model'), normalArgs.indexOf('--model') + 2),
|
|
213
|
+
).toEqual(['--model', 'sonnet'])
|
|
214
|
+
expect(
|
|
215
|
+
resumedArgs.slice(resumedArgs.indexOf('--resume'), resumedArgs.indexOf('--resume') + 2),
|
|
216
|
+
).toEqual(['--resume', 'parent-session'])
|
|
217
|
+
expect(
|
|
218
|
+
resumedArgs.slice(resumedArgs.indexOf('--model'), resumedArgs.indexOf('--model') + 2),
|
|
219
|
+
).toEqual(['--model', 'opus'])
|
|
220
|
+
expect(freshArgs.slice(freshArgs.indexOf('--model'), freshArgs.indexOf('--model') + 2)).toEqual([
|
|
221
|
+
'--model',
|
|
222
|
+
'fable',
|
|
223
|
+
])
|
|
224
|
+
expect(freshArgs).not.toContain('--resume')
|
|
225
|
+
expect(forkArgs.slice(forkArgs.indexOf('--model'), forkArgs.indexOf('--model') + 2)).toEqual([
|
|
226
|
+
'--model',
|
|
227
|
+
'sonnet',
|
|
228
|
+
])
|
|
229
|
+
expect(forkArgs.slice(forkArgs.indexOf('--resume'), forkArgs.indexOf('--resume') + 3)).toEqual([
|
|
230
|
+
'--resume',
|
|
231
|
+
'parent-session',
|
|
232
|
+
'--fork-session',
|
|
233
|
+
])
|
|
234
|
+
const normalEffortAt = normalArgs.indexOf('--effort')
|
|
235
|
+
expect(normalArgs.slice(normalEffortAt, normalEffortAt + 2)).toEqual(['--effort', 'xhigh'])
|
|
236
|
+
const settingsAt = normalArgs.indexOf('--settings')
|
|
237
|
+
expect(normalArgs.slice(settingsAt, settingsAt + 2)).toEqual(['--settings', '{"ultracode":true}'])
|
|
238
|
+
expect(normalArgs).not.toContain('ultracode')
|
|
239
|
+
|
|
240
|
+
for (const args of [resumedArgs, freshArgs, forkArgs]) {
|
|
241
|
+
const effortAt = args.indexOf('--effort')
|
|
242
|
+
expect(args.slice(effortAt, effortAt + 2)).toEqual(['--effort', 'max'])
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('Claude turns and Ask reject incomplete terminal protocols', async () => {
|
|
247
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-claude-terminal-'))
|
|
248
|
+
roots.push(root)
|
|
249
|
+
const harness = new ClaudeCodeHarness(fakeClaude(root))
|
|
250
|
+
const input = {
|
|
251
|
+
root,
|
|
252
|
+
prompt: 'work',
|
|
253
|
+
signal: new AbortController().signal,
|
|
254
|
+
onEvent: () => {},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const noResult = await harness.run({
|
|
258
|
+
...input,
|
|
259
|
+
env: { FAKE_CLAUDE_MODE: 'no-result' },
|
|
260
|
+
})
|
|
261
|
+
expect(noResult).toMatchObject({
|
|
262
|
+
isError: true,
|
|
263
|
+
errorMessage: 'claude exited without a result event',
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const crashed = await harness.run({
|
|
267
|
+
...input,
|
|
268
|
+
env: { FAKE_CLAUDE_MODE: 'text-then-crash' },
|
|
269
|
+
})
|
|
270
|
+
expect(crashed).toMatchObject({
|
|
271
|
+
isError: true,
|
|
272
|
+
errorMessage: expect.stringContaining('claude exited 7'),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const askInput = {
|
|
276
|
+
root,
|
|
277
|
+
prompt: 'question',
|
|
278
|
+
signal: new AbortController().signal,
|
|
279
|
+
onDelta: () => {},
|
|
280
|
+
}
|
|
281
|
+
const askNoResult = await harness.ask({
|
|
282
|
+
...askInput,
|
|
283
|
+
env: { FAKE_CLAUDE_MODE: 'no-result' },
|
|
284
|
+
})
|
|
285
|
+
expect(askNoResult).toMatchObject({
|
|
286
|
+
isError: true,
|
|
287
|
+
errorMessage: 'claude exited without a result event',
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const askCrashed = await harness.ask({
|
|
291
|
+
...askInput,
|
|
292
|
+
env: { FAKE_CLAUDE_MODE: 'text-then-crash' },
|
|
293
|
+
})
|
|
294
|
+
expect(askCrashed).toMatchObject({
|
|
295
|
+
isError: true,
|
|
296
|
+
errorMessage: expect.stringContaining('claude exited 7'),
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const askTerminalError = await harness.ask({
|
|
300
|
+
...askInput,
|
|
301
|
+
env: { FAKE_CLAUDE_MODE: 'result-error' },
|
|
302
|
+
})
|
|
303
|
+
expect(askTerminalError).toEqual({
|
|
304
|
+
text: 'failed',
|
|
305
|
+
isError: true,
|
|
306
|
+
errorMessage: 'error_during_execution',
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('Claude Ask cancellation kills the entire stubborn subprocess group', async () => {
|
|
311
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-claude-ask-cancel-'))
|
|
312
|
+
roots.push(root)
|
|
313
|
+
const pidLog = join(root, 'pids.json')
|
|
314
|
+
const controller = new AbortController()
|
|
315
|
+
const resultPromise = new ClaudeCodeHarness(fakeClaude(root)).ask({
|
|
316
|
+
root,
|
|
317
|
+
prompt: 'question',
|
|
318
|
+
env: {
|
|
319
|
+
FAKE_CLAUDE_MODE: 'hang-ignore-term',
|
|
320
|
+
FAKE_CLAUDE_PID_LOG: pidLog,
|
|
321
|
+
},
|
|
322
|
+
signal: controller.signal,
|
|
323
|
+
onDelta: () => {},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await waitFor(() => existsSync(pidLog))
|
|
327
|
+
const pids = JSON.parse(readFileSync(pidLog, 'utf8')) as { parent: number; child: number }
|
|
328
|
+
expect(processExists(pids.parent)).toBe(true)
|
|
329
|
+
expect(processExists(pids.child)).toBe(true)
|
|
330
|
+
controller.abort()
|
|
331
|
+
|
|
332
|
+
expect(await resultPromise).toEqual({
|
|
333
|
+
text: '',
|
|
334
|
+
isError: true,
|
|
335
|
+
errorMessage: 'canceled',
|
|
336
|
+
})
|
|
337
|
+
await waitFor(() => !processExists(pids.parent) && !processExists(pids.child))
|
|
338
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HarnessLoadout } from '../../../../shared/types'
|
|
2
|
+
import type {
|
|
3
|
+
AgentHarness,
|
|
4
|
+
AgentTurnInput,
|
|
5
|
+
AgentTurnResult,
|
|
6
|
+
AskInput,
|
|
7
|
+
HarnessHealth,
|
|
8
|
+
HarnessLoadoutOptions,
|
|
9
|
+
} from '../adapter'
|
|
10
|
+
|
|
11
|
+
import { readSkillContent } from '../skills'
|
|
12
|
+
import { runClaudeAsk } from './ask'
|
|
13
|
+
import { CLAUDE_CAPABILITIES } from './capabilities'
|
|
14
|
+
import { runClaudeTurn } from './events'
|
|
15
|
+
import { loadClaudeConfiguration, probeClaudeHealth } from './loadout'
|
|
16
|
+
import { scanClaudeSkills } from './skills'
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BIN = process.env.DOMAIN_STUDIO_CLAUDE_BIN || 'claude'
|
|
19
|
+
|
|
20
|
+
export class ClaudeCodeHarness implements AgentHarness {
|
|
21
|
+
id = 'claude'
|
|
22
|
+
label = 'Claude Code (local)'
|
|
23
|
+
capabilities = CLAUDE_CAPABILITIES
|
|
24
|
+
|
|
25
|
+
private availCache?: { at: number; health: HarnessHealth }
|
|
26
|
+
private loadoutCache?: { at: number; key: string; data: HarnessLoadout }
|
|
27
|
+
|
|
28
|
+
constructor(private readonly bin = DEFAULT_BIN) {}
|
|
29
|
+
|
|
30
|
+
async health(signal?: AbortSignal): Promise<HarnessHealth> {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
if (this.availCache && now - this.availCache.at < 30_000) return this.availCache.health
|
|
33
|
+
const health = await probeClaudeHealth(this.bin, signal)
|
|
34
|
+
if (!signal?.aborted) this.availCache = { at: now, health }
|
|
35
|
+
return health
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async isAvailable(signal?: AbortSignal): Promise<boolean> {
|
|
39
|
+
return (await this.health(signal)).ok
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async loadout(root: string, options?: HarnessLoadoutOptions): Promise<HarnessLoadout> {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
const key = `${root}\u0000${options?.model ?? ''}\u0000${JSON.stringify(options?.env ?? {})}`
|
|
45
|
+
if (
|
|
46
|
+
!options?.refresh &&
|
|
47
|
+
this.loadoutCache &&
|
|
48
|
+
this.loadoutCache.key === key &&
|
|
49
|
+
now - this.loadoutCache.at < 60_000
|
|
50
|
+
)
|
|
51
|
+
return this.loadoutCache.data
|
|
52
|
+
const data = await loadClaudeConfiguration(this.bin, root, options)
|
|
53
|
+
this.loadoutCache = { at: now, key, data }
|
|
54
|
+
return data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async skillContent(
|
|
58
|
+
root: string,
|
|
59
|
+
command: string,
|
|
60
|
+
): Promise<{ command: string; content: string; path: string } | null> {
|
|
61
|
+
return readSkillContent(scanClaudeSkills(root), command)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run(input: AgentTurnInput): Promise<AgentTurnResult> {
|
|
65
|
+
return runClaudeTurn(this.bin, input)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ask(input: AskInput) {
|
|
69
|
+
return runClaudeAsk(this.bin, input)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import type { AskInput, AskResult } from '../adapter'
|
|
4
|
+
|
|
5
|
+
import { childEnvironment, terminateProcessTree } from '../process'
|
|
6
|
+
import { buildClaudeAskArgs } from './command'
|
|
7
|
+
|
|
8
|
+
/** Execute one ephemeral Claude side-question stream. */
|
|
9
|
+
export function runClaudeAsk(bin: string, input: AskInput): Promise<AskResult> {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let finalText = ''
|
|
12
|
+
let stderr = ''
|
|
13
|
+
let isError = false
|
|
14
|
+
let errorMessage: string | undefined
|
|
15
|
+
let sawResult = false
|
|
16
|
+
let settled = false
|
|
17
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
|
|
18
|
+
let onAbort = () => {}
|
|
19
|
+
const finish = (result: AskResult) => {
|
|
20
|
+
if (settled) return
|
|
21
|
+
settled = true
|
|
22
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
23
|
+
input.signal.removeEventListener('abort', onAbort)
|
|
24
|
+
resolve(result)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let child: ReturnType<typeof spawn>
|
|
28
|
+
try {
|
|
29
|
+
child = spawn(bin, buildClaudeAskArgs(input), {
|
|
30
|
+
cwd: input.root,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
env: childEnvironment(input.env),
|
|
33
|
+
detached: process.platform !== 'win32',
|
|
34
|
+
})
|
|
35
|
+
} catch (error) {
|
|
36
|
+
finish({
|
|
37
|
+
text: finalText,
|
|
38
|
+
isError: true,
|
|
39
|
+
errorMessage: `failed to spawn ${bin}: ${String(error)}`,
|
|
40
|
+
})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
onAbort = () => {
|
|
44
|
+
terminateProcessTree(child)
|
|
45
|
+
forceKillTimer = setTimeout(() => terminateProcessTree(child, 'SIGKILL'), 2_000)
|
|
46
|
+
forceKillTimer.unref?.()
|
|
47
|
+
}
|
|
48
|
+
if (input.signal.aborted) onAbort()
|
|
49
|
+
else input.signal.addEventListener('abort', onAbort, { once: true })
|
|
50
|
+
child.stdin?.write(input.prompt)
|
|
51
|
+
child.stdin?.end()
|
|
52
|
+
|
|
53
|
+
const handleLine = (line: string) => {
|
|
54
|
+
let event: any
|
|
55
|
+
try {
|
|
56
|
+
event = JSON.parse(line)
|
|
57
|
+
} catch {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
|
|
61
|
+
for (const block of event.message.content)
|
|
62
|
+
if (block.type === 'text' && block.text) input.onDelta(block.text)
|
|
63
|
+
} else if (event.type === 'result') {
|
|
64
|
+
sawResult = true
|
|
65
|
+
if (typeof event.result === 'string') finalText = event.result
|
|
66
|
+
if (
|
|
67
|
+
event.is_error ||
|
|
68
|
+
event.subtype === 'error_during_execution' ||
|
|
69
|
+
event.subtype === 'error_max_turns'
|
|
70
|
+
) {
|
|
71
|
+
isError = true
|
|
72
|
+
errorMessage = event.subtype || 'ask error'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
let buffer = ''
|
|
77
|
+
child.stdout?.setEncoding('utf8')
|
|
78
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
79
|
+
buffer += chunk
|
|
80
|
+
let newline: number
|
|
81
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
82
|
+
const line = buffer.slice(0, newline).trim()
|
|
83
|
+
buffer = buffer.slice(newline + 1)
|
|
84
|
+
if (line) handleLine(line)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
child.stderr?.setEncoding('utf8')
|
|
88
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
89
|
+
stderr += chunk
|
|
90
|
+
})
|
|
91
|
+
child.on('error', (error) =>
|
|
92
|
+
finish({
|
|
93
|
+
text: finalText,
|
|
94
|
+
isError: true,
|
|
95
|
+
errorMessage: `failed to spawn ${bin}: ${error.message}`,
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
child.on('close', (code) => {
|
|
99
|
+
const trailing = buffer.trim()
|
|
100
|
+
if (trailing) handleLine(trailing)
|
|
101
|
+
if (input.signal.aborted)
|
|
102
|
+
return finish({ text: finalText, isError: true, errorMessage: 'canceled' })
|
|
103
|
+
if (code !== 0) {
|
|
104
|
+
isError = true
|
|
105
|
+
errorMessage =
|
|
106
|
+
errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-300)}` : ''}`
|
|
107
|
+
} else if (!sawResult) {
|
|
108
|
+
isError = true
|
|
109
|
+
errorMessage = 'claude exited without a result event'
|
|
110
|
+
}
|
|
111
|
+
finish({ text: finalText, isError, errorMessage })
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HarnessCapabilities } from '../../../../shared/types'
|
|
2
|
+
|
|
3
|
+
/** Stable model aliases plus the effort modes supported by current Claude Code. */
|
|
4
|
+
export const CLAUDE_CAPABILITIES = {
|
|
5
|
+
effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
|
|
6
|
+
accessLevels: ['workspace', 'full'],
|
|
7
|
+
modelOptions: [
|
|
8
|
+
{
|
|
9
|
+
id: 'fable',
|
|
10
|
+
label: 'Fable',
|
|
11
|
+
description: 'Claude Code alias for the latest available Fable model.',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'sonnet',
|
|
15
|
+
label: 'Sonnet',
|
|
16
|
+
description: 'Claude Code alias for the latest available Sonnet model.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'opus',
|
|
20
|
+
label: 'Opus',
|
|
21
|
+
description: 'Claude Code alias for the latest available Opus model.',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
ask: true,
|
|
25
|
+
loadout: true,
|
|
26
|
+
gateway: 'anthropic',
|
|
27
|
+
} as const satisfies HarnessCapabilities
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentTurnInput, AskInput } from '../adapter'
|
|
2
|
+
|
|
3
|
+
const PERMISSION_MODE = process.env.DOMAIN_STUDIO_AGENT_PERMISSION || 'bypassPermissions'
|
|
4
|
+
const EXTRA_ARGS = (process.env.DOMAIN_STUDIO_CLAUDE_ARGS || '').split(' ').filter(Boolean)
|
|
5
|
+
const ULTRACODE_SETTINGS = JSON.stringify({ ultracode: true })
|
|
6
|
+
|
|
7
|
+
function permissionMode(access?: AgentTurnInput['access']): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env.DOMAIN_STUDIO_AGENT_PERMISSION ||
|
|
10
|
+
(access === 'workspace' ? 'acceptEdits' : PERMISSION_MODE)
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function appendEffort(args: string[], effort?: AgentTurnInput['effort']): void {
|
|
15
|
+
if (!effort) return
|
|
16
|
+
if (effort === 'ultracode') {
|
|
17
|
+
args.push('--effort', 'xhigh', '--settings', ULTRACODE_SETTINGS)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
args.push('--effort', effort)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildClaudeProbeArgs(model?: string): string[] {
|
|
24
|
+
const args = ['-p', '--output-format', 'stream-json', '--verbose', ...EXTRA_ARGS]
|
|
25
|
+
if (model) args.push('--model', model)
|
|
26
|
+
return args
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildClaudeTurnArgs(input: AgentTurnInput, mcpPath?: string): string[] {
|
|
30
|
+
const args = [
|
|
31
|
+
'-p',
|
|
32
|
+
'--output-format',
|
|
33
|
+
'stream-json',
|
|
34
|
+
'--verbose',
|
|
35
|
+
'--permission-mode',
|
|
36
|
+
permissionMode(input.access),
|
|
37
|
+
]
|
|
38
|
+
if (input.sessionId) args.push('--resume', input.sessionId)
|
|
39
|
+
if (input.model) args.push('--model', input.model)
|
|
40
|
+
appendEffort(args, input.effort)
|
|
41
|
+
if (mcpPath) args.push('--mcp-config', mcpPath)
|
|
42
|
+
if (input.appendSystemPrompt) args.push('--append-system-prompt', input.appendSystemPrompt)
|
|
43
|
+
args.push(...EXTRA_ARGS)
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildClaudeAskArgs(input: AskInput): string[] {
|
|
48
|
+
const args = [
|
|
49
|
+
'-p',
|
|
50
|
+
'--output-format',
|
|
51
|
+
'stream-json',
|
|
52
|
+
'--verbose',
|
|
53
|
+
'--permission-mode',
|
|
54
|
+
permissionMode(input.access),
|
|
55
|
+
]
|
|
56
|
+
if (input.model) args.push('--model', input.model)
|
|
57
|
+
appendEffort(args, input.effort)
|
|
58
|
+
if (input.sessionId) args.push('--resume', input.sessionId, '--fork-session')
|
|
59
|
+
args.push('--no-session-persistence')
|
|
60
|
+
if (input.appendSystemPrompt) args.push('--append-system-prompt', input.appendSystemPrompt)
|
|
61
|
+
args.push(...EXTRA_ARGS)
|
|
62
|
+
return args
|
|
63
|
+
}
|