@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,133 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { HarnessTokenBroker, HarnessTokenError } from './token'
4
+
5
+ function jwt(audience: string, expiresAtMs = Date.now() + 3_600_000): string {
6
+ const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url')
7
+ const payload = Buffer.from(
8
+ JSON.stringify({ aud: audience, exp: Math.floor(expiresAtMs / 1000) }),
9
+ ).toString('base64url')
10
+ return `${header}.${payload}.signature`
11
+ }
12
+
13
+ test('resolves static and audience-bound host-relayed gateway tokens', async () => {
14
+ const broker = new HarnessTokenBroker()
15
+ expect(
16
+ await broker.acquireGatewayToken(
17
+ {
18
+ enabled: true,
19
+ baseUrl: 'https://gateway.example',
20
+ auth: { mode: 'token', token: ' x ' },
21
+ },
22
+ 'https://gateway.example',
23
+ ),
24
+ ).toBe('x')
25
+
26
+ const audience = 'https://host.example'
27
+ const token = jwt(audience)
28
+ expect(broker.setHostToken(audience, token)).toBe(true)
29
+ expect(broker.setHostToken(audience, jwt('https://other.example'))).toBe(false)
30
+ expect(
31
+ await broker.acquireGatewayToken(
32
+ { enabled: true, baseUrl: audience, auth: { mode: 'host' } },
33
+ audience,
34
+ ),
35
+ ).toBe(token)
36
+ })
37
+
38
+ test('reports a missing host relay as the concrete typed failure', async () => {
39
+ expect.assertions(2)
40
+ const broker = new HarnessTokenBroker()
41
+ try {
42
+ await broker.acquireGatewayToken(
43
+ {
44
+ enabled: true,
45
+ baseUrl: 'https://missing.example',
46
+ auth: { mode: 'host' },
47
+ },
48
+ 'https://missing.example',
49
+ )
50
+ } catch (error) {
51
+ expect(error).toBeInstanceOf(HarnessTokenError)
52
+ expect((error as HarnessTokenError).kind).toBe('host-token-needed')
53
+ }
54
+ })
55
+
56
+ test('mints with the exact audience and instance, caches, and coalesces concurrent callers', async () => {
57
+ const calls: { args: string[]; timeoutMs?: number }[] = []
58
+ const audience = 'https://gateway.example'
59
+ const token = jwt(audience)
60
+ const broker = new HarnessTokenBroker({
61
+ capture: async (_bin, args, _cwd, options) => {
62
+ calls.push({ args, timeoutMs: options?.timeoutMs })
63
+ await Promise.resolve()
64
+ return { code: 0, stdout: `${token}\n`, stderr: '' }
65
+ },
66
+ })
67
+ const config = {
68
+ enabled: true,
69
+ baseUrl: audience,
70
+ auth: { mode: 'mint' as const, instance: 'prod' },
71
+ }
72
+
73
+ expect(
74
+ await Promise.all([
75
+ broker.acquireGatewayToken(config, audience),
76
+ broker.acquireGatewayToken(config, audience),
77
+ ]),
78
+ ).toEqual([token, token])
79
+ expect(await broker.acquireGatewayToken(config, audience)).toBe(token)
80
+ expect(calls).toEqual([
81
+ {
82
+ args: ['token', '--audience', audience, '--ttl', '3600', '--raw', '-i', 'prod'],
83
+ timeoutMs: 12_000,
84
+ },
85
+ ])
86
+ })
87
+
88
+ test('isolates mint caches by audience and instance', async () => {
89
+ let calls = 0
90
+ const broker = new HarnessTokenBroker({
91
+ capture: async (_bin, args) => {
92
+ calls++
93
+ const audience = args[args.indexOf('--audience') + 1]
94
+ return { code: 0, stdout: jwt(audience), stderr: '' }
95
+ },
96
+ })
97
+
98
+ for (const [audience, instance] of [
99
+ ['https://one.example', 'a'],
100
+ ['https://one.example', 'b'],
101
+ ['https://two.example', 'a'],
102
+ ] as const)
103
+ await broker.acquireGatewayToken(
104
+ { enabled: true, baseUrl: audience, auth: { mode: 'mint', instance } },
105
+ audience,
106
+ )
107
+
108
+ expect(calls).toBe(3)
109
+ })
110
+
111
+ test('turns failed, blank, or wrong-audience mint output into a typed failure', async () => {
112
+ expect.assertions(6)
113
+ for (const result of [
114
+ { code: 7, stdout: '', stderr: 'not signed in' },
115
+ { code: 0, stdout: ' ', stderr: '' },
116
+ { code: 0, stdout: jwt('https://other.example'), stderr: '' },
117
+ ]) {
118
+ const broker = new HarnessTokenBroker({ capture: async () => result })
119
+ try {
120
+ await broker.acquireGatewayToken(
121
+ {
122
+ enabled: true,
123
+ baseUrl: 'https://gateway.example',
124
+ auth: { mode: 'mint' },
125
+ },
126
+ 'https://gateway.example',
127
+ )
128
+ } catch (error) {
129
+ expect(error).toBeInstanceOf(HarnessTokenError)
130
+ expect((error as HarnessTokenError).kind).toBe('mint-failed')
131
+ }
132
+ }
133
+ })
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Acquire bearer tokens for a harness model gateway.
3
+ *
4
+ * - mint: shell out to `astrale token`, cache the short-lived delegation, and
5
+ * coalesce concurrent requests for the same audience/instance
6
+ * - token: use the manually configured bearer
7
+ * - host: consume a token relayed by the embedding Astrale application
8
+ *
9
+ * Tokens only flow into the spawned harness child environment.
10
+ */
11
+ import type { HarnessGatewayConfig } from '../../../../shared/types'
12
+
13
+ import { captureCommand, type CapturedCommand, type CaptureOptions } from '../process'
14
+
15
+ const MINT_TTL_SECONDS = 3600
16
+ const REFRESH_SKEW_MS = 5 * 60_000
17
+
18
+ interface CachedToken {
19
+ token: string
20
+ expiresAtMs: number
21
+ }
22
+
23
+ type CaptureHarnessCommand = (
24
+ bin: string,
25
+ args: string[],
26
+ cwd: string,
27
+ options?: CaptureOptions,
28
+ ) => Promise<CapturedCommand>
29
+
30
+ export interface HarnessTokenBrokerOptions {
31
+ capture?: CaptureHarnessCommand
32
+ now?: () => number
33
+ }
34
+
35
+ export class HarnessTokenError extends Error {
36
+ constructor(
37
+ message: string,
38
+ readonly kind: 'mint-failed' | 'host-token-needed' | 'config',
39
+ ) {
40
+ super(message)
41
+ this.name = 'HarnessTokenError'
42
+ }
43
+ }
44
+
45
+ function jwtClaims(jwt: string): { audience?: string | string[]; expiresAtMs?: number } | null {
46
+ try {
47
+ const payload = Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')
48
+ const claims = JSON.parse(payload)
49
+ return {
50
+ audience:
51
+ typeof claims?.aud === 'string' ||
52
+ (Array.isArray(claims?.aud) &&
53
+ claims.aud.every((item: unknown) => typeof item === 'string'))
54
+ ? claims.aud
55
+ : undefined,
56
+ expiresAtMs: typeof claims?.exp === 'number' ? claims.exp * 1000 : undefined,
57
+ }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ function audienceMatches(actual: string | string[] | undefined, expected: string): boolean {
64
+ return actual === expected || (Array.isArray(actual) && actual.includes(expected))
65
+ }
66
+
67
+ export class HarnessTokenBroker {
68
+ private readonly mintCache = new Map<string, CachedToken>()
69
+ private readonly hostTokens = new Map<string, CachedToken>()
70
+ private readonly inFlightMints = new Map<string, Promise<string>>()
71
+ private readonly capture: CaptureHarnessCommand
72
+ private readonly now: () => number
73
+
74
+ constructor(options: HarnessTokenBrokerOptions = {}) {
75
+ this.capture = options.capture ?? captureCommand
76
+ this.now = options.now ?? Date.now
77
+ }
78
+
79
+ /** Relay a host-owned delegation token into the embedded Studio process. */
80
+ setHostToken(audience: string, token: string): boolean {
81
+ if (!audience || !token || token.split('.').length !== 3) return false
82
+ const claims = jwtClaims(token)
83
+ if (!claims || !audienceMatches(claims.audience, audience)) return false
84
+ const expiresAtMs = claims.expiresAtMs ?? this.now() + MINT_TTL_SECONDS * 1000
85
+ if (expiresAtMs <= this.now()) return false
86
+ this.hostTokens.set(audience, { token, expiresAtMs })
87
+ return true
88
+ }
89
+
90
+ /** Acquire the configured gateway bearer for one child-process invocation. */
91
+ async acquireGatewayToken(config: HarnessGatewayConfig, audience: string): Promise<string> {
92
+ switch (config.auth.mode) {
93
+ case 'token': {
94
+ const token = config.auth.token.trim()
95
+ if (!token) throw new HarnessTokenError('no token set for this gateway', 'config')
96
+ return token
97
+ }
98
+ case 'host':
99
+ return this.readHostToken(audience)
100
+ default:
101
+ return this.mintToken(audience, config.auth.instance)
102
+ }
103
+ }
104
+
105
+ private readHostToken(audience: string): string {
106
+ const cached = this.hostTokens.get(audience)
107
+ if (!cached || cached.expiresAtMs <= this.now())
108
+ throw new HarnessTokenError(
109
+ 'no valid host-supplied token — the embedding Astrale app must provide one',
110
+ 'host-token-needed',
111
+ )
112
+ return cached.token
113
+ }
114
+
115
+ private mintToken(audience: string, instance?: string): Promise<string> {
116
+ const key = `${audience}\u0000${instance ?? ''}`
117
+ const cached = this.mintCache.get(key)
118
+ if (cached && cached.expiresAtMs - this.now() > REFRESH_SKEW_MS)
119
+ return Promise.resolve(cached.token)
120
+
121
+ const pending = this.inFlightMints.get(key)
122
+ if (pending) return pending
123
+
124
+ let mint!: Promise<string>
125
+ mint = this.mintAndCache(key, audience, instance).finally(() => {
126
+ if (this.inFlightMints.get(key) === mint) this.inFlightMints.delete(key)
127
+ })
128
+ this.inFlightMints.set(key, mint)
129
+ return mint
130
+ }
131
+
132
+ private async mintAndCache(key: string, audience: string, instance?: string): Promise<string> {
133
+ const args = ['token', '--audience', audience, '--ttl', String(MINT_TTL_SECONDS), '--raw']
134
+ if (instance) args.push('-i', instance)
135
+ const result = await this.capture('astrale', args, process.cwd(), { timeoutMs: 12_000 })
136
+ const token = result.code === 0 ? result.stdout.trim() : ''
137
+ const claims = token && token.split('.').length === 3 ? jwtClaims(token) : null
138
+ if (
139
+ !claims ||
140
+ !audienceMatches(claims.audience, audience) ||
141
+ (claims.expiresAtMs !== undefined && claims.expiresAtMs <= this.now())
142
+ )
143
+ throw new HarnessTokenError(
144
+ 'could not mint a delegation token — is the instance reachable and are you signed in? (try `astrale login` / `astrale use <instance>`)',
145
+ 'mint-failed',
146
+ )
147
+ this.mintCache.set(key, {
148
+ token,
149
+ expiresAtMs: claims.expiresAtMs ?? this.now() + MINT_TTL_SECONDS * 1000,
150
+ })
151
+ return token
152
+ }
153
+ }
154
+
155
+ const defaultBroker = new HarnessTokenBroker()
156
+
157
+ export function setHostToken(audience: string, token: string): boolean {
158
+ return defaultBroker.setHostToken(audience, token)
159
+ }
160
+
161
+ export function acquireGatewayToken(
162
+ config: HarnessGatewayConfig,
163
+ audience: string,
164
+ ): Promise<string> {
165
+ return defaultBroker.acquireGatewayToken(config, audience)
166
+ }
@@ -0,0 +1,190 @@
1
+ import type { Comment } from '../../../../shared/types'
2
+ import type { AgentHarness, AgentTurnInput, AgentTurnResult, AskInput, AskResult } from '../adapter'
3
+
4
+ import { readComments } from '../../../state/comments'
5
+ import { applyMockDomainEdit } from './domain-edit'
6
+
7
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
8
+ if (signal.aborted) return Promise.resolve()
9
+ return new Promise((resolve) => {
10
+ const timer = setTimeout(resolve, ms)
11
+ signal.addEventListener(
12
+ 'abort',
13
+ () => {
14
+ clearTimeout(timer)
15
+ resolve()
16
+ },
17
+ { once: true },
18
+ )
19
+ })
20
+ }
21
+
22
+ export class MockHarness implements AgentHarness {
23
+ id = 'mock'
24
+ label = 'Mock agent (free)'
25
+ capabilities = {
26
+ effortLevels: ['low', 'medium', 'high'],
27
+ accessLevels: ['workspace', 'full'],
28
+ ask: true,
29
+ loadout: false,
30
+ gateway: 'none',
31
+ } as const
32
+
33
+ async isAvailable(): Promise<boolean> {
34
+ return true
35
+ }
36
+
37
+ async run(input: AgentTurnInput): Promise<AgentTurnResult> {
38
+ const expectedModel = process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL
39
+ if (expectedModel && input.model !== expectedModel)
40
+ throw new Error(
41
+ `mock expected model ${expectedModel}, received ${input.model ?? '(default)'}`,
42
+ )
43
+ const mode = process.env.DOMAIN_STUDIO_MOCK_MODE || 'normal'
44
+ const extraDelay = Number(process.env.DOMAIN_STUDIO_MOCK_DELAY_MS || 0)
45
+ if ((mode === 'resumefail' || mode === 'resumefailafterevent') && input.sessionId) {
46
+ input.onEvent({ kind: 'status', text: 'resuming…' })
47
+ if (mode === 'resumefailafterevent')
48
+ input.onEvent({
49
+ kind: 'tool',
50
+ text: 'Edit',
51
+ tool: 'Edit',
52
+ target: 'schema/test.ts',
53
+ })
54
+ return {
55
+ sessionId: input.sessionId,
56
+ finalText: '',
57
+ isError: true,
58
+ errorMessage: 'mock: no conversation found with session id',
59
+ resumeRejected: true,
60
+ }
61
+ }
62
+ const store = readComments(input.root)
63
+ const open = store.comments.filter(
64
+ (comment) => comment.status === 'open' && comment.thread.at(-1)?.role !== 'author',
65
+ )
66
+
67
+ input.onEvent({ kind: 'status', text: 'session started' })
68
+ await sleep(250, input.signal)
69
+ if (extraDelay > 0) await sleep(extraDelay, input.signal)
70
+ if (mode === 'error') throw new Error('mock harness failure (test)')
71
+ input.onEvent({
72
+ kind: 'thinking',
73
+ text: `Reviewing ${open.length} open thread(s) and the current schema.`,
74
+ })
75
+ await sleep(300, input.signal)
76
+ input.onEvent({
77
+ kind: 'tool',
78
+ text: 'Read',
79
+ tool: 'Read',
80
+ target: '.domain-studio/comments.json',
81
+ })
82
+ await sleep(250, input.signal)
83
+
84
+ const seed = open[0]?.thread.at(-1)?.text ?? 'note'
85
+ const edit = input.signal.aborted ? null : applyMockDomainEdit(input.root, seed)
86
+ if (edit) {
87
+ input.onEvent({ kind: 'tool', text: 'Edit', tool: 'Edit', target: edit.file })
88
+ await sleep(300, input.signal)
89
+ }
90
+ input.onEvent({
91
+ kind: 'message',
92
+ text: edit
93
+ ? `Added a \`${edit.prop}\` property to \`${edit.file}\` and answered the open threads.`
94
+ : 'Answered the open threads.',
95
+ })
96
+
97
+ const replyText = edit
98
+ ? `Done — implemented this by adding \`${edit.prop}\` to \`${edit.file}\`. (mock agent)`
99
+ : 'Acknowledged. (mock agent)'
100
+
101
+ if ((mode === 'liveandblock' || mode === 'liveandblockdifferent') && open[0]) {
102
+ const bridge = input.mcpServers?.find((server) => server.name === 'domain-studio')
103
+ if (!bridge?.invoke) throw new Error('mock bridge grant is not invokable')
104
+ await bridge.invoke('reply_to_thread', {
105
+ commentId: open[0].id,
106
+ text: replyText,
107
+ resolve: true,
108
+ closeNote: 'mock live reply',
109
+ })
110
+ }
111
+
112
+ const replied: Comment[] = open.map((comment) => ({
113
+ ...comment,
114
+ status: 'closed',
115
+ thread: [
116
+ ...comment.thread,
117
+ {
118
+ id: crypto.randomUUID(),
119
+ role: 'author' as const,
120
+ type: 'text' as const,
121
+ text: replyText,
122
+ },
123
+ ...(mode === 'liveandblockdifferent'
124
+ ? [
125
+ {
126
+ id: crypto.randomUUID(),
127
+ role: 'author' as const,
128
+ type: 'text' as const,
129
+ text: 'Additional final detail. (mock agent)',
130
+ },
131
+ ]
132
+ : []),
133
+ ],
134
+ }))
135
+ const machine = {
136
+ schemaVersion: store.schemaVersion,
137
+ comments: replied.map((comment) => ({
138
+ id: comment.id,
139
+ anchors: comment.anchors,
140
+ status: mode === 'openreply' ? 'open' : comment.status,
141
+ thread: comment.thread,
142
+ })),
143
+ }
144
+ const finalText =
145
+ mode === 'noblock'
146
+ ? 'I reviewed the open threads and made the edit. (no machine-state block — resilience test)'
147
+ : mode === 'badblock'
148
+ ? 'I made the edit.\n\n```json\n{ this is : not valid json, ]\n```\n'
149
+ : `I reviewed the open threads and made the edit.\n\n\`\`\`json\n${JSON.stringify(machine, null, 2)}\n\`\`\`\n`
150
+
151
+ return {
152
+ sessionId: input.sessionId ?? 'mock-session',
153
+ finalText,
154
+ costUsd: 0,
155
+ numTurns: 1,
156
+ isError: false,
157
+ }
158
+ }
159
+
160
+ async ask(input: AskInput): Promise<AskResult> {
161
+ const expectedModel = process.env.DOMAIN_STUDIO_MOCK_EXPECT_MODEL
162
+ if (expectedModel && input.model !== expectedModel)
163
+ return {
164
+ text: '',
165
+ isError: true,
166
+ errorMessage: `mock expected model ${expectedModel}, received ${input.model ?? '(default)'}`,
167
+ }
168
+ const expectedSession = process.env.DOMAIN_STUDIO_MOCK_EXPECT_SESSION
169
+ if (expectedSession && input.sessionId !== expectedSession)
170
+ return {
171
+ text: '',
172
+ isError: true,
173
+ errorMessage: `mock expected session ${expectedSession}, received ${input.sessionId ?? '(fresh)'}`,
174
+ }
175
+ const forked = input.sessionId ? `(forked from ${input.sessionId.slice(0, 8)}…) ` : '(fresh) '
176
+ const parts = [
177
+ forked,
178
+ 'This is a mock answer to your side question. ',
179
+ 'In a real run, the selected harness would answer from the inherited conversation context.',
180
+ ]
181
+ let text = ''
182
+ for (const part of parts) {
183
+ if (input.signal.aborted) break
184
+ text += part
185
+ input.onDelta(part)
186
+ await sleep(180, input.signal)
187
+ }
188
+ return { text, isError: false }
189
+ }
190
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ function identifier(text: string, fallback: string): string {
5
+ const words = text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9 ]+/g, ' ')
8
+ .trim()
9
+ .split(/\s+/)
10
+ .filter(Boolean)
11
+ .slice(0, 3)
12
+ if (words.length === 0) return fallback
13
+ return words
14
+ .map((word, index) => (index === 0 ? word : word[0].toUpperCase() + word.slice(1)))
15
+ .join('')
16
+ }
17
+
18
+ /** Apply the deterministic schema edit used by the local mock harness. */
19
+ export function applyMockDomainEdit(
20
+ root: string,
21
+ instruction: string,
22
+ ): { file: string; prop: string } | null {
23
+ const schemaDir = join(root, 'schema')
24
+ if (!existsSync(schemaDir)) return null
25
+ const files = readdirSync(schemaDir).filter((file) => file.endsWith('.ts') && file !== 'index.ts')
26
+ const propName = identifier(instruction, 'agentNote')
27
+ for (const file of files) {
28
+ const absolute = join(schemaDir, file)
29
+ const source = readFileSync(absolute, 'utf8')
30
+ const props = source.indexOf('props: {')
31
+ if (props < 0) continue
32
+ let prop = propName
33
+ let suffix = 2
34
+ while (new RegExp(`\\b${prop}\\b\\s*:`).test(source)) prop = `${propName}${suffix++}`
35
+ const insertAt = props + 'props: {'.length
36
+ const line = `\n /** Added by the agent in response to a studio comment. */\n ${prop}: z.string().optional(),`
37
+ writeFileSync(absolute, source.slice(0, insertAt) + line + source.slice(insertAt))
38
+ return { file: `schema/${file}`, prop }
39
+ }
40
+ return null
41
+ }
@@ -0,0 +1,46 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { captureCommand } from './process'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ function hangingCommand(root: string): string {
15
+ const file = join(root, 'hang')
16
+ writeFileSync(file, '#!/usr/bin/env bun\nsetInterval(() => {}, 1000)\n')
17
+ chmodSync(file, 0o755)
18
+ return file
19
+ }
20
+
21
+ test('bounds hanging probes with a timeout', async () => {
22
+ const root = mkdtempSync(join(tmpdir(), 'studio-harness-process-timeout-'))
23
+ roots.push(root)
24
+ const result = await captureCommand(hangingCommand(root), [], root, { timeoutMs: 30 })
25
+ expect(result).toMatchObject({
26
+ code: -1,
27
+ timedOut: true,
28
+ stderr: 'command timed out after 30ms',
29
+ })
30
+ })
31
+
32
+ test('lets setup cancellation abort an in-flight probe', async () => {
33
+ const root = mkdtempSync(join(tmpdir(), 'studio-harness-process-abort-'))
34
+ roots.push(root)
35
+ const controller = new AbortController()
36
+ const pending = captureCommand(hangingCommand(root), [], root, {
37
+ signal: controller.signal,
38
+ timeoutMs: 5_000,
39
+ })
40
+ controller.abort()
41
+ expect(await pending).toMatchObject({
42
+ code: -1,
43
+ aborted: true,
44
+ stderr: 'canceled',
45
+ })
46
+ })
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export interface CapturedCommand {
4
+ code: number
5
+ stdout: string
6
+ stderr: string
7
+ aborted?: boolean
8
+ timedOut?: boolean
9
+ }
10
+
11
+ export interface CaptureOptions {
12
+ env?: Record<string, string>
13
+ signal?: AbortSignal
14
+ timeoutMs?: number
15
+ }
16
+
17
+ /** Merge overrides into the spawned harness only. */
18
+ export function childEnvironment(extra?: Record<string, string>): NodeJS.ProcessEnv | undefined {
19
+ return extra && Object.keys(extra).length ? { ...process.env, ...extra } : undefined
20
+ }
21
+
22
+ /** Capture a short-lived harness command such as version or loadout discovery. */
23
+ export function captureCommand(
24
+ bin: string,
25
+ args: string[],
26
+ cwd: string,
27
+ options: CaptureOptions = {},
28
+ ): Promise<CapturedCommand> {
29
+ return new Promise((resolve) => {
30
+ let stdout = ''
31
+ let stderr = ''
32
+ let settled = false
33
+ let timer: ReturnType<typeof setTimeout> | undefined
34
+ let onAbort = () => {}
35
+ const finish = (result: CapturedCommand) => {
36
+ if (settled) return
37
+ settled = true
38
+ if (timer) clearTimeout(timer)
39
+ options.signal?.removeEventListener('abort', onAbort)
40
+ resolve(result)
41
+ }
42
+ if (options.signal?.aborted) {
43
+ finish({ code: -1, stdout, stderr: 'canceled', aborted: true })
44
+ return
45
+ }
46
+ let child: ReturnType<typeof spawn>
47
+ try {
48
+ child = spawn(bin, args, {
49
+ cwd,
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ env: childEnvironment(options.env),
52
+ detached: process.platform !== 'win32',
53
+ })
54
+ } catch (error) {
55
+ finish({ code: -1, stdout, stderr: String(error) })
56
+ return
57
+ }
58
+ onAbort = () => {
59
+ terminateProcessTree(child, 'SIGKILL')
60
+ finish({ code: -1, stdout, stderr: 'canceled', aborted: true })
61
+ }
62
+ options.signal?.addEventListener('abort', onAbort, { once: true })
63
+ const timeoutMs = options.timeoutMs ?? 15_000
64
+ if (timeoutMs > 0) {
65
+ timer = setTimeout(() => {
66
+ terminateProcessTree(child, 'SIGKILL')
67
+ finish({
68
+ code: -1,
69
+ stdout,
70
+ stderr: `command timed out after ${timeoutMs}ms`,
71
+ timedOut: true,
72
+ })
73
+ }, timeoutMs)
74
+ timer.unref?.()
75
+ }
76
+ child.stdout?.setEncoding('utf8')
77
+ child.stderr?.setEncoding('utf8')
78
+ child.stdout?.on('data', (chunk: string) => {
79
+ stdout += chunk
80
+ })
81
+ child.stderr?.on('data', (chunk: string) => {
82
+ stderr += chunk
83
+ })
84
+ child.on('error', (error) => finish({ code: -1, stdout, stderr: error.message }))
85
+ child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }))
86
+ })
87
+ }
88
+
89
+ /** Terminate the whole harness process group so child commands cannot leak. */
90
+ export function terminateProcessTree(
91
+ child: ReturnType<typeof spawn>,
92
+ signal: NodeJS.Signals = 'SIGTERM',
93
+ ): void {
94
+ try {
95
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
96
+ else child.kill(signal)
97
+ } catch {
98
+ try {
99
+ child.kill(signal)
100
+ } catch {
101
+ /* already gone */
102
+ }
103
+ }
104
+ }