@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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import type { AskInput, AskResult } from '../adapter'
|
|
4
|
+
|
|
5
|
+
import { childEnvironment, terminateProcessTree } from '../process'
|
|
6
|
+
|
|
7
|
+
const ASK_TIMEOUT_MS = 120_000
|
|
8
|
+
|
|
9
|
+
function sandboxFor(access: AskInput['access']): 'workspace-write' | 'danger-full-access' {
|
|
10
|
+
return access === 'workspace' ? 'workspace-write' : 'danger-full-access'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Use Codex's rich-client protocol only for the capability missing from
|
|
14
|
+
* `codex exec`: fork a conversation without mutating its parent. */
|
|
15
|
+
export function runCodexForkAsk(bin: string, input: AskInput): Promise<AskResult> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const child = spawn(bin, ['app-server', '--stdio'], {
|
|
18
|
+
cwd: input.root,
|
|
19
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
env: childEnvironment(input.env),
|
|
21
|
+
detached: process.platform !== 'win32',
|
|
22
|
+
})
|
|
23
|
+
let finished = false
|
|
24
|
+
let threadId = ''
|
|
25
|
+
let turnId = ''
|
|
26
|
+
let text = ''
|
|
27
|
+
let finalText = ''
|
|
28
|
+
let stderr = ''
|
|
29
|
+
let nextId = 1
|
|
30
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
31
|
+
|
|
32
|
+
const send = (message: unknown) => child.stdin?.write(`${JSON.stringify(message)}\n`)
|
|
33
|
+
const request = (method: string, params: unknown, id = nextId++) => {
|
|
34
|
+
send({ method, id, params })
|
|
35
|
+
return id
|
|
36
|
+
}
|
|
37
|
+
const finish = (result: AskResult) => {
|
|
38
|
+
if (finished) return
|
|
39
|
+
finished = true
|
|
40
|
+
if (timer) clearTimeout(timer)
|
|
41
|
+
input.signal.removeEventListener('abort', onAbort)
|
|
42
|
+
try {
|
|
43
|
+
terminateProcessTree(child)
|
|
44
|
+
} catch {
|
|
45
|
+
/* already gone */
|
|
46
|
+
}
|
|
47
|
+
resolve(result)
|
|
48
|
+
}
|
|
49
|
+
const fail = (message: string) =>
|
|
50
|
+
finish({ text: finalText || text, isError: true, errorMessage: message })
|
|
51
|
+
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
if (threadId && turnId) request('turn/interrupt', { threadId, turnId }, 99)
|
|
54
|
+
finish({ text: finalText || text, isError: true, errorMessage: 'canceled' })
|
|
55
|
+
}
|
|
56
|
+
if (input.signal.aborted) return onAbort()
|
|
57
|
+
input.signal.addEventListener('abort', onAbort, { once: true })
|
|
58
|
+
|
|
59
|
+
timer = setTimeout(() => fail('Codex side question timed out'), ASK_TIMEOUT_MS)
|
|
60
|
+
const commonThread = {
|
|
61
|
+
cwd: input.root,
|
|
62
|
+
approvalPolicy: 'never',
|
|
63
|
+
sandbox: sandboxFor(input.access),
|
|
64
|
+
developerInstructions: input.appendSystemPrompt ?? null,
|
|
65
|
+
ephemeral: true,
|
|
66
|
+
model: input.model ?? null,
|
|
67
|
+
config: { 'mcp_servers.domain-studio.enabled': false },
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
child.stdout?.setEncoding('utf8')
|
|
71
|
+
let buffer = ''
|
|
72
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
73
|
+
buffer += chunk
|
|
74
|
+
let newline: number
|
|
75
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
76
|
+
const line = buffer.slice(0, newline).trim()
|
|
77
|
+
buffer = buffer.slice(newline + 1)
|
|
78
|
+
if (!line) continue
|
|
79
|
+
let message: any
|
|
80
|
+
try {
|
|
81
|
+
message = JSON.parse(line)
|
|
82
|
+
} catch {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (message.id === 1) {
|
|
87
|
+
if (message.error) return fail(message.error.message ?? 'Codex app-server init failed')
|
|
88
|
+
send({ method: 'initialized', params: {} })
|
|
89
|
+
if (input.sessionId)
|
|
90
|
+
request('thread/fork', { threadId: input.sessionId, ...commonThread }, 2)
|
|
91
|
+
else request('thread/start', commonThread, 2)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (message.id === 2) {
|
|
95
|
+
if (message.error) return fail(message.error.message ?? 'Codex thread fork failed')
|
|
96
|
+
threadId = message.result?.thread?.id ?? ''
|
|
97
|
+
if (!threadId) return fail('Codex app-server returned no thread id')
|
|
98
|
+
request(
|
|
99
|
+
'turn/start',
|
|
100
|
+
{
|
|
101
|
+
threadId,
|
|
102
|
+
input: [{ type: 'text', text: input.prompt, text_elements: [] }],
|
|
103
|
+
model: input.model ?? null,
|
|
104
|
+
effort:
|
|
105
|
+
input.effort === 'max' || input.effort === 'ultracode'
|
|
106
|
+
? 'xhigh'
|
|
107
|
+
: (input.effort ?? null),
|
|
108
|
+
},
|
|
109
|
+
3,
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (message.id === 3) {
|
|
114
|
+
if (message.error)
|
|
115
|
+
return fail(message.error.message ?? 'Codex side question failed to start')
|
|
116
|
+
turnId = message.result?.turn?.id ?? turnId
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (message.method === 'turn/started') {
|
|
120
|
+
turnId = message.params?.turn?.id ?? turnId
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (message.method === 'item/agentMessage/delta') {
|
|
124
|
+
const delta = typeof message.params?.delta === 'string' ? message.params.delta : ''
|
|
125
|
+
if (delta) {
|
|
126
|
+
text += delta
|
|
127
|
+
input.onDelta(delta)
|
|
128
|
+
}
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (message.method === 'item/completed') {
|
|
132
|
+
const item = message.params?.item
|
|
133
|
+
if (item?.type === 'agentMessage' && typeof item.text === 'string') {
|
|
134
|
+
finalText = item.text
|
|
135
|
+
if (!text && finalText) {
|
|
136
|
+
text = finalText
|
|
137
|
+
input.onDelta(finalText)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (message.method === 'error' && message.params?.willRetry !== true) {
|
|
143
|
+
return fail(message.params?.error?.message ?? 'Codex side question failed')
|
|
144
|
+
}
|
|
145
|
+
if (message.method === 'turn/completed') {
|
|
146
|
+
const turn = message.params?.turn
|
|
147
|
+
if (turn?.status !== 'completed')
|
|
148
|
+
return fail(
|
|
149
|
+
turn?.error?.message ??
|
|
150
|
+
`Codex side question ended with status ${turn?.status ?? 'unknown'}`,
|
|
151
|
+
)
|
|
152
|
+
finish({ text: finalText || text, isError: false })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
child.stderr?.setEncoding('utf8')
|
|
157
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
158
|
+
stderr += chunk
|
|
159
|
+
})
|
|
160
|
+
child.on('error', (error) => fail(`failed to spawn ${bin} app-server: ${error.message}`))
|
|
161
|
+
child.on('close', (code) => {
|
|
162
|
+
if (!finished)
|
|
163
|
+
fail(
|
|
164
|
+
`Codex app-server exited ${code ?? -1}${stderr ? `: ${stderr.trim().slice(-400)}` : ''}`,
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
request(
|
|
169
|
+
'initialize',
|
|
170
|
+
{
|
|
171
|
+
clientInfo: {
|
|
172
|
+
name: 'astrale_domain_studio',
|
|
173
|
+
title: 'Astrale Domain Studio',
|
|
174
|
+
version: '0.1.0',
|
|
175
|
+
},
|
|
176
|
+
capabilities: null,
|
|
177
|
+
},
|
|
178
|
+
1,
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildCodexArgs } from './command'
|
|
4
|
+
|
|
5
|
+
describe('Codex command construction', () => {
|
|
6
|
+
test('builds a new stable JSONL turn with developer instructions and approved MCP tools', () => {
|
|
7
|
+
const args = buildCodexArgs({
|
|
8
|
+
appendSystemPrompt: 'Studio protocol\nwith a newline',
|
|
9
|
+
model: 'gpt-studio',
|
|
10
|
+
effort: 'high',
|
|
11
|
+
access: 'workspace',
|
|
12
|
+
mcpServers: [
|
|
13
|
+
{
|
|
14
|
+
name: 'domain-studio',
|
|
15
|
+
command: '/opt/homebrew/bin/bun',
|
|
16
|
+
args: ['/studio/bridge/stdio.ts', '--config', '/domain/.domain-studio/bridge.json'],
|
|
17
|
+
required: true,
|
|
18
|
+
approvalMode: 'approve',
|
|
19
|
+
enabledTools: ['reply_to_thread'],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
expect(args).toEqual([
|
|
24
|
+
'exec',
|
|
25
|
+
'--json',
|
|
26
|
+
'--skip-git-repo-check',
|
|
27
|
+
'-c',
|
|
28
|
+
'approval_policy="never"',
|
|
29
|
+
'-c',
|
|
30
|
+
'sandbox_mode="workspace-write"',
|
|
31
|
+
'--model',
|
|
32
|
+
'gpt-studio',
|
|
33
|
+
'-c',
|
|
34
|
+
'model_reasoning_effort="high"',
|
|
35
|
+
'-c',
|
|
36
|
+
'developer_instructions="Studio protocol\\nwith a newline"',
|
|
37
|
+
'-c',
|
|
38
|
+
'mcp_servers.domain-studio.command="/opt/homebrew/bin/bun"',
|
|
39
|
+
'-c',
|
|
40
|
+
'mcp_servers.domain-studio.args=["/studio/bridge/stdio.ts","--config","/domain/.domain-studio/bridge.json"]',
|
|
41
|
+
'-c',
|
|
42
|
+
'mcp_servers.domain-studio.required=true',
|
|
43
|
+
'-c',
|
|
44
|
+
'mcp_servers.domain-studio.default_tools_approval_mode="approve"',
|
|
45
|
+
'-c',
|
|
46
|
+
'mcp_servers.domain-studio.enabled_tools=["reply_to_thread"]',
|
|
47
|
+
'-',
|
|
48
|
+
])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('resumes by thread id, maps Claude-only effort modes, and can be ephemeral', () => {
|
|
52
|
+
const args = buildCodexArgs(
|
|
53
|
+
{
|
|
54
|
+
sessionId: '019f-thread',
|
|
55
|
+
effort: 'max',
|
|
56
|
+
access: 'full',
|
|
57
|
+
},
|
|
58
|
+
true,
|
|
59
|
+
)
|
|
60
|
+
expect(args.slice(0, 3)).toEqual(['exec', 'resume', '019f-thread'])
|
|
61
|
+
expect(args.join(' ')).toContain('model_reasoning_effort="xhigh"')
|
|
62
|
+
expect(args.join(' ')).toContain('sandbox_mode="danger-full-access"')
|
|
63
|
+
expect(args).toContain('--ephemeral')
|
|
64
|
+
|
|
65
|
+
expect(buildCodexArgs({ effort: 'ultracode' }).join(' ')).toContain(
|
|
66
|
+
'model_reasoning_effort="xhigh"',
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AgentAccess, AgentEffort } from '../../../../shared/types'
|
|
2
|
+
import type { AgentTurnInput } from '../adapter'
|
|
3
|
+
|
|
4
|
+
import { codexMcpConfigArgs, tomlString } from './mcp'
|
|
5
|
+
|
|
6
|
+
const EXTRA_ARGS = (process.env.DOMAIN_STUDIO_CODEX_ARGS || '').split(' ').filter(Boolean)
|
|
7
|
+
|
|
8
|
+
function sandbox(access?: AgentAccess): 'workspace-write' | 'danger-full-access' {
|
|
9
|
+
return access === 'workspace' ? 'workspace-write' : 'danger-full-access'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeEffort(effort?: AgentEffort): AgentEffort | undefined {
|
|
13
|
+
return effort === 'max' || effort === 'ultracode' ? 'xhigh' : effort
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Build one `codex exec` invocation from the harness-neutral turn. */
|
|
17
|
+
export function buildCodexArgs(
|
|
18
|
+
input: Pick<
|
|
19
|
+
AgentTurnInput,
|
|
20
|
+
'appendSystemPrompt' | 'sessionId' | 'model' | 'effort' | 'access' | 'mcpServers'
|
|
21
|
+
>,
|
|
22
|
+
ephemeral = false,
|
|
23
|
+
): string[] {
|
|
24
|
+
const args = ['exec']
|
|
25
|
+
if (input.sessionId) args.push('resume', input.sessionId)
|
|
26
|
+
args.push(
|
|
27
|
+
'--json',
|
|
28
|
+
'--skip-git-repo-check',
|
|
29
|
+
'-c',
|
|
30
|
+
'approval_policy="never"',
|
|
31
|
+
'-c',
|
|
32
|
+
`sandbox_mode=${tomlString(sandbox(input.access))}`,
|
|
33
|
+
)
|
|
34
|
+
const effort = normalizeEffort(input.effort)
|
|
35
|
+
if (input.model) args.push('--model', input.model)
|
|
36
|
+
if (effort) args.push('-c', `model_reasoning_effort=${tomlString(effort)}`)
|
|
37
|
+
if (input.appendSystemPrompt)
|
|
38
|
+
args.push('-c', `developer_instructions=${tomlString(input.appendSystemPrompt)}`)
|
|
39
|
+
args.push(...codexMcpConfigArgs(input.mcpServers))
|
|
40
|
+
if (ephemeral) args.push('--ephemeral')
|
|
41
|
+
args.push(...EXTRA_ARGS, '-')
|
|
42
|
+
return args
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { handleCodexExecEvent, newCodexRunState } from './events'
|
|
4
|
+
|
|
5
|
+
describe('Codex exec JSONL normalization', () => {
|
|
6
|
+
test('captures session, activity, final text, and non-duplicated token usage', () => {
|
|
7
|
+
const state = newCodexRunState()
|
|
8
|
+
const events: any[] = []
|
|
9
|
+
const onEvent = (event: any) => events.push(event)
|
|
10
|
+
|
|
11
|
+
handleCodexExecEvent({ type: 'thread.started', thread_id: '019f-test-thread' }, state, onEvent)
|
|
12
|
+
handleCodexExecEvent(
|
|
13
|
+
{
|
|
14
|
+
type: 'item.started',
|
|
15
|
+
item: { id: 'cmd-1', type: 'command_execution', command: 'pnpm typecheck' },
|
|
16
|
+
},
|
|
17
|
+
state,
|
|
18
|
+
onEvent,
|
|
19
|
+
)
|
|
20
|
+
handleCodexExecEvent(
|
|
21
|
+
{
|
|
22
|
+
type: 'item.completed',
|
|
23
|
+
item: { id: 'cmd-1', type: 'command_execution', command: 'pnpm typecheck' },
|
|
24
|
+
},
|
|
25
|
+
state,
|
|
26
|
+
onEvent,
|
|
27
|
+
)
|
|
28
|
+
handleCodexExecEvent(
|
|
29
|
+
{
|
|
30
|
+
type: 'item.completed',
|
|
31
|
+
item: { id: 'msg-1', type: 'agent_message', text: 'Done.' },
|
|
32
|
+
},
|
|
33
|
+
state,
|
|
34
|
+
onEvent,
|
|
35
|
+
)
|
|
36
|
+
handleCodexExecEvent(
|
|
37
|
+
{
|
|
38
|
+
type: 'turn.completed',
|
|
39
|
+
usage: {
|
|
40
|
+
input_tokens: 100,
|
|
41
|
+
cached_input_tokens: 70,
|
|
42
|
+
output_tokens: 20,
|
|
43
|
+
reasoning_output_tokens: 5,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
state,
|
|
47
|
+
onEvent,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expect(state.sessionId).toBe('019f-test-thread')
|
|
51
|
+
expect(state.finalText).toBe('Done.')
|
|
52
|
+
expect(state.tokens).toBe(120)
|
|
53
|
+
expect(events).toEqual([
|
|
54
|
+
{ kind: 'status', text: 'session started' },
|
|
55
|
+
{
|
|
56
|
+
kind: 'tool',
|
|
57
|
+
text: 'Shell',
|
|
58
|
+
tool: 'Shell',
|
|
59
|
+
target: 'pnpm typecheck',
|
|
60
|
+
},
|
|
61
|
+
{ kind: 'message', text: 'Done.' },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('maps MCP calls and failures', () => {
|
|
66
|
+
const state = newCodexRunState('existing')
|
|
67
|
+
const events: any[] = []
|
|
68
|
+
handleCodexExecEvent(
|
|
69
|
+
{
|
|
70
|
+
type: 'item.started',
|
|
71
|
+
item: {
|
|
72
|
+
id: 'mcp-1',
|
|
73
|
+
type: 'mcp_tool_call',
|
|
74
|
+
server: 'domain-studio',
|
|
75
|
+
tool: 'reply_to_thread',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
state,
|
|
79
|
+
(event) => events.push(event),
|
|
80
|
+
)
|
|
81
|
+
handleCodexExecEvent(
|
|
82
|
+
{ type: 'turn.failed', error: { message: 'tool failed' } },
|
|
83
|
+
state,
|
|
84
|
+
(event) => events.push(event),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(events[0]).toMatchObject({
|
|
88
|
+
kind: 'tool',
|
|
89
|
+
tool: 'domain-studio:reply_to_thread',
|
|
90
|
+
})
|
|
91
|
+
expect(state.isError).toBe(true)
|
|
92
|
+
expect(state.errorMessage).toBe('tool failed')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AgentStreamEvent } from '../adapter'
|
|
2
|
+
|
|
3
|
+
export interface CodexRunState {
|
|
4
|
+
sessionId?: string
|
|
5
|
+
finalText: string
|
|
6
|
+
tokens?: number
|
|
7
|
+
isError: boolean
|
|
8
|
+
errorMessage?: string
|
|
9
|
+
startedItems: Set<string>
|
|
10
|
+
terminal: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function newCodexRunState(sessionId?: string): CodexRunState {
|
|
14
|
+
return {
|
|
15
|
+
sessionId,
|
|
16
|
+
finalText: '',
|
|
17
|
+
isError: false,
|
|
18
|
+
startedItems: new Set(),
|
|
19
|
+
terminal: false,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function text(value: unknown): string {
|
|
24
|
+
return typeof value === 'string' ? value : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fileChangeTarget(item: any): string {
|
|
28
|
+
const paths = Array.isArray(item?.changes)
|
|
29
|
+
? item.changes
|
|
30
|
+
.map((change: any) => text(change?.path ?? change?.file ?? change?.file_path))
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
: []
|
|
33
|
+
return paths.slice(0, 3).join(', ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toolEvent(item: any): AgentStreamEvent | null {
|
|
37
|
+
switch (item?.type) {
|
|
38
|
+
case 'command_execution':
|
|
39
|
+
return {
|
|
40
|
+
kind: 'tool',
|
|
41
|
+
text: 'Shell',
|
|
42
|
+
tool: 'Shell',
|
|
43
|
+
target: text(item.command).slice(0, 200),
|
|
44
|
+
}
|
|
45
|
+
case 'file_change':
|
|
46
|
+
return {
|
|
47
|
+
kind: 'tool',
|
|
48
|
+
text: 'Edit',
|
|
49
|
+
tool: 'Edit',
|
|
50
|
+
target: fileChangeTarget(item),
|
|
51
|
+
}
|
|
52
|
+
case 'mcp_tool_call':
|
|
53
|
+
return {
|
|
54
|
+
kind: 'tool',
|
|
55
|
+
text: text(item.tool) || 'MCP',
|
|
56
|
+
tool: `${text(item.server)}:${text(item.tool)}`.replace(/^:/, ''),
|
|
57
|
+
target: text(item.tool),
|
|
58
|
+
}
|
|
59
|
+
case 'web_search':
|
|
60
|
+
return {
|
|
61
|
+
kind: 'tool',
|
|
62
|
+
text: 'Web search',
|
|
63
|
+
tool: 'WebSearch',
|
|
64
|
+
target: text(item.query),
|
|
65
|
+
}
|
|
66
|
+
case 'plan':
|
|
67
|
+
return { kind: 'status', text: text(item.text) || 'plan updated' }
|
|
68
|
+
default:
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Normalize one `codex exec --json` JSONL record into Studio's compact event
|
|
74
|
+
* contract while accumulating the final result and usage. */
|
|
75
|
+
export function handleCodexExecEvent(
|
|
76
|
+
event: any,
|
|
77
|
+
state: CodexRunState,
|
|
78
|
+
onEvent: (event: AgentStreamEvent) => void,
|
|
79
|
+
): void {
|
|
80
|
+
switch (event?.type) {
|
|
81
|
+
case 'thread.started':
|
|
82
|
+
if (typeof event.thread_id === 'string') state.sessionId = event.thread_id
|
|
83
|
+
onEvent({ kind: 'status', text: 'session started' })
|
|
84
|
+
return
|
|
85
|
+
case 'item.started': {
|
|
86
|
+
const normalized = toolEvent(event.item)
|
|
87
|
+
if (normalized) {
|
|
88
|
+
if (typeof event.item?.id === 'string') state.startedItems.add(event.item.id)
|
|
89
|
+
onEvent(normalized)
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
case 'item.completed': {
|
|
94
|
+
const item = event.item
|
|
95
|
+
if (item?.type === 'agent_message' && text(item.text).trim()) {
|
|
96
|
+
state.finalText = item.text
|
|
97
|
+
onEvent({ kind: 'message', text: item.text.trim() })
|
|
98
|
+
} else if (item?.type === 'reasoning') {
|
|
99
|
+
const reasoning =
|
|
100
|
+
text(item.text) ||
|
|
101
|
+
(Array.isArray(item.summary)
|
|
102
|
+
? item.summary.filter((part: unknown) => typeof part === 'string').join('\n')
|
|
103
|
+
: '')
|
|
104
|
+
if (reasoning.trim()) onEvent({ kind: 'thinking', text: reasoning.trim() })
|
|
105
|
+
} else if (!state.startedItems.has(item?.id)) {
|
|
106
|
+
const normalized = toolEvent(item)
|
|
107
|
+
if (normalized) onEvent(normalized)
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
case 'turn.completed': {
|
|
112
|
+
state.terminal = true
|
|
113
|
+
const usage = event.usage
|
|
114
|
+
if (usage && typeof usage === 'object')
|
|
115
|
+
state.tokens = Number(usage.input_tokens ?? 0) + Number(usage.output_tokens ?? 0)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
case 'turn.failed':
|
|
119
|
+
state.terminal = true
|
|
120
|
+
state.isError = true
|
|
121
|
+
state.errorMessage =
|
|
122
|
+
text(event.error?.message) ||
|
|
123
|
+
text(event.message) ||
|
|
124
|
+
text(event.error) ||
|
|
125
|
+
'Codex turn failed'
|
|
126
|
+
return
|
|
127
|
+
case 'error':
|
|
128
|
+
state.isError = true
|
|
129
|
+
state.errorMessage =
|
|
130
|
+
text(event.message) || text(event.error?.message) || text(event.error) || 'Codex error'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
import type { AgentTurnInput, AgentTurnResult } from '../adapter'
|
|
4
|
+
|
|
5
|
+
import { childEnvironment, terminateProcessTree } from '../process'
|
|
6
|
+
import { buildCodexArgs } from './command'
|
|
7
|
+
import { handleCodexExecEvent, newCodexRunState, type CodexRunState } from './events'
|
|
8
|
+
|
|
9
|
+
const RESUME_REJECTED =
|
|
10
|
+
/no rollout found for thread id|thread\/resume failed|thread (?:id )?.*(?:not found|does not exist|expired)|could not (?:find|load|resume) .*thread|unknown thread|invalid thread id/i
|
|
11
|
+
|
|
12
|
+
/** Execute one Codex JSONL turn and normalize its result. */
|
|
13
|
+
export function runCodexExec(
|
|
14
|
+
bin: string,
|
|
15
|
+
input: AgentTurnInput,
|
|
16
|
+
ephemeral = false,
|
|
17
|
+
): Promise<AgentTurnResult> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const state = newCodexRunState(input.sessionId)
|
|
20
|
+
let stderr = ''
|
|
21
|
+
let settled = false
|
|
22
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
|
|
23
|
+
let onAbort = () => {}
|
|
24
|
+
const finish = (result: AgentTurnResult) => {
|
|
25
|
+
if (settled) return
|
|
26
|
+
settled = true
|
|
27
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
28
|
+
input.signal.removeEventListener('abort', onAbort)
|
|
29
|
+
resolve(result)
|
|
30
|
+
}
|
|
31
|
+
const resultFromState = (current: CodexRunState, errorMessage?: string): AgentTurnResult => ({
|
|
32
|
+
sessionId: current.sessionId,
|
|
33
|
+
finalText: current.finalText,
|
|
34
|
+
tokens: current.tokens,
|
|
35
|
+
numTurns: 1,
|
|
36
|
+
isError: current.isError || !!errorMessage,
|
|
37
|
+
errorMessage: errorMessage ?? current.errorMessage,
|
|
38
|
+
resumeRejected:
|
|
39
|
+
!!input.sessionId &&
|
|
40
|
+
(current.isError || !!errorMessage) &&
|
|
41
|
+
RESUME_REJECTED.test(`${errorMessage ?? current.errorMessage ?? ''}\n${stderr}`),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
let child: ReturnType<typeof spawn>
|
|
45
|
+
try {
|
|
46
|
+
child = spawn(bin, buildCodexArgs(input, ephemeral), {
|
|
47
|
+
cwd: input.root,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
env: childEnvironment(input.env),
|
|
50
|
+
detached: process.platform !== 'win32',
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
finish(resultFromState(state, `failed to spawn ${bin}: ${String(error)}`))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
onAbort = () => {
|
|
57
|
+
terminateProcessTree(child)
|
|
58
|
+
forceKillTimer = setTimeout(() => terminateProcessTree(child, 'SIGKILL'), 2_000)
|
|
59
|
+
forceKillTimer.unref?.()
|
|
60
|
+
}
|
|
61
|
+
if (input.signal.aborted) onAbort()
|
|
62
|
+
else input.signal.addEventListener('abort', onAbort, { once: true })
|
|
63
|
+
|
|
64
|
+
child.stdin?.write(input.prompt)
|
|
65
|
+
child.stdin?.end()
|
|
66
|
+
|
|
67
|
+
let buffer = ''
|
|
68
|
+
const handleLine = (line: string) => {
|
|
69
|
+
let event: any
|
|
70
|
+
try {
|
|
71
|
+
event = JSON.parse(line)
|
|
72
|
+
} catch {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
handleCodexExecEvent(event, state, input.onEvent)
|
|
76
|
+
}
|
|
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(resultFromState(state, `failed to spawn ${bin}: ${error.message}`)),
|
|
93
|
+
)
|
|
94
|
+
child.on('close', (code) => {
|
|
95
|
+
const trailing = buffer.trim()
|
|
96
|
+
if (trailing) handleLine(trailing)
|
|
97
|
+
if (input.signal.aborted)
|
|
98
|
+
return finish({
|
|
99
|
+
...resultFromState(state, 'canceled'),
|
|
100
|
+
isError: true,
|
|
101
|
+
errorMessage: 'canceled',
|
|
102
|
+
})
|
|
103
|
+
const exitError =
|
|
104
|
+
code !== 0
|
|
105
|
+
? state.errorMessage ||
|
|
106
|
+
`codex exited ${code ?? -1}${stderr ? `: ${stderr.trim().slice(-500)}` : ''}`
|
|
107
|
+
: !state.terminal && !state.isError
|
|
108
|
+
? 'codex exited without a terminal turn event'
|
|
109
|
+
: undefined
|
|
110
|
+
finish(resultFromState(state, exitError))
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { HarnessLoadout } from '../../../../shared/types'
|
|
2
|
+
import type { HarnessLoadoutOptions } from '../adapter'
|
|
3
|
+
|
|
4
|
+
import { captureCommand } from '../process'
|
|
5
|
+
import { probeCodexModels } from './models'
|
|
6
|
+
import { scanCodexSkills, type CodexPlugin } from './skills'
|
|
7
|
+
|
|
8
|
+
function parseJson<T>(text: string, fallback: T): T {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(text) as T
|
|
11
|
+
} catch {
|
|
12
|
+
return fallback
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Inspect Codex configuration, plugins, skills, MCP servers, and models. */
|
|
17
|
+
export async function loadCodexConfiguration(
|
|
18
|
+
bin: string,
|
|
19
|
+
root: string,
|
|
20
|
+
options?: HarnessLoadoutOptions,
|
|
21
|
+
): Promise<HarnessLoadout> {
|
|
22
|
+
const [mcpResult, pluginResult, modelResult] = await Promise.all([
|
|
23
|
+
captureCommand(bin, ['mcp', 'list', '--json'], root),
|
|
24
|
+
captureCommand(bin, ['plugin', 'list', '--json'], root),
|
|
25
|
+
probeCodexModels(bin, root, options?.model, options?.env),
|
|
26
|
+
])
|
|
27
|
+
const pluginWire = parseJson<any>(pluginResult.stdout, {})
|
|
28
|
+
const plugins: CodexPlugin[] = (Array.isArray(pluginWire?.installed) ? pluginWire.installed : [])
|
|
29
|
+
.map((plugin: any) => ({
|
|
30
|
+
name: String(plugin?.name ?? plugin?.pluginId?.split('@')[0] ?? ''),
|
|
31
|
+
path: String(plugin?.source?.path ?? ''),
|
|
32
|
+
enabled: plugin?.enabled === true,
|
|
33
|
+
}))
|
|
34
|
+
.filter((plugin: CodexPlugin) => !!plugin.name && !!plugin.path)
|
|
35
|
+
const mcpWire = parseJson<any[]>(mcpResult.stdout, [])
|
|
36
|
+
return {
|
|
37
|
+
ok: mcpResult.code === 0 && pluginResult.code === 0 && modelResult.ok,
|
|
38
|
+
detail:
|
|
39
|
+
mcpResult.code === 0 && pluginResult.code === 0 && modelResult.ok
|
|
40
|
+
? `${modelResult.detail ?? 'Codex model resolved.'} Runtime tools are discovered when a turn starts.`
|
|
41
|
+
: (
|
|
42
|
+
mcpResult.stderr ||
|
|
43
|
+
pluginResult.stderr ||
|
|
44
|
+
modelResult.detail ||
|
|
45
|
+
'Codex loadout probe failed'
|
|
46
|
+
).trim(),
|
|
47
|
+
model: modelResult.model,
|
|
48
|
+
nativeModel: modelResult.nativeModel,
|
|
49
|
+
modelSource: modelResult.modelSource,
|
|
50
|
+
models: modelResult.models,
|
|
51
|
+
cwd: root,
|
|
52
|
+
tools: [],
|
|
53
|
+
mcpServers: mcpWire.map((server) => ({
|
|
54
|
+
name: String(server?.name ?? ''),
|
|
55
|
+
status: server?.enabled === false ? 'disabled' : String(server?.auth_status ?? 'configured'),
|
|
56
|
+
})),
|
|
57
|
+
skills: scanCodexSkills(root, plugins),
|
|
58
|
+
agents: [],
|
|
59
|
+
builtinCommandCount: 0,
|
|
60
|
+
probedAt: Date.now(),
|
|
61
|
+
source: 'configured',
|
|
62
|
+
}
|
|
63
|
+
}
|