@astrale-os/cli 0.7.0-alpha.0 → 0.8.1-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 +7 -3
- package/dist/astrale.js +128 -98
- package/package.json +11 -5
- 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/elk-api-DVw5WDTH.js +1 -0
- package/studio/client/dist/assets/elk-worker.min-C9JGDOE-.js +6312 -0
- package/studio/client/dist/assets/index-BQnJ5sgd.css +1 -0
- package/studio/client/dist/assets/index-CTbkDp3z.js +81 -0
- package/studio/client/dist/assets/index-ChOr3yP0.css +1 -0
- package/studio/client/dist/assets/index-Dl709Ra1.js +8 -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/studio/tsconfig.json +23 -0
- package/viewer/tsconfig.json +13 -0
- 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,148 @@
|
|
|
1
|
+
import type { StudioEvent } from '../../../shared/types'
|
|
2
|
+
import type { DomainHandle } from '../../domain'
|
|
3
|
+
|
|
4
|
+
import { addThreadEntry, readComments, setStatus, upsertComment } from '../../state/comments'
|
|
5
|
+
import { emitStudioEvent } from '../notify'
|
|
6
|
+
|
|
7
|
+
interface RunBridge {
|
|
8
|
+
domainId: string
|
|
9
|
+
root: string
|
|
10
|
+
notify: (event: StudioEvent) => void
|
|
11
|
+
onReply: (commentId: string, text: string) => void
|
|
12
|
+
onProgress: (text: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BridgeSession {
|
|
16
|
+
token: string
|
|
17
|
+
onReply(callback: (commentId: string, text: string) => void): void
|
|
18
|
+
onProgress(callback: (text: string) => void): void
|
|
19
|
+
invoke(sub: string, body: Record<string, unknown>): Promise<Response>
|
|
20
|
+
dispose(): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bridges = new Map<string, RunBridge>()
|
|
24
|
+
|
|
25
|
+
function ok(data: unknown): Response {
|
|
26
|
+
return new Response(JSON.stringify(data), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function error(message: string, status = 400): Response {
|
|
33
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
34
|
+
status,
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Open the authenticated write-back session owned by one agent run. */
|
|
40
|
+
export function openBridgeSession(
|
|
41
|
+
handle: DomainHandle,
|
|
42
|
+
token: string,
|
|
43
|
+
notify: (event: StudioEvent) => void,
|
|
44
|
+
): BridgeSession {
|
|
45
|
+
const bridge: RunBridge = {
|
|
46
|
+
domainId: handle.id,
|
|
47
|
+
root: handle.root,
|
|
48
|
+
notify,
|
|
49
|
+
onReply: () => {},
|
|
50
|
+
onProgress: () => {},
|
|
51
|
+
}
|
|
52
|
+
bridges.set(token, bridge)
|
|
53
|
+
return {
|
|
54
|
+
token,
|
|
55
|
+
onReply: (callback) => {
|
|
56
|
+
bridge.onReply = callback
|
|
57
|
+
},
|
|
58
|
+
onProgress: (callback) => {
|
|
59
|
+
bridge.onProgress = callback
|
|
60
|
+
},
|
|
61
|
+
invoke: (sub, body) => applyBridgeCall(handle, sub, { ...body, token }),
|
|
62
|
+
dispose: () => {
|
|
63
|
+
bridges.delete(token)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Handle one token-guarded bridge route. */
|
|
69
|
+
export function handleBridge(
|
|
70
|
+
handle: DomainHandle,
|
|
71
|
+
sub: string,
|
|
72
|
+
_req: Request,
|
|
73
|
+
body: any,
|
|
74
|
+
): Promise<Response> {
|
|
75
|
+
return applyBridgeCall(handle, sub, body)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function applyBridgeCall(handle: DomainHandle, sub: string, body: any): Promise<Response> {
|
|
79
|
+
const token = String(body?.token ?? '')
|
|
80
|
+
const bridge = bridges.get(token)
|
|
81
|
+
if (!bridge || bridge.domainId !== handle.id) return error('invalid or expired bridge token', 401)
|
|
82
|
+
const root = bridge.root
|
|
83
|
+
|
|
84
|
+
switch (sub) {
|
|
85
|
+
case 'threads': {
|
|
86
|
+
const open = readComments(root).comments.filter(
|
|
87
|
+
(comment) => comment.status === 'open' && comment.thread.at(-1)?.role !== 'author',
|
|
88
|
+
)
|
|
89
|
+
return ok({
|
|
90
|
+
threads: open.map((comment) => ({
|
|
91
|
+
id: comment.id,
|
|
92
|
+
kind: comment.kind,
|
|
93
|
+
anchor: comment.anchorRefs?.[0]?.ref ?? null,
|
|
94
|
+
file: comment.anchorRefs?.[0]?.file ?? null,
|
|
95
|
+
latest: comment.thread.at(-1)?.text ?? '',
|
|
96
|
+
})),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
case 'reply': {
|
|
100
|
+
const commentId = String(body.commentId ?? '')
|
|
101
|
+
const text = String(body.text ?? '').trim()
|
|
102
|
+
if (!commentId || !text) return error('commentId and text are required')
|
|
103
|
+
const comment = addThreadEntry(root, commentId, {
|
|
104
|
+
role: 'author',
|
|
105
|
+
type: body.options ? 'choice' : 'text',
|
|
106
|
+
text,
|
|
107
|
+
options: body.options,
|
|
108
|
+
answer: body.answer,
|
|
109
|
+
})
|
|
110
|
+
if (!comment) return error('unknown commentId', 404)
|
|
111
|
+
if (body.resolve) setStatus(root, commentId, 'closed', body.closeNote)
|
|
112
|
+
bridge.onReply(commentId, text)
|
|
113
|
+
emitStudioEvent(bridge.notify, { type: 'comments', domainId: handle.id })
|
|
114
|
+
return ok({ ok: true, resolved: !!body.resolve })
|
|
115
|
+
}
|
|
116
|
+
case 'resolve': {
|
|
117
|
+
const commentId = String(body.commentId ?? '')
|
|
118
|
+
if (!commentId) return error('commentId is required')
|
|
119
|
+
const comment = setStatus(root, commentId, 'closed', body.closeNote)
|
|
120
|
+
if (!comment) return error('unknown commentId', 404)
|
|
121
|
+
bridge.onReply(commentId, body.closeNote ? `resolved: ${body.closeNote}` : 'resolved')
|
|
122
|
+
emitStudioEvent(bridge.notify, { type: 'comments', domainId: handle.id })
|
|
123
|
+
return ok({ ok: true })
|
|
124
|
+
}
|
|
125
|
+
case 'progress': {
|
|
126
|
+
const text = String(body.text ?? '').trim()
|
|
127
|
+
if (text) bridge.onProgress(text)
|
|
128
|
+
return ok({ ok: true })
|
|
129
|
+
}
|
|
130
|
+
case 'raise_question': {
|
|
131
|
+
const ref = String(body.ref ?? '')
|
|
132
|
+
const text = String(body.text ?? '').trim()
|
|
133
|
+
if (!ref || !text) return error('ref and text are required')
|
|
134
|
+
const comment = upsertComment(root, {
|
|
135
|
+
anchors: [body.label ?? ref],
|
|
136
|
+
anchorRefs: [{ ref, kind: (body.kind ?? 'schema') as any, file: body.file }],
|
|
137
|
+
text,
|
|
138
|
+
firstRole: 'author',
|
|
139
|
+
type: body.options ? 'choice' : 'text',
|
|
140
|
+
options: body.options,
|
|
141
|
+
})
|
|
142
|
+
emitStudioEvent(bridge.notify, { type: 'comments', domainId: handle.id })
|
|
143
|
+
return ok({ ok: true, id: comment.id })
|
|
144
|
+
}
|
|
145
|
+
default:
|
|
146
|
+
return error('unknown bridge route', 404)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* bridge
|
|
4
|
-
*
|
|
3
|
+
* bridge/stdio.ts — the stdio MCP server spawned by local harnesses. It exposes
|
|
4
|
+
* the Domain Studio write-back tools from the generated MCP configuration and
|
|
5
5
|
* forwards each call to the token-guarded HTTP bridge (api → agent/bridge/*),
|
|
6
6
|
* so an agent can reply to comment threads and post progress AS IT WORKS.
|
|
7
7
|
*
|
|
8
8
|
* Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout. Nothing but
|
|
9
9
|
* protocol messages may touch stdout; diagnostics go to stderr.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
|
|
13
|
+
import { forwardBridgeTool } from './client'
|
|
14
|
+
|
|
15
|
+
function configFromArgv(): { base?: string; token?: string } {
|
|
16
|
+
const i = process.argv.indexOf('--config')
|
|
17
|
+
const path = i >= 0 ? process.argv[i + 1] : undefined
|
|
18
|
+
if (!path) return {}
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(path, 'utf8')) as { base?: string; token?: string }
|
|
21
|
+
} catch {
|
|
22
|
+
return {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = configFromArgv()
|
|
27
|
+
const BASE = config.base || process.env.DOMAIN_STUDIO_BRIDGE_URL || ''
|
|
28
|
+
const TOKEN = config.token || process.env.DOMAIN_STUDIO_BRIDGE_TOKEN || ''
|
|
13
29
|
|
|
14
30
|
const TOOLS = [
|
|
15
31
|
{
|
|
@@ -106,21 +122,6 @@ function replyError(id: unknown, code: number, message: string): void {
|
|
|
106
122
|
send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
async function callBridge(route: string, args: Record<string, unknown>): Promise<string> {
|
|
110
|
-
if (!BASE || !TOKEN) return 'bridge not configured'
|
|
111
|
-
try {
|
|
112
|
-
const res = await fetch(`${BASE}/${route}`, {
|
|
113
|
-
method: 'POST',
|
|
114
|
-
headers: { 'content-type': 'application/json' },
|
|
115
|
-
body: JSON.stringify({ token: TOKEN, ...args }),
|
|
116
|
-
})
|
|
117
|
-
const text = await res.text()
|
|
118
|
-
return res.ok ? text || 'ok' : `error ${res.status}: ${text}`
|
|
119
|
-
} catch (e: any) {
|
|
120
|
-
return `bridge call failed: ${e?.message ?? e}`
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
125
|
async function handle(req: any): Promise<void> {
|
|
125
126
|
const { id, method, params } = req
|
|
126
127
|
switch (method) {
|
|
@@ -152,8 +153,13 @@ async function handle(req: any): Promise<void> {
|
|
|
152
153
|
replyError(id, -32602, `unknown tool: ${params?.name}`)
|
|
153
154
|
return
|
|
154
155
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
156
|
+
const result = await forwardBridgeTool(
|
|
157
|
+
BASE,
|
|
158
|
+
TOKEN,
|
|
159
|
+
tool.route,
|
|
160
|
+
(params?.arguments ?? {}) as Record<string, unknown>,
|
|
161
|
+
)
|
|
162
|
+
reply(id, result)
|
|
157
163
|
return
|
|
158
164
|
}
|
|
159
165
|
default:
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { readJson, writeJson } from '../state/store'
|
|
7
|
+
import {
|
|
8
|
+
clearConversation,
|
|
9
|
+
readConversation,
|
|
10
|
+
saveConversation,
|
|
11
|
+
setConversationSession,
|
|
12
|
+
} from './conversation'
|
|
13
|
+
|
|
14
|
+
const roots: string[] = []
|
|
15
|
+
const root = () => {
|
|
16
|
+
const value = mkdtempSync(join(tmpdir(), 'studio-conversation-'))
|
|
17
|
+
roots.push(value)
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('per-harness conversations', () => {
|
|
26
|
+
test('preserves independent Claude and Codex sessions', () => {
|
|
27
|
+
const dir = root()
|
|
28
|
+
saveConversation(dir, 'claude', {
|
|
29
|
+
sessionId: 'claude-session',
|
|
30
|
+
turns: 2,
|
|
31
|
+
updatedAt: 'now',
|
|
32
|
+
})
|
|
33
|
+
saveConversation(dir, 'codex', {
|
|
34
|
+
sessionId: 'codex-thread',
|
|
35
|
+
turns: 3,
|
|
36
|
+
updatedAt: 'later',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(readConversation(dir, 'claude')).toMatchObject({
|
|
40
|
+
sessionId: 'claude-session',
|
|
41
|
+
turns: 2,
|
|
42
|
+
})
|
|
43
|
+
expect(readConversation(dir, 'codex')).toMatchObject({
|
|
44
|
+
sessionId: 'codex-thread',
|
|
45
|
+
turns: 3,
|
|
46
|
+
})
|
|
47
|
+
clearConversation(dir, 'codex')
|
|
48
|
+
expect(readConversation(dir, 'claude').sessionId).toBe('claude-session')
|
|
49
|
+
expect(readConversation(dir, 'codex').sessionId).toBeUndefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('migrates the legacy single-session shape on first write', () => {
|
|
53
|
+
const dir = root()
|
|
54
|
+
writeJson(dir, '.cache/agent/session.json', {
|
|
55
|
+
harness: 'claude',
|
|
56
|
+
sessionId: 'legacy-session',
|
|
57
|
+
turns: 4,
|
|
58
|
+
updatedAt: 'then',
|
|
59
|
+
})
|
|
60
|
+
expect(readConversation(dir, 'claude').turns).toBe(4)
|
|
61
|
+
|
|
62
|
+
setConversationSession(dir, 'codex', 'codex-thread')
|
|
63
|
+
const stored = readJson<any>(dir, '.cache/agent/session.json', {})
|
|
64
|
+
expect(stored.version).toBe(1)
|
|
65
|
+
expect(stored.conversations.claude.sessionId).toBe('legacy-session')
|
|
66
|
+
expect(stored.conversations.codex.sessionId).toBe('codex-thread')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('fails closed without rewriting a future conversation-store version', () => {
|
|
70
|
+
const dir = root()
|
|
71
|
+
const future = {
|
|
72
|
+
version: 2,
|
|
73
|
+
conversations: {
|
|
74
|
+
codex: { sessionId: 'future-thread', turns: 9 },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
writeJson(dir, '.cache/agent/session.json', future)
|
|
78
|
+
|
|
79
|
+
expect(() => setConversationSession(dir, 'claude', 'new-session')).toThrow(
|
|
80
|
+
'unsupported agent conversation store version: 2',
|
|
81
|
+
)
|
|
82
|
+
expect(readJson<any>(dir, '.cache/agent/session.json', {})).toEqual(future)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AgentSessionInfo, ConversationInfo } from '../../shared/types'
|
|
2
|
+
|
|
3
|
+
import { readJson, removeState, writeJson } from '../state/store'
|
|
4
|
+
|
|
5
|
+
const SESSION_FILE = '.cache/agent/session.json'
|
|
6
|
+
|
|
7
|
+
interface HarnessConversation {
|
|
8
|
+
sessionId?: string
|
|
9
|
+
turns?: number
|
|
10
|
+
updatedAt?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ConversationStore {
|
|
14
|
+
version: 1
|
|
15
|
+
conversations: Record<string, HarnessConversation>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LegacySessionState extends HarnessConversation {
|
|
19
|
+
harness?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readStore(root: string): ConversationStore {
|
|
23
|
+
const raw = readJson<ConversationStore | LegacySessionState | null>(root, SESSION_FILE, null)
|
|
24
|
+
if (!raw) return { version: 1, conversations: {} }
|
|
25
|
+
if ('conversations' in raw) {
|
|
26
|
+
if (raw.version !== 1)
|
|
27
|
+
throw new Error(`unsupported agent conversation store version: ${String(raw.version)}`)
|
|
28
|
+
if (raw.conversations) return { version: 1, conversations: { ...raw.conversations } }
|
|
29
|
+
}
|
|
30
|
+
if ('harness' in raw && raw.harness && raw.sessionId) {
|
|
31
|
+
return {
|
|
32
|
+
version: 1,
|
|
33
|
+
conversations: {
|
|
34
|
+
[raw.harness]: {
|
|
35
|
+
sessionId: raw.sessionId,
|
|
36
|
+
turns: raw.turns ?? 0,
|
|
37
|
+
updatedAt: raw.updatedAt,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { version: 1, conversations: {} }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeStore(root: string, store: ConversationStore): void {
|
|
46
|
+
const entries = Object.entries(store.conversations).filter(([, c]) => !!c.sessionId)
|
|
47
|
+
if (entries.length === 0) {
|
|
48
|
+
removeState(root, SESSION_FILE)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
writeJson(root, SESSION_FILE, {
|
|
52
|
+
version: 1,
|
|
53
|
+
conversations: Object.fromEntries(entries),
|
|
54
|
+
} satisfies ConversationStore)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readConversation(root: string, harness: string): HarnessConversation {
|
|
58
|
+
return readStore(root).conversations[harness] ?? {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function conversationInfo(root: string, harness: string): ConversationInfo {
|
|
62
|
+
const conversation = readConversation(root, harness)
|
|
63
|
+
return {
|
|
64
|
+
active: !!conversation.sessionId,
|
|
65
|
+
turns: conversation.turns ?? 0,
|
|
66
|
+
harness: conversation.sessionId ? harness : undefined,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sessionInfo(root: string, harness: string): AgentSessionInfo {
|
|
71
|
+
const conversation = readConversation(root, harness)
|
|
72
|
+
return {
|
|
73
|
+
sessionId: conversation.sessionId ?? null,
|
|
74
|
+
turns: conversation.turns ?? 0,
|
|
75
|
+
harness: conversation.sessionId ? harness : undefined,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveConversation(
|
|
80
|
+
root: string,
|
|
81
|
+
harness: string,
|
|
82
|
+
conversation: Required<Pick<HarnessConversation, 'sessionId' | 'turns'>> &
|
|
83
|
+
Pick<HarnessConversation, 'updatedAt'>,
|
|
84
|
+
): void {
|
|
85
|
+
const store = readStore(root)
|
|
86
|
+
store.conversations[harness] = conversation
|
|
87
|
+
writeStore(root, store)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setConversationSession(root: string, harness: string, sessionId: string): void {
|
|
91
|
+
const trimmed = sessionId.trim()
|
|
92
|
+
if (!trimmed) {
|
|
93
|
+
clearConversation(root, harness)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const store = readStore(root)
|
|
97
|
+
const previous = store.conversations[harness] ?? {}
|
|
98
|
+
store.conversations[harness] = {
|
|
99
|
+
sessionId: trimmed,
|
|
100
|
+
turns: previous.turns ?? 0,
|
|
101
|
+
updatedAt: new Date().toISOString(),
|
|
102
|
+
}
|
|
103
|
+
writeStore(root, store)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function clearConversation(root: string, harness: string): void {
|
|
107
|
+
const store = readStore(root)
|
|
108
|
+
delete store.conversations[harness]
|
|
109
|
+
writeStore(root, store)
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* harness/adapter.ts — the executable boundary implemented by each local agent
|
|
3
|
+
* harness.
|
|
3
4
|
*
|
|
4
5
|
* A *harness* is whatever local AI agent does the edits (Claude Code, Codex, …).
|
|
5
6
|
* The studio never talks to a model API directly; it shells out to the harness
|
|
@@ -7,7 +8,13 @@
|
|
|
7
8
|
* harness maps its native streaming output onto `AgentStreamEvent`s and returns
|
|
8
9
|
* a final text blob (which carries the machine-state reply block).
|
|
9
10
|
*/
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
AgentAccess,
|
|
13
|
+
AgentEffort,
|
|
14
|
+
AgentEventKind,
|
|
15
|
+
HarnessCapabilities,
|
|
16
|
+
HarnessLoadout,
|
|
17
|
+
} from '../../../shared/types'
|
|
11
18
|
|
|
12
19
|
/** A single normalized activity event emitted while the harness runs. */
|
|
13
20
|
export interface AgentStreamEvent {
|
|
@@ -17,6 +24,21 @@ export interface AgentStreamEvent {
|
|
|
17
24
|
target?: string
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/** Harness-neutral description of one stdio MCP server. Each harness is
|
|
28
|
+
* responsible for expressing this in its native CLI/config shape. */
|
|
29
|
+
export interface HarnessMcpServer {
|
|
30
|
+
name: string
|
|
31
|
+
command: string
|
|
32
|
+
args?: string[]
|
|
33
|
+
env?: Record<string, string>
|
|
34
|
+
required?: boolean
|
|
35
|
+
/** Studio's run-scoped write-back tools are user-authorized by the submit. */
|
|
36
|
+
approvalMode?: 'auto' | 'prompt' | 'writes' | 'approve'
|
|
37
|
+
enabledTools?: string[]
|
|
38
|
+
/** In-process harnesses may invoke the same grant without spawning stdio. */
|
|
39
|
+
invoke?: (tool: string, args: Record<string, unknown>) => Promise<unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
export interface AgentTurnInput {
|
|
21
43
|
/** the domain repo root — the agent's working directory */
|
|
22
44
|
root: string
|
|
@@ -26,13 +48,16 @@ export interface AgentTurnInput {
|
|
|
26
48
|
appendSystemPrompt?: string
|
|
27
49
|
/** prior harness session id → resume the same conversation */
|
|
28
50
|
sessionId?: string
|
|
51
|
+
/** explicit model override; absent preserves the harness's native config/default */
|
|
52
|
+
model?: string
|
|
29
53
|
/** harness reasoning effort for this turn */
|
|
30
54
|
effort?: AgentEffort
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
55
|
+
/** authority granted to the local harness */
|
|
56
|
+
access?: AgentAccess
|
|
57
|
+
/** generated MCP servers exposing Studio write-back */
|
|
58
|
+
mcpServers?: HarnessMcpServer[]
|
|
59
|
+
/** extra env merged into the harness CHILD process only. Never touches the
|
|
60
|
+
* studio's own env or the user's shell/global harness process. */
|
|
36
61
|
env?: Record<string, string>
|
|
37
62
|
/** abort the turn (user pressed Cancel) */
|
|
38
63
|
signal: AbortSignal
|
|
@@ -47,7 +72,7 @@ export interface AgentTurnResult {
|
|
|
47
72
|
finalText: string
|
|
48
73
|
costUsd?: number
|
|
49
74
|
numTurns?: number
|
|
50
|
-
/** total
|
|
75
|
+
/** total token usage reported by the harness */
|
|
51
76
|
tokens?: number
|
|
52
77
|
isError: boolean
|
|
53
78
|
errorMessage?: string
|
|
@@ -68,9 +93,13 @@ export interface AskInput {
|
|
|
68
93
|
appendSystemPrompt?: string
|
|
69
94
|
/** the domain's live conversation session id to FORK from (undefined ⇒ fresh, no fork) */
|
|
70
95
|
sessionId?: string
|
|
96
|
+
/** explicit model override; absent preserves the harness's native config/default */
|
|
97
|
+
model?: string
|
|
71
98
|
/** harness reasoning effort for this side question */
|
|
72
99
|
effort?: AgentEffort
|
|
73
|
-
/**
|
|
100
|
+
/** authority granted to the local harness */
|
|
101
|
+
access?: AgentAccess
|
|
102
|
+
/** extra env merged into the harness child only — see AgentTurnInput.env */
|
|
74
103
|
env?: Record<string, string>
|
|
75
104
|
signal: AbortSignal
|
|
76
105
|
/** called with each chunk of the answer as it streams in */
|
|
@@ -93,16 +122,26 @@ export interface HarnessHealth {
|
|
|
93
122
|
detail?: string
|
|
94
123
|
}
|
|
95
124
|
|
|
125
|
+
export interface HarnessLoadoutOptions {
|
|
126
|
+
/** Child-only environment overrides used while probing the harness. */
|
|
127
|
+
env?: Record<string, string>
|
|
128
|
+
/** Studio's per-domain override for the selected harness. */
|
|
129
|
+
model?: string
|
|
130
|
+
/** Bypass the adapter's short-lived probe cache for an explicit user re-probe. */
|
|
131
|
+
refresh?: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
96
134
|
export interface AgentHarness {
|
|
97
135
|
/** stable id, e.g. 'claude' | 'mock' | 'codex' */
|
|
98
136
|
id: string
|
|
99
137
|
/** human label for the UI */
|
|
100
138
|
label: string
|
|
139
|
+
capabilities: HarnessCapabilities
|
|
101
140
|
/** is the underlying CLI/binary actually invokable here? */
|
|
102
|
-
isAvailable(): Promise<boolean>
|
|
141
|
+
isAvailable(signal?: AbortSignal): Promise<boolean>
|
|
103
142
|
/** optional: a richer install probe for the UI (version + reason). Harnesses that
|
|
104
143
|
* omit it fall back to isAvailable(). */
|
|
105
|
-
health?(): Promise<HarnessHealth>
|
|
144
|
+
health?(signal?: AbortSignal): Promise<HarnessHealth>
|
|
106
145
|
run(input: AgentTurnInput): Promise<AgentTurnResult>
|
|
107
146
|
/** optional: answer a quick forked side question (Ask). Harnesses without a
|
|
108
147
|
* fork concept simply omit this; the studio falls back gracefully. */
|
|
@@ -111,10 +150,22 @@ export interface AgentHarness {
|
|
|
111
150
|
* tools, agents) — a read-only window into the agent for the Settings page.
|
|
112
151
|
* Harnesses without an introspectable loadout omit it. `env` (e.g. a custom
|
|
113
152
|
* model gateway) is merged into the probe child so the reported model reflects it. */
|
|
114
|
-
loadout?(root: string,
|
|
153
|
+
loadout?(root: string, options?: HarnessLoadoutOptions): Promise<HarnessLoadout>
|
|
115
154
|
/** optional: the raw SKILL.md for a skill command, so the UI can show it. */
|
|
116
155
|
skillContent?(
|
|
117
156
|
root: string,
|
|
118
157
|
command: string,
|
|
119
158
|
): Promise<{ command: string; content: string; path: string } | null>
|
|
120
159
|
}
|
|
160
|
+
|
|
161
|
+
/** Normalize optional rich health probes into one route-facing result. */
|
|
162
|
+
export async function inspectHarnessHealth(
|
|
163
|
+
harness: AgentHarness,
|
|
164
|
+
signal?: AbortSignal,
|
|
165
|
+
): Promise<HarnessHealth> {
|
|
166
|
+
if (harness.health) return harness.health(signal)
|
|
167
|
+
return {
|
|
168
|
+
ok: await harness.isAvailable(signal),
|
|
169
|
+
bin: harness.id,
|
|
170
|
+
}
|
|
171
|
+
}
|