@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-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 (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  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/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. package/studio/server/state/harness-token.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  /**
2
- * agent/types.ts — the harness-agnostic contract for "run one agent turn".
2
+ * harness/adapter.ts — the executable boundary implemented by each local agent
3
+ * harness.
3
4
  *
4
5
  * A *harness* is whatever local AI agent does the edits (Claude Code, Codex, …).
5
6
  * The studio never talks to a model API directly; it shells out to the harness
@@ -7,7 +8,13 @@
7
8
  * harness maps its native streaming output onto `AgentStreamEvent`s and returns
8
9
  * a final text blob (which carries the machine-state reply block).
9
10
  */
10
- import type { AgentEffort, AgentEventKind, HarnessLoadout } from '../../shared/types'
11
+ import type {
12
+ AgentAccess,
13
+ AgentEffort,
14
+ AgentEventKind,
15
+ HarnessCapabilities,
16
+ HarnessLoadout,
17
+ } from '../../../shared/types'
11
18
 
12
19
  /** A single normalized activity event emitted while the harness runs. */
13
20
  export interface AgentStreamEvent {
@@ -17,6 +24,21 @@ export interface AgentStreamEvent {
17
24
  target?: string
18
25
  }
19
26
 
27
+ /** Harness-neutral description of one stdio MCP server. Each harness is
28
+ * responsible for expressing this in its native CLI/config shape. */
29
+ export interface HarnessMcpServer {
30
+ name: string
31
+ command: string
32
+ args?: string[]
33
+ env?: Record<string, string>
34
+ required?: boolean
35
+ /** Studio's run-scoped write-back tools are user-authorized by the submit. */
36
+ approvalMode?: 'auto' | 'prompt' | 'writes' | 'approve'
37
+ enabledTools?: string[]
38
+ /** In-process harnesses may invoke the same grant without spawning stdio. */
39
+ invoke?: (tool: string, args: Record<string, unknown>) => Promise<unknown>
40
+ }
41
+
20
42
  export interface AgentTurnInput {
21
43
  /** the domain repo root — the agent's working directory */
22
44
  root: string
@@ -26,13 +48,16 @@ export interface AgentTurnInput {
26
48
  appendSystemPrompt?: string
27
49
  /** prior harness session id → resume the same conversation */
28
50
  sessionId?: string
51
+ /** explicit model override; absent preserves the harness's native config/default */
52
+ model?: string
29
53
  /** harness reasoning effort for this turn */
30
54
  effort?: AgentEffort
31
- /** path to a generated MCP config exposing the studio write-back bridge */
32
- mcpConfigPath?: string
33
- /** extra env merged into the harness CHILD process only (e.g. ANTHROPIC_BASE_URL
34
- * / ANTHROPIC_AUTH_TOKEN to route through a custom model gateway). Never touches
35
- * the studio's own env or the user's shell / global `claude`. */
55
+ /** authority granted to the local harness */
56
+ access?: AgentAccess
57
+ /** generated MCP servers exposing Studio write-back */
58
+ mcpServers?: HarnessMcpServer[]
59
+ /** extra env merged into the harness CHILD process only. Never touches the
60
+ * studio's own env or the user's shell/global harness process. */
36
61
  env?: Record<string, string>
37
62
  /** abort the turn (user pressed Cancel) */
38
63
  signal: AbortSignal
@@ -47,7 +72,7 @@ export interface AgentTurnResult {
47
72
  finalText: string
48
73
  costUsd?: number
49
74
  numTurns?: number
50
- /** total tokens processed (input + output + cache) */
75
+ /** total token usage reported by the harness */
51
76
  tokens?: number
52
77
  isError: boolean
53
78
  errorMessage?: string
@@ -68,9 +93,13 @@ export interface AskInput {
68
93
  appendSystemPrompt?: string
69
94
  /** the domain's live conversation session id to FORK from (undefined ⇒ fresh, no fork) */
70
95
  sessionId?: string
96
+ /** explicit model override; absent preserves the harness's native config/default */
97
+ model?: string
71
98
  /** harness reasoning effort for this side question */
72
99
  effort?: AgentEffort
73
- /** extra env merged into the harness child only (custom model gateway) — see AgentTurnInput.env */
100
+ /** authority granted to the local harness */
101
+ access?: AgentAccess
102
+ /** extra env merged into the harness child only — see AgentTurnInput.env */
74
103
  env?: Record<string, string>
75
104
  signal: AbortSignal
76
105
  /** called with each chunk of the answer as it streams in */
@@ -93,16 +122,26 @@ export interface HarnessHealth {
93
122
  detail?: string
94
123
  }
95
124
 
125
+ export interface HarnessLoadoutOptions {
126
+ /** Child-only environment overrides used while probing the harness. */
127
+ env?: Record<string, string>
128
+ /** Studio's per-domain override for the selected harness. */
129
+ model?: string
130
+ /** Bypass the adapter's short-lived probe cache for an explicit user re-probe. */
131
+ refresh?: boolean
132
+ }
133
+
96
134
  export interface AgentHarness {
97
135
  /** stable id, e.g. 'claude' | 'mock' | 'codex' */
98
136
  id: string
99
137
  /** human label for the UI */
100
138
  label: string
139
+ capabilities: HarnessCapabilities
101
140
  /** is the underlying CLI/binary actually invokable here? */
102
- isAvailable(): Promise<boolean>
141
+ isAvailable(signal?: AbortSignal): Promise<boolean>
103
142
  /** optional: a richer install probe for the UI (version + reason). Harnesses that
104
143
  * omit it fall back to isAvailable(). */
105
- health?(): Promise<HarnessHealth>
144
+ health?(signal?: AbortSignal): Promise<HarnessHealth>
106
145
  run(input: AgentTurnInput): Promise<AgentTurnResult>
107
146
  /** optional: answer a quick forked side question (Ask). Harnesses without a
108
147
  * fork concept simply omit this; the studio falls back gracefully. */
@@ -111,10 +150,22 @@ export interface AgentHarness {
111
150
  * tools, agents) — a read-only window into the agent for the Settings page.
112
151
  * Harnesses without an introspectable loadout omit it. `env` (e.g. a custom
113
152
  * model gateway) is merged into the probe child so the reported model reflects it. */
114
- loadout?(root: string, env?: Record<string, string>): Promise<HarnessLoadout>
153
+ loadout?(root: string, options?: HarnessLoadoutOptions): Promise<HarnessLoadout>
115
154
  /** optional: the raw SKILL.md for a skill command, so the UI can show it. */
116
155
  skillContent?(
117
156
  root: string,
118
157
  command: string,
119
158
  ): Promise<{ command: string; content: string; path: string } | null>
120
159
  }
160
+
161
+ /** Normalize optional rich health probes into one route-facing result. */
162
+ export async function inspectHarnessHealth(
163
+ harness: AgentHarness,
164
+ signal?: AbortSignal,
165
+ ): Promise<HarnessHealth> {
166
+ if (harness.health) return harness.health(signal)
167
+ return {
168
+ ok: await harness.isAvailable(signal),
169
+ bin: harness.id,
170
+ }
171
+ }
@@ -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