@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.
Files changed (111) hide show
  1. package/README.md +7 -3
  2. package/dist/astrale.js +128 -98
  3. package/package.json +11 -5
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/elk-api-DVw5WDTH.js +1 -0
  11. package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  12. package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
  13. package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
  14. package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
  15. package/studio/client/dist/assets/index-Dl709Ra1.js +8 -0
  16. package/studio/client/dist/index.html +2 -2
  17. package/studio/server/agent/ask.test.ts +74 -0
  18. package/studio/server/agent/ask.ts +20 -22
  19. package/studio/server/agent/bridge/client.test.ts +49 -0
  20. package/studio/server/agent/bridge/client.ts +37 -0
  21. package/studio/server/agent/bridge/grant.test.ts +62 -0
  22. package/studio/server/agent/bridge/grant.ts +84 -0
  23. package/studio/server/agent/bridge/routes.test.ts +151 -0
  24. package/studio/server/agent/bridge/routes.ts +148 -0
  25. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  26. package/studio/server/agent/conversation.test.ts +84 -0
  27. package/studio/server/agent/conversation.ts +110 -0
  28. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  29. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  30. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  31. package/studio/server/agent/harness/claude/ask.ts +114 -0
  32. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  33. package/studio/server/agent/harness/claude/command.ts +63 -0
  34. package/studio/server/agent/harness/claude/events.ts +215 -0
  35. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  36. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  37. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  38. package/studio/server/agent/harness/claude/skills.ts +43 -0
  39. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  40. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  41. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  42. package/studio/server/agent/harness/codex/ask.ts +181 -0
  43. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  44. package/studio/server/agent/harness/codex/command.ts +43 -0
  45. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  46. package/studio/server/agent/harness/codex/events.ts +132 -0
  47. package/studio/server/agent/harness/codex/exec.ts +113 -0
  48. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  49. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  50. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  51. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  52. package/studio/server/agent/harness/codex/models.ts +211 -0
  53. package/studio/server/agent/harness/codex/skills.ts +33 -0
  54. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  55. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  56. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  57. package/studio/server/agent/harness/gateway/token.ts +166 -0
  58. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  59. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  60. package/studio/server/agent/harness/process.test.ts +46 -0
  61. package/studio/server/agent/harness/process.ts +104 -0
  62. package/studio/server/agent/harness/registry.ts +37 -0
  63. package/studio/server/agent/harness/selection.test.ts +75 -0
  64. package/studio/server/agent/harness/selection.ts +77 -0
  65. package/studio/server/agent/harness/skills.test.ts +95 -0
  66. package/studio/server/agent/harness/skills.ts +100 -0
  67. package/studio/server/agent/layout.test.ts +95 -0
  68. package/studio/server/agent/notify.ts +12 -0
  69. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  70. package/studio/server/agent/prompts/ask.ts +34 -0
  71. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  72. package/studio/server/agent/prompts/turn.ts +57 -0
  73. package/studio/server/agent/routes.test.ts +176 -0
  74. package/studio/server/agent/routes.ts +214 -0
  75. package/studio/server/agent/run/completion.ts +193 -0
  76. package/studio/server/agent/run/coordinator.test.ts +337 -0
  77. package/studio/server/agent/run/coordinator.ts +104 -0
  78. package/studio/server/agent/run/live-state.ts +73 -0
  79. package/studio/server/agent/run/preparation.ts +162 -0
  80. package/studio/server/agent/run/transcript.test.ts +47 -0
  81. package/studio/server/agent/run/transcript.ts +30 -0
  82. package/studio/server/agent/run/usage.test.ts +46 -0
  83. package/studio/server/{state → agent/run}/usage.ts +4 -4
  84. package/studio/server/agent/stream.ts +37 -0
  85. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  86. package/studio/server/api-agent-loadout.test.ts +69 -0
  87. package/studio/server/api.ts +3 -155
  88. package/studio/server/index.ts +6 -5
  89. package/studio/server/state/comments.test.ts +29 -0
  90. package/studio/server/state/comments.ts +3 -3
  91. package/studio/server/state/settings.test.ts +78 -0
  92. package/studio/server/state/settings.ts +28 -1
  93. package/studio/shared/agent-effort.test.ts +12 -0
  94. package/studio/shared/agent-effort.ts +15 -0
  95. package/studio/shared/agent-models.test.ts +9 -0
  96. package/studio/shared/agent-models.ts +14 -0
  97. package/studio/shared/settings-values.test.ts +18 -0
  98. package/studio/shared/settings-values.ts +20 -0
  99. package/studio/shared/types.ts +58 -7
  100. package/studio/tsconfig.json +23 -0
  101. package/viewer/tsconfig.json +13 -0
  102. package/src/connect-core.test.ts +0 -42
  103. package/src/connect-core.ts +0 -53
  104. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  105. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  106. package/studio/server/agent/bridge.ts +0 -188
  107. package/studio/server/agent/claude.ts +0 -678
  108. package/studio/server/agent/mock.ts +0 -186
  109. package/studio/server/agent/registry.ts +0 -29
  110. package/studio/server/agent/runner.ts +0 -487
  111. 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
+ }