@astrale-os/cli 0.6.3-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 (126) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +339 -242
  3. package/package.json +2 -3
  4. package/src/commands/__tests__/admin-instance.test.ts +49 -2
  5. package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
  6. package/src/commands/__tests__/help-contract.test.ts +15 -0
  7. package/src/commands/domain/install.ts +15 -4
  8. package/src/commands/instance/active.ts +4 -7
  9. package/src/commands/instance/list.ts +4 -8
  10. package/src/commands/instance/status.ts +14 -9
  11. package/src/commands/instance/use.ts +13 -11
  12. package/src/commands/studio.ts +13 -0
  13. package/src/commands/view.ts +40 -13
  14. package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
  15. package/src/kernel/client.ts +47 -20
  16. package/src/kernel/expand.ts +3 -22
  17. package/src/lib/__tests__/instance-candidates.test.ts +94 -19
  18. package/src/lib/__tests__/instance-target.test.ts +9 -1
  19. package/src/lib/__tests__/view-assets.test.ts +114 -0
  20. package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
  21. package/src/lib/admin-instance.ts +27 -2
  22. package/src/lib/instance-candidates.ts +3 -3
  23. package/src/lib/instance-target.ts +1 -0
  24. package/src/lib/instance.ts +0 -11
  25. package/src/lib/login-flow.ts +4 -41
  26. package/src/lib/view/assets.ts +64 -0
  27. package/src/lib/view/port-allocation.ts +19 -0
  28. package/src/lib/view/server.ts +4 -37
  29. package/src/setup/__tests__/instance-step.test.ts +239 -0
  30. package/src/setup/steps/instance.ts +141 -61
  31. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  32. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  33. package/studio/client/dist/index.html +2 -2
  34. package/studio/server/agent/ask.test.ts +74 -0
  35. package/studio/server/agent/ask.ts +20 -22
  36. package/studio/server/agent/bridge/client.test.ts +49 -0
  37. package/studio/server/agent/bridge/client.ts +37 -0
  38. package/studio/server/agent/bridge/grant.test.ts +62 -0
  39. package/studio/server/agent/bridge/grant.ts +84 -0
  40. package/studio/server/agent/bridge/routes.test.ts +151 -0
  41. package/studio/server/agent/bridge/routes.ts +148 -0
  42. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  43. package/studio/server/agent/conversation.test.ts +84 -0
  44. package/studio/server/agent/conversation.ts +110 -0
  45. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  46. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  47. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  48. package/studio/server/agent/harness/claude/ask.ts +114 -0
  49. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  50. package/studio/server/agent/harness/claude/command.ts +63 -0
  51. package/studio/server/agent/harness/claude/events.ts +215 -0
  52. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  53. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  54. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  55. package/studio/server/agent/harness/claude/skills.ts +43 -0
  56. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  57. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  58. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  59. package/studio/server/agent/harness/codex/ask.ts +181 -0
  60. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  61. package/studio/server/agent/harness/codex/command.ts +43 -0
  62. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  63. package/studio/server/agent/harness/codex/events.ts +132 -0
  64. package/studio/server/agent/harness/codex/exec.ts +113 -0
  65. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  66. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  67. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  68. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  69. package/studio/server/agent/harness/codex/models.ts +211 -0
  70. package/studio/server/agent/harness/codex/skills.ts +33 -0
  71. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  72. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  73. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  74. package/studio/server/agent/harness/gateway/token.ts +166 -0
  75. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  76. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  77. package/studio/server/agent/harness/process.test.ts +46 -0
  78. package/studio/server/agent/harness/process.ts +104 -0
  79. package/studio/server/agent/harness/registry.ts +37 -0
  80. package/studio/server/agent/harness/selection.test.ts +75 -0
  81. package/studio/server/agent/harness/selection.ts +77 -0
  82. package/studio/server/agent/harness/skills.test.ts +95 -0
  83. package/studio/server/agent/harness/skills.ts +100 -0
  84. package/studio/server/agent/layout.test.ts +95 -0
  85. package/studio/server/agent/notify.ts +12 -0
  86. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  87. package/studio/server/agent/prompts/ask.ts +34 -0
  88. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  89. package/studio/server/agent/prompts/turn.ts +57 -0
  90. package/studio/server/agent/routes.test.ts +176 -0
  91. package/studio/server/agent/routes.ts +214 -0
  92. package/studio/server/agent/run/completion.ts +193 -0
  93. package/studio/server/agent/run/coordinator.test.ts +337 -0
  94. package/studio/server/agent/run/coordinator.ts +104 -0
  95. package/studio/server/agent/run/live-state.ts +73 -0
  96. package/studio/server/agent/run/preparation.ts +162 -0
  97. package/studio/server/agent/run/transcript.test.ts +47 -0
  98. package/studio/server/agent/run/transcript.ts +30 -0
  99. package/studio/server/agent/run/usage.test.ts +46 -0
  100. package/studio/server/{state → agent/run}/usage.ts +4 -4
  101. package/studio/server/agent/stream.ts +37 -0
  102. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  103. package/studio/server/api-agent-loadout.test.ts +69 -0
  104. package/studio/server/api.ts +3 -155
  105. package/studio/server/index.ts +6 -5
  106. package/studio/server/state/comments.test.ts +29 -0
  107. package/studio/server/state/comments.ts +3 -3
  108. package/studio/server/state/settings.test.ts +78 -0
  109. package/studio/server/state/settings.ts +28 -1
  110. package/studio/shared/agent-effort.test.ts +12 -0
  111. package/studio/shared/agent-effort.ts +15 -0
  112. package/studio/shared/agent-models.test.ts +9 -0
  113. package/studio/shared/agent-models.ts +14 -0
  114. package/studio/shared/settings-values.test.ts +18 -0
  115. package/studio/shared/settings-values.ts +20 -0
  116. package/studio/shared/types.ts +58 -7
  117. package/src/connect-core.test.ts +0 -42
  118. package/src/connect-core.ts +0 -53
  119. package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
  120. package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
  121. package/studio/server/agent/bridge.ts +0 -188
  122. package/studio/server/agent/claude.ts +0 -678
  123. package/studio/server/agent/mock.ts +0 -186
  124. package/studio/server/agent/registry.ts +0 -29
  125. package/studio/server/agent/runner.ts +0 -487
  126. package/studio/server/state/harness-token.ts +0 -0
@@ -0,0 +1,57 @@
1
+ import type { Comment, ContextItem, DocMeta, SchemaIR, SchemaOverlay } from '../../../shared/types'
2
+
3
+ import { buildCopyMarkdown } from '../../state/copy'
4
+ import { resolveThreadAnchors } from './anchors'
5
+
6
+ export interface TurnParts {
7
+ origin: string
8
+ root: string
9
+ schemaHash: string
10
+ awaitingThreads: Comment[]
11
+ userContext: ContextItem[]
12
+ autoContext: ContextItem[]
13
+ documents: DocMeta[]
14
+ firstTurn: boolean
15
+ message?: string
16
+ ir: SchemaIR | null
17
+ overlay?: SchemaOverlay
18
+ }
19
+
20
+ /** Build the handoff payload for one new or follow-up turn. */
21
+ export function buildTurnPrompt(parts: TurnParts): string {
22
+ const body = buildCopyMarkdown({
23
+ origin: parts.origin,
24
+ root: parts.root,
25
+ schemaHash: parts.schemaHash,
26
+ openComments: parts.awaitingThreads,
27
+ userContext: parts.userContext,
28
+ autoContext: parts.autoContext,
29
+ documents: parts.documents,
30
+ })
31
+ const hasThreads = parts.awaitingThreads.length > 0
32
+ const header = parts.firstTurn
33
+ ? hasThreads
34
+ ? '> New session. The thread pointers and context below are your orientation — implement the open threads and reply by id.'
35
+ : '> New session. Follow the direct instruction below; use the context below and read schema/ when needed.'
36
+ : hasThreads
37
+ ? '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits); the threads below were added or updated since your last reply — implement and answer them.'
38
+ : '> Follow-up turn in the SAME session. The schema files are current (incl. your prior edits). Follow the direct instruction below.'
39
+ const anchors = resolveThreadAnchors(parts.awaitingThreads, parts.overlay)
40
+ const message = parts.message?.trim()
41
+ const instruction = message ? ['', '## Direct instruction', '', message] : []
42
+ return [header, ...instruction, '', anchors, '', body]
43
+ .filter((part) => part !== undefined)
44
+ .join('\n')
45
+ }
46
+
47
+ /** Build the minimal nudge for a surviving interrupted conversation. */
48
+ export function buildResumePrompt(): string {
49
+ return [
50
+ '> Resuming the SAME session. Your previous turn was cut off when Domain Studio',
51
+ '> restarted — nothing else has changed. Pick up exactly where you left off.',
52
+ '',
53
+ 'Continue and finish what you were doing, then make sure every open thread gets an',
54
+ 'answer through the usual channel (the domain-studio MCP tools, or the final',
55
+ 'machine-state ```json``` block). Keep going from where you stopped — do not restart.',
56
+ ].join('\n')
57
+ }
@@ -0,0 +1,176 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { AskResult } from './harness/adapter'
7
+
8
+ import { registerDomain, unregisterDomain } from '../domain'
9
+ import { getHarnessById } from './harness/registry'
10
+ import { handleAgentRoute } from './routes'
11
+ import { NdjsonChannel } from './stream'
12
+
13
+ const roots: string[] = []
14
+ const domainIds: string[] = []
15
+ const previousHarness = process.env.DOMAIN_STUDIO_HARNESS
16
+
17
+ afterEach(() => {
18
+ if (previousHarness === undefined) delete process.env.DOMAIN_STUDIO_HARNESS
19
+ else process.env.DOMAIN_STUDIO_HARNESS = previousHarness
20
+ while (domainIds.length) unregisterDomain(domainIds.pop()!)
21
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
22
+ })
23
+
24
+ function fixture() {
25
+ const root = mkdtempSync(join(tmpdir(), 'studio-agent-routes-'))
26
+ roots.push(root)
27
+ mkdirSync(join(root, 'schema'))
28
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
29
+ writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
30
+ writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
31
+ const handle = registerDomain(root)!
32
+ domainIds.push(handle.id)
33
+ return handle
34
+ }
35
+
36
+ async function route(rest: string, method = 'GET', body: any = {}, search = '') {
37
+ const handle = fixture()
38
+ const url = new URL(`http://127.0.0.1/api/domain/${handle.id}${rest}${search}`)
39
+ const req = new Request(url, {
40
+ method,
41
+ ...(method === 'POST'
42
+ ? {
43
+ headers: { 'content-type': 'application/json' },
44
+ body: JSON.stringify(body),
45
+ }
46
+ : {}),
47
+ })
48
+ return handleAgentRoute({ req, url, rest, body, handle, notify: () => {} })
49
+ }
50
+
51
+ test('owns harness status and prompt routes behind one agent boundary', async () => {
52
+ process.env.DOMAIN_STUDIO_HARNESS = 'mock'
53
+
54
+ const harness = await route('/agent/harness')
55
+ expect(harness?.status).toBe(200)
56
+ expect(await harness?.json()).toMatchObject({
57
+ id: 'mock',
58
+ label: 'Mock agent (free)',
59
+ ok: true,
60
+ locked: true,
61
+ source: 'environment',
62
+ })
63
+
64
+ const prompt = await route('/agent/prompt/system')
65
+ expect(await prompt?.json()).toMatchObject({
66
+ bridge: true,
67
+ })
68
+ })
69
+
70
+ test('ignores non-agent routes and rejects unknown agent routes', async () => {
71
+ process.env.DOMAIN_STUDIO_HARNESS = 'mock'
72
+ expect(await route('/context')).toBeNull()
73
+ expect((await route('/agent/unknown'))?.status).toBe(404)
74
+ })
75
+
76
+ test('rejects stale session ownership and invalid gateway writes at the route boundary', async () => {
77
+ process.env.DOMAIN_STUDIO_HARNESS = 'mock'
78
+
79
+ const staleSession = await route('/agent/session', 'POST', {
80
+ harness: 'codex',
81
+ sessionId: 'wrong-owner',
82
+ })
83
+ expect(staleSession?.status).toBe(400)
84
+ expect(await staleSession?.json()).toEqual({
85
+ error: 'selected harness changed from codex to mock',
86
+ })
87
+
88
+ const invalidGateway = await route('/agent/harness-gateway', 'POST', {
89
+ action: 'set',
90
+ scope: 'domain',
91
+ config: { enabled: true, baseUrl: '', auth: { mode: 'mint' } },
92
+ })
93
+ expect(invalidGateway?.status).toBe(400)
94
+ expect(await invalidGateway?.json()).toEqual({
95
+ error: 'gateway base URL is required while enabled',
96
+ })
97
+ })
98
+
99
+ test('threads an explicit loadout refresh through to the selected harness', async () => {
100
+ process.env.DOMAIN_STUDIO_HARNESS = 'claude'
101
+ const harness = getHarnessById('claude')
102
+ const originalLoadout = harness.loadout
103
+ let refresh: boolean | undefined
104
+ harness.loadout = async (_root, options) => {
105
+ refresh = options?.refresh
106
+ return {
107
+ ok: true,
108
+ tools: [],
109
+ mcpServers: [],
110
+ skills: [],
111
+ agents: [],
112
+ builtinCommandCount: 0,
113
+ probedAt: Date.now(),
114
+ }
115
+ }
116
+
117
+ try {
118
+ const response = await route('/agent/loadout', 'GET', {}, '?refresh=1')
119
+ expect(response?.status).toBe(200)
120
+ expect(refresh).toBe(true)
121
+ } finally {
122
+ harness.loadout = originalLoadout
123
+ }
124
+ })
125
+
126
+ test('cancels an Ask stream without enqueueing after the client closes it', async () => {
127
+ process.env.DOMAIN_STUDIO_HARNESS = 'mock'
128
+ const harness = getHarnessById('mock')
129
+ const originalAsk = harness.ask
130
+ let finishAsk!: () => void
131
+ const askFinished = new Promise<void>((resolve) => (finishAsk = resolve))
132
+ let receivedAbort = false
133
+ harness.ask = async (input): Promise<AskResult> => {
134
+ input.onDelta('first')
135
+ await new Promise<void>((resolve) => {
136
+ input.signal.addEventListener(
137
+ 'abort',
138
+ () => {
139
+ receivedAbort = true
140
+ resolve()
141
+ },
142
+ { once: true },
143
+ )
144
+ })
145
+ input.onDelta('late')
146
+ finishAsk()
147
+ return { text: 'firstlate', isError: false }
148
+ }
149
+
150
+ try {
151
+ const response = await route('/agent/ask', 'POST', { question: 'stream briefly' })
152
+ const reader = response!.body!.getReader()
153
+ expect(new TextDecoder().decode((await reader.read()).value)).toContain('"delta":"first"')
154
+ await reader.cancel()
155
+ await askFinished
156
+ expect(receivedAbort).toBe(true)
157
+ } finally {
158
+ harness.ask = originalAsk
159
+ }
160
+ })
161
+
162
+ test('an NDJSON channel never enqueues after cancellation', () => {
163
+ const abortController = new AbortController()
164
+ const chunks: Uint8Array[] = []
165
+ const target = {
166
+ enqueue: (chunk: Uint8Array) => chunks.push(chunk),
167
+ close: () => {},
168
+ } as unknown as ReadableStreamDefaultController<Uint8Array>
169
+ const channel = new NdjsonChannel(target, abortController)
170
+
171
+ expect(channel.send({ delta: 'first' })).toBe(true)
172
+ channel.cancel()
173
+ expect(abortController.signal.aborted).toBe(true)
174
+ expect(channel.send({ delta: 'late' })).toBe(false)
175
+ expect(chunks).toHaveLength(1)
176
+ })
@@ -0,0 +1,214 @@
1
+ import type { HarnessStatus, StudioEvent } from '../../shared/types'
2
+ import type { DomainHandle } from '../domain'
3
+
4
+ import { runAsk } from './ask'
5
+ import { handleBridge } from './bridge/routes'
6
+ import { inspectHarnessHealth } from './harness/adapter'
7
+ import {
8
+ clearHarnessGateway,
9
+ gatewayAudience,
10
+ getHarnessGatewayState,
11
+ setHarnessGateway,
12
+ } from './harness/gateway/config'
13
+ import { setHostToken } from './harness/gateway/token'
14
+ import { listHarnesses } from './harness/registry'
15
+ import {
16
+ getHarness,
17
+ getHarnessSelection,
18
+ resolveHarnessConfiguration,
19
+ setHarnessSelection,
20
+ } from './harness/selection'
21
+ import { buildSystemPrompt } from './prompts/system'
22
+ import {
23
+ cancelRun,
24
+ getSessionId,
25
+ getSnapshot,
26
+ isRunning,
27
+ resetConversation,
28
+ setSessionId,
29
+ submitRun,
30
+ } from './run/coordinator'
31
+ import { readUsage } from './run/usage'
32
+ import { NdjsonChannel } from './stream'
33
+
34
+ function json(data: unknown, status = 200): Response {
35
+ return new Response(JSON.stringify(data), {
36
+ status,
37
+ headers: { 'content-type': 'application/json' },
38
+ })
39
+ }
40
+
41
+ const badRequest = (message: string) => json({ error: message }, 400)
42
+ const notFound = () => json({ error: 'not found' }, 404)
43
+
44
+ async function harnessStatus(root: string, selected = false): Promise<HarnessStatus> {
45
+ const selection = getHarnessSelection(root)
46
+ const harness = getHarness(root)
47
+ const health = await inspectHarnessHealth(harness)
48
+ return {
49
+ id: harness.id,
50
+ label: harness.label,
51
+ bin: health.bin ?? harness.id,
52
+ ok: health.ok,
53
+ version: health.version,
54
+ message: health.ok
55
+ ? `${selected ? `Selected ${harness.label}` : 'Detected'}${health.version ? ` — ${health.version}` : ''}`
56
+ : (health.detail ?? `${harness.label} is not detected. Is it installed and on your PATH?`),
57
+ options: listHarnesses(harness.id),
58
+ locked: selection.locked,
59
+ source: selection.source,
60
+ capabilities: harness.capabilities,
61
+ }
62
+ }
63
+
64
+ export interface AgentRouteInput {
65
+ req: Request
66
+ url: URL
67
+ rest: string
68
+ body: any
69
+ handle: DomainHandle
70
+ notify: (event: StudioEvent) => void
71
+ }
72
+
73
+ /** Own every `/agent/*` HTTP route for one Studio domain. */
74
+ export async function handleAgentRoute(input: AgentRouteInput): Promise<Response | null> {
75
+ const { req, url, rest, body, handle, notify } = input
76
+ if (rest !== '/agent' && !rest.startsWith('/agent/')) return null
77
+ const id = handle.id
78
+ const root = handle.root
79
+
80
+ if (rest === '/agent') {
81
+ if (req.method === 'GET') return json(await getSnapshot(id))
82
+ return badRequest('use /agent/submit or /agent/cancel')
83
+ }
84
+ if (rest === '/agent/submit' && req.method === 'POST') {
85
+ const result = await submitRun(handle, notify, {
86
+ message: typeof body.message === 'string' ? body.message : undefined,
87
+ resume: body.resume === true,
88
+ })
89
+ return result.error ? json({ error: result.error }) : json(result.run)
90
+ }
91
+ if (rest === '/agent/cancel' && req.method === 'POST') return json({ ok: cancelRun(id) })
92
+ if (rest === '/agent/reset' && req.method === 'POST') return json({ ok: resetConversation(id) })
93
+ if (rest === '/agent/session') {
94
+ if (req.method === 'GET') return json(getSessionId(id))
95
+ if (req.method === 'POST') {
96
+ const harness = getHarness(root)
97
+ if (body.harness !== harness.id)
98
+ return badRequest(
99
+ `selected harness changed from ${String(body.harness ?? '(unknown)')} to ${harness.id}`,
100
+ )
101
+ const ok = setSessionId(id, typeof body.sessionId === 'string' ? body.sessionId : '')
102
+ if (!ok) return badRequest('the session id cannot be changed while a turn is running')
103
+ return json(getSessionId(id))
104
+ }
105
+ return badRequest('GET or POST')
106
+ }
107
+ if (rest === '/agent/prompt/system' && req.method === 'GET')
108
+ return json({ bridge: true, systemPrompt: buildSystemPrompt({ bridge: true }) })
109
+ if (rest === '/agent/harness' && req.method === 'GET') return json(await harnessStatus(root))
110
+ if (rest === '/agent/harness' && req.method === 'POST') {
111
+ if (isRunning(id)) return badRequest('the harness cannot be changed while a turn is running')
112
+ try {
113
+ setHarnessSelection(root, String(body.id ?? ''))
114
+ return json(await harnessStatus(root, true))
115
+ } catch (error) {
116
+ return badRequest(String((error as Error)?.message ?? error))
117
+ }
118
+ }
119
+ if (rest === '/agent/loadout' && req.method === 'GET') {
120
+ const harness = getHarness(root)
121
+ if (!harness.loadout)
122
+ return json({
123
+ ok: false,
124
+ detail: `${harness.label} does not expose a loadout`,
125
+ tools: [],
126
+ mcpServers: [],
127
+ skills: [],
128
+ agents: [],
129
+ builtinCommandCount: 0,
130
+ probedAt: Date.now(),
131
+ })
132
+ const configuration = await resolveHarnessConfiguration(root, harness)
133
+ if (!configuration.ok)
134
+ return json({
135
+ ok: false,
136
+ detail: `model gateway auth failed — ${configuration.error}`,
137
+ tools: [],
138
+ mcpServers: [],
139
+ skills: [],
140
+ agents: [],
141
+ builtinCommandCount: 0,
142
+ probedAt: Date.now(),
143
+ })
144
+ const { env, model } = configuration.configuration
145
+ return json(
146
+ await harness.loadout(root, {
147
+ env,
148
+ model,
149
+ refresh: url.searchParams.get('refresh') === '1',
150
+ }),
151
+ )
152
+ }
153
+ if (rest === '/agent/harness-gateway') {
154
+ if (req.method === 'GET') return json(getHarnessGatewayState(root))
155
+ if (req.method === 'POST') {
156
+ const scope = body.scope === 'global' ? 'global' : 'domain'
157
+ if (body.action === 'set') {
158
+ try {
159
+ return json(setHarnessGateway(root, { scope, config: body.config ?? {} }))
160
+ } catch (error) {
161
+ return badRequest(String((error as Error)?.message ?? error))
162
+ }
163
+ }
164
+ if (body.action === 'clear') return json(clearHarnessGateway(root, scope))
165
+ return badRequest('unknown harness-gateway action')
166
+ }
167
+ }
168
+ if (rest === '/agent/harness-gateway/host-token' && req.method === 'POST') {
169
+ const audience = gatewayAudience(root)
170
+ if (!audience) return badRequest('no gateway base URL configured for this domain')
171
+ return json({ ok: setHostToken(audience, String(body.token ?? '')) })
172
+ }
173
+ if (rest === '/agent/usage' && req.method === 'GET') return json(readUsage(root))
174
+ if (rest === '/agent/skill' && req.method === 'GET') {
175
+ const command = url.searchParams.get('command')
176
+ const harness = getHarness(root)
177
+ if (!command || !harness.skillContent) return notFound()
178
+ const content = await harness.skillContent(root, command)
179
+ return content ? json(content) : notFound()
180
+ }
181
+ if (rest === '/agent/ask' && req.method === 'POST') {
182
+ const controller = new AbortController()
183
+ req.signal?.addEventListener('abort', () => controller.abort(), { once: true })
184
+ let channel: NdjsonChannel | undefined
185
+ const stream = new ReadableStream<Uint8Array>({
186
+ async start(target) {
187
+ channel = new NdjsonChannel(target, controller)
188
+ try {
189
+ const result = await runAsk(handle, body, controller.signal, (delta) =>
190
+ channel!.send({ delta }),
191
+ )
192
+ channel.send(
193
+ result.isError ? { error: result.errorMessage || 'ask failed' } : { done: result.text },
194
+ )
195
+ } catch (error: any) {
196
+ channel.send({ error: String(error?.message ?? error) })
197
+ } finally {
198
+ channel.close()
199
+ }
200
+ },
201
+ cancel() {
202
+ channel?.cancel()
203
+ if (!channel) controller.abort()
204
+ },
205
+ })
206
+ return new Response(stream, {
207
+ headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' },
208
+ })
209
+ }
210
+ if (rest.startsWith('/agent/bridge/'))
211
+ return handleBridge(handle, rest.slice('/agent/bridge/'.length), req, body)
212
+
213
+ return notFound()
214
+ }
@@ -0,0 +1,193 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import type { AgentEvent, StudioEvent } from '../../../shared/types'
4
+ import type { PreparedRun } from './preparation'
5
+
6
+ import { mergeReply } from '../../state/comments'
7
+ import { clearConversation, saveConversation } from '../conversation'
8
+ import { emitStudioEvent } from '../notify'
9
+ import { releaseController } from './live-state'
10
+ import { persistRun } from './transcript'
11
+ import { recordRun } from './usage'
12
+
13
+ function stripMachineState(text: string): string {
14
+ return text
15
+ .replace(/```(?:json)?\s*[\s\S]*?```/g, (block) =>
16
+ /"comments"|"schemaVersion"/.test(block) ? '' : block,
17
+ )
18
+ .trim()
19
+ }
20
+
21
+ /** Execute and settle one prepared agent run. */
22
+ export async function completeRun(
23
+ prepared: PreparedRun,
24
+ controller: AbortController,
25
+ notify: (event: StudioEvent) => void,
26
+ ): Promise<void> {
27
+ const {
28
+ domainId,
29
+ root,
30
+ harness,
31
+ settings,
32
+ session,
33
+ resume,
34
+ schemaHash,
35
+ model,
36
+ effort,
37
+ harnessEnv,
38
+ bridge,
39
+ run,
40
+ promptSnapshot,
41
+ } = prepared
42
+ const pushEvent = (event: Omit<AgentEvent, 'id' | 'ts'>) => {
43
+ let text = event.text
44
+ if (event.kind === 'message') {
45
+ text = stripMachineState(text)
46
+ if (!text) return
47
+ }
48
+ const stored: AgentEvent = {
49
+ id: randomUUID(),
50
+ ts: new Date().toISOString(),
51
+ ...event,
52
+ text,
53
+ }
54
+ run.events.push(stored)
55
+ emitStudioEvent(notify, { type: 'agent-event', domainId, runId: run.id, event: stored })
56
+ }
57
+
58
+ let bridgeReplies = 0
59
+ const liveByComment = new Map<string, Set<string>>()
60
+ bridge.onReply((commentId, text) => {
61
+ bridgeReplies += 1
62
+ if (!liveByComment.has(commentId)) liveByComment.set(commentId, new Set())
63
+ liveByComment.get(commentId)!.add(text.trim())
64
+ pushEvent({ kind: 'reply', text, commentId })
65
+ })
66
+ bridge.onProgress((text) => pushEvent({ kind: 'status', text }))
67
+
68
+ try {
69
+ const runTurn = (sessionId: string | undefined, firstTurn: boolean) => {
70
+ const prompt = promptSnapshot(sessionId, firstTurn)
71
+ run.prompt = prompt
72
+ emitStudioEvent(notify, { type: 'agent-run', domainId, run })
73
+ return harness.run({
74
+ root,
75
+ prompt: prompt.turnPrompt,
76
+ appendSystemPrompt: prompt.systemPrompt,
77
+ sessionId,
78
+ model,
79
+ effort,
80
+ access: settings.agentAccess,
81
+ mcpServers: bridge.mcpServers,
82
+ env: harnessEnv,
83
+ signal: controller.signal,
84
+ onEvent: pushEvent,
85
+ })
86
+ }
87
+
88
+ const resumeEventStart = run.events.length
89
+ const resumeBridgeReplies = bridgeReplies
90
+ let result = await runTurn(resume, !resume)
91
+ let conversationTurns = resume ? (session.turns ?? 0) : 0
92
+ if (resume && result.resumeRejected && !controller.signal.aborted) {
93
+ clearConversation(root, harness.id)
94
+ conversationTurns = 0
95
+ run.sessionId = undefined
96
+ run.resumed = false
97
+ const observableActivity =
98
+ bridgeReplies > resumeBridgeReplies ||
99
+ run.events
100
+ .slice(resumeEventStart)
101
+ .some(
102
+ (event) => event.kind === 'message' || event.kind === 'tool' || event.kind === 'reply',
103
+ )
104
+ if (observableActivity) {
105
+ result = {
106
+ ...result,
107
+ sessionId: undefined,
108
+ isError: true,
109
+ errorMessage:
110
+ 'the previous conversation was rejected after observable activity — Studio did not retry automatically to avoid duplicating work',
111
+ }
112
+ pushEvent({
113
+ kind: 'status',
114
+ text: 'resume was not retried because the rejected attempt had already produced observable activity',
115
+ })
116
+ } else {
117
+ pushEvent({
118
+ kind: 'status',
119
+ text: 'previous conversation was no longer available — started a new one',
120
+ })
121
+ result = await runTurn(undefined, true)
122
+ }
123
+ }
124
+
125
+ run.sessionId = result.sessionId ?? run.sessionId
126
+ run.costUsd = result.costUsd
127
+ run.tokens = result.tokens
128
+ run.numTurns = result.numTurns
129
+ run.liveReplies = bridgeReplies
130
+ if (bridgeReplies > 0 && !controller.signal.aborted)
131
+ pushEvent({
132
+ kind: 'status',
133
+ text: `replied to ${bridgeReplies} thread${bridgeReplies === 1 ? '' : 's'} live`,
134
+ })
135
+
136
+ let replyError: string | undefined
137
+ if (
138
+ !controller.signal.aborted &&
139
+ !result.isError &&
140
+ result.finalText &&
141
+ result.finalText.trim()
142
+ ) {
143
+ try {
144
+ const merged = mergeReply(root, schemaHash, result.finalText, {
145
+ skipByComment: liveByComment,
146
+ })
147
+ run.merge = merged
148
+ if (merged.merged || merged.closed)
149
+ pushEvent({
150
+ kind: 'status',
151
+ text: `merged ${merged.merged} repl${merged.merged === 1 ? 'y' : 'ies'}, closed ${merged.closed}`,
152
+ })
153
+ emitStudioEvent(notify, { type: 'comments', domainId })
154
+ } catch (error: any) {
155
+ if (/```json/i.test(result.finalText)) {
156
+ replyError = `agent reply block was malformed JSON — reply not merged (${String(error?.message ?? error).slice(0, 80)})`
157
+ pushEvent({ kind: 'error', text: replyError })
158
+ } else if (bridgeReplies === 0) {
159
+ pushEvent({ kind: 'status', text: 'no machine-state reply block in the final message' })
160
+ }
161
+ }
162
+ }
163
+
164
+ if (controller.signal.aborted) run.status = 'canceled'
165
+ else if (result.isError) {
166
+ run.status = 'failed'
167
+ run.error = result.errorMessage
168
+ pushEvent({ kind: 'error', text: result.errorMessage ?? 'agent error' })
169
+ } else if (replyError) {
170
+ run.status = 'failed'
171
+ run.error = replyError
172
+ } else run.status = 'succeeded'
173
+
174
+ if (run.status === 'succeeded' && result.sessionId)
175
+ saveConversation(root, harness.id, {
176
+ sessionId: result.sessionId,
177
+ turns: conversationTurns + 1,
178
+ updatedAt: new Date().toISOString(),
179
+ })
180
+ } catch (error: any) {
181
+ run.status = controller.signal.aborted ? 'canceled' : 'failed'
182
+ run.error = String(error?.message ?? error)
183
+ pushEvent({ kind: 'error', text: run.error })
184
+ } finally {
185
+ run.finishedAt = new Date().toISOString()
186
+ releaseController(domainId, controller)
187
+ bridge.dispose()
188
+ recordRun(root, run)
189
+ persistRun(root, run, true)
190
+ emitStudioEvent(notify, { type: 'agent-run', domainId, run })
191
+ emitStudioEvent(notify, { type: 'comments', domainId })
192
+ }
193
+ }