@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
@@ -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
+ }