@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,151 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { DomainHandle } from '../../domain'
|
|
7
|
+
|
|
8
|
+
import { readComments, upsertComment } from '../../state/comments'
|
|
9
|
+
import { startBridge } from './grant'
|
|
10
|
+
import { handleBridge } from './routes'
|
|
11
|
+
|
|
12
|
+
const roots: string[] = []
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function domain(root: string, id: string): DomainHandle {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
root,
|
|
22
|
+
configFile: join(root, 'astrale.config.ts'),
|
|
23
|
+
domainFile: join(root, 'domain.ts'),
|
|
24
|
+
schemaDirName: 'schema',
|
|
25
|
+
schemaDir: join(root, 'schema'),
|
|
26
|
+
schemaIndex: join(root, 'schema/index.ts'),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('authorizes one domain and applies live thread mutations', async () => {
|
|
31
|
+
const rootA = mkdtempSync(join(tmpdir(), 'studio-bridge-a-'))
|
|
32
|
+
const rootB = mkdtempSync(join(tmpdir(), 'studio-bridge-b-'))
|
|
33
|
+
roots.push(rootA, rootB)
|
|
34
|
+
const domainA = domain(rootA, 'domain-a')
|
|
35
|
+
const domainB = domain(rootB, 'domain-b')
|
|
36
|
+
const awaiting = upsertComment(rootA, {
|
|
37
|
+
anchors: ['Test'],
|
|
38
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
39
|
+
text: 'please answer',
|
|
40
|
+
})
|
|
41
|
+
upsertComment(rootA, {
|
|
42
|
+
anchors: ['Agent question'],
|
|
43
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
44
|
+
text: 'already answered by author',
|
|
45
|
+
firstRole: 'author',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const notifications: string[] = []
|
|
49
|
+
const bridge = startBridge(domainA, (event) =>
|
|
50
|
+
notifications.push(`${event.type}:${'domainId' in event ? event.domainId : ''}`),
|
|
51
|
+
)
|
|
52
|
+
const configPath = bridge.mcpServers[0]!.args!.at(-1)!
|
|
53
|
+
const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
|
|
54
|
+
const replies: string[] = []
|
|
55
|
+
const progress: string[] = []
|
|
56
|
+
bridge.onReply((id, text) => replies.push(`${id}:${text}`))
|
|
57
|
+
bridge.onProgress((text) => progress.push(text))
|
|
58
|
+
const request = new Request('http://studio.test')
|
|
59
|
+
|
|
60
|
+
const invalid = await handleBridge(domainA, 'reply', request, {
|
|
61
|
+
token: 'wrong',
|
|
62
|
+
commentId: awaiting.id,
|
|
63
|
+
text: 'forged',
|
|
64
|
+
})
|
|
65
|
+
const crossDomain = await handleBridge(domainB, 'reply', request, {
|
|
66
|
+
token,
|
|
67
|
+
commentId: awaiting.id,
|
|
68
|
+
text: 'forged',
|
|
69
|
+
})
|
|
70
|
+
expect([invalid.status, crossDomain.status]).toEqual([401, 401])
|
|
71
|
+
expect(readComments(rootA).comments[0]!.thread).toHaveLength(1)
|
|
72
|
+
|
|
73
|
+
const listed = await handleBridge(domainA, 'threads', request, { token })
|
|
74
|
+
expect(await listed.json()).toEqual({
|
|
75
|
+
threads: [
|
|
76
|
+
{
|
|
77
|
+
id: awaiting.id,
|
|
78
|
+
kind: 'comment',
|
|
79
|
+
anchor: 'class.Test',
|
|
80
|
+
file: null,
|
|
81
|
+
latest: 'please answer',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const replied = await handleBridge(domainA, 'reply', request, {
|
|
87
|
+
token,
|
|
88
|
+
commentId: awaiting.id,
|
|
89
|
+
text: 'done',
|
|
90
|
+
resolve: true,
|
|
91
|
+
closeNote: 'complete',
|
|
92
|
+
})
|
|
93
|
+
await handleBridge(domainA, 'progress', request, { token, text: 'working' })
|
|
94
|
+
const raised = await handleBridge(domainA, 'raise_question', request, {
|
|
95
|
+
token,
|
|
96
|
+
ref: 'class.Test.property.value',
|
|
97
|
+
text: 'Which value?',
|
|
98
|
+
options: ['A', 'B'],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(await replied.json()).toEqual({ ok: true, resolved: true })
|
|
102
|
+
expect(await raised.json()).toMatchObject({ ok: true })
|
|
103
|
+
const comments = readComments(rootA).comments
|
|
104
|
+
expect(comments[0]).toMatchObject({
|
|
105
|
+
id: awaiting.id,
|
|
106
|
+
status: 'closed',
|
|
107
|
+
closeNote: 'complete',
|
|
108
|
+
})
|
|
109
|
+
expect(comments[0]!.thread.at(-1)).toMatchObject({ role: 'author', text: 'done' })
|
|
110
|
+
expect(comments.at(-1)).toMatchObject({
|
|
111
|
+
kind: 'question',
|
|
112
|
+
status: 'open',
|
|
113
|
+
thread: [{ role: 'author', type: 'choice', text: 'Which value?', options: ['A', 'B'] }],
|
|
114
|
+
})
|
|
115
|
+
expect(replies).toEqual([`${awaiting.id}:done`])
|
|
116
|
+
expect(progress).toEqual(['working'])
|
|
117
|
+
expect(notifications).toEqual(['comments:domain-a', 'comments:domain-a'])
|
|
118
|
+
|
|
119
|
+
bridge.dispose()
|
|
120
|
+
const expired = await handleBridge(domainA, 'threads', request, { token })
|
|
121
|
+
expect(expired.status).toBe(401)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('returns committed success when UI notification delivery fails', async () => {
|
|
125
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-bridge-notify-'))
|
|
126
|
+
roots.push(root)
|
|
127
|
+
const handle = domain(root, 'domain-notify')
|
|
128
|
+
const comment = upsertComment(root, {
|
|
129
|
+
anchors: ['Test'],
|
|
130
|
+
anchorRefs: [{ ref: 'class.Test', kind: 'schema' }],
|
|
131
|
+
text: 'reply once',
|
|
132
|
+
})
|
|
133
|
+
const bridge = startBridge(handle, () => {
|
|
134
|
+
throw new Error('listener failed')
|
|
135
|
+
})
|
|
136
|
+
const configPath = bridge.mcpServers[0]!.args!.at(-1)!
|
|
137
|
+
const { token } = JSON.parse(readFileSync(configPath, 'utf8')) as { token: string }
|
|
138
|
+
|
|
139
|
+
const response = await handleBridge(handle, 'reply', new Request('http://studio.test'), {
|
|
140
|
+
token,
|
|
141
|
+
commentId: comment.id,
|
|
142
|
+
text: 'committed',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
expect(response.status).toBe(200)
|
|
146
|
+
expect(readComments(root).comments[0]!.thread.map((entry) => entry.text)).toEqual([
|
|
147
|
+
'reply once',
|
|
148
|
+
'committed',
|
|
149
|
+
])
|
|
150
|
+
bridge.dispose()
|
|
151
|
+
})
|
|
@@ -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
|
+
}
|