@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,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-mcp.ts — a minimal stdio MCP server the harness spawns (via the
4
- * generated --mcp-config). It exposes the Domain Studio write-back tools and
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
- const BASE = process.env.DOMAIN_STUDIO_BRIDGE_URL || ''
12
- const TOKEN = process.env.DOMAIN_STUDIO_BRIDGE_TOKEN || ''
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 out = await callBridge(tool.route, (params?.arguments ?? {}) as Record<string, unknown>)
156
- reply(id, { content: [{ type: 'text', text: out }] })
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
+ }