@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.
- package/README.md +3 -2
- package/dist/astrale.js +118 -95
- package/package.json +2 -3
- 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/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -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/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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
/**
|
|
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,
|
|
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
|