@astrale-os/cli 0.6.3-alpha.0 → 0.8.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +339 -242
  3. package/package.json +2 -3
  4. package/src/commands/__tests__/admin-instance.test.ts +49 -2
  5. package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
  6. package/src/commands/__tests__/help-contract.test.ts +15 -0
  7. package/src/commands/domain/install.ts +15 -4
  8. package/src/commands/instance/active.ts +4 -7
  9. package/src/commands/instance/list.ts +4 -8
  10. package/src/commands/instance/status.ts +14 -9
  11. package/src/commands/instance/use.ts +13 -11
  12. package/src/commands/studio.ts +13 -0
  13. package/src/commands/view.ts +40 -13
  14. package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
  15. package/src/kernel/client.ts +47 -20
  16. package/src/kernel/expand.ts +3 -22
  17. package/src/lib/__tests__/instance-candidates.test.ts +94 -19
  18. package/src/lib/__tests__/instance-target.test.ts +9 -1
  19. package/src/lib/__tests__/view-assets.test.ts +114 -0
  20. package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
  21. package/src/lib/admin-instance.ts +27 -2
  22. package/src/lib/instance-candidates.ts +3 -3
  23. package/src/lib/instance-target.ts +1 -0
  24. package/src/lib/instance.ts +0 -11
  25. package/src/lib/login-flow.ts +4 -41
  26. package/src/lib/view/assets.ts +64 -0
  27. package/src/lib/view/port-allocation.ts +19 -0
  28. package/src/lib/view/server.ts +4 -37
  29. package/src/setup/__tests__/instance-step.test.ts +239 -0
  30. package/src/setup/steps/instance.ts +141 -61
  31. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  32. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  33. package/studio/client/dist/index.html +2 -2
  34. package/studio/server/agent/ask.test.ts +74 -0
  35. package/studio/server/agent/ask.ts +20 -22
  36. package/studio/server/agent/bridge/client.test.ts +49 -0
  37. package/studio/server/agent/bridge/client.ts +37 -0
  38. package/studio/server/agent/bridge/grant.test.ts +62 -0
  39. package/studio/server/agent/bridge/grant.ts +84 -0
  40. package/studio/server/agent/bridge/routes.test.ts +151 -0
  41. package/studio/server/agent/bridge/routes.ts +148 -0
  42. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  43. package/studio/server/agent/conversation.test.ts +84 -0
  44. package/studio/server/agent/conversation.ts +110 -0
  45. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  46. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  47. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  48. package/studio/server/agent/harness/claude/ask.ts +114 -0
  49. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  50. package/studio/server/agent/harness/claude/command.ts +63 -0
  51. package/studio/server/agent/harness/claude/events.ts +215 -0
  52. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  53. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  54. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  55. package/studio/server/agent/harness/claude/skills.ts +43 -0
  56. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  57. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  58. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  59. package/studio/server/agent/harness/codex/ask.ts +181 -0
  60. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  61. package/studio/server/agent/harness/codex/command.ts +43 -0
  62. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  63. package/studio/server/agent/harness/codex/events.ts +132 -0
  64. package/studio/server/agent/harness/codex/exec.ts +113 -0
  65. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  66. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  67. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  68. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  69. package/studio/server/agent/harness/codex/models.ts +211 -0
  70. package/studio/server/agent/harness/codex/skills.ts +33 -0
  71. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  72. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  73. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  74. package/studio/server/agent/harness/gateway/token.ts +166 -0
  75. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  76. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  77. package/studio/server/agent/harness/process.test.ts +46 -0
  78. package/studio/server/agent/harness/process.ts +104 -0
  79. package/studio/server/agent/harness/registry.ts +37 -0
  80. package/studio/server/agent/harness/selection.test.ts +75 -0
  81. package/studio/server/agent/harness/selection.ts +77 -0
  82. package/studio/server/agent/harness/skills.test.ts +95 -0
  83. package/studio/server/agent/harness/skills.ts +100 -0
  84. package/studio/server/agent/layout.test.ts +95 -0
  85. package/studio/server/agent/notify.ts +12 -0
  86. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  87. package/studio/server/agent/prompts/ask.ts +34 -0
  88. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  89. package/studio/server/agent/prompts/turn.ts +57 -0
  90. package/studio/server/agent/routes.test.ts +176 -0
  91. package/studio/server/agent/routes.ts +214 -0
  92. package/studio/server/agent/run/completion.ts +193 -0
  93. package/studio/server/agent/run/coordinator.test.ts +337 -0
  94. package/studio/server/agent/run/coordinator.ts +104 -0
  95. package/studio/server/agent/run/live-state.ts +73 -0
  96. package/studio/server/agent/run/preparation.ts +162 -0
  97. package/studio/server/agent/run/transcript.test.ts +47 -0
  98. package/studio/server/agent/run/transcript.ts +30 -0
  99. package/studio/server/agent/run/usage.test.ts +46 -0
  100. package/studio/server/{state → agent/run}/usage.ts +4 -4
  101. package/studio/server/agent/stream.ts +37 -0
  102. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  103. package/studio/server/api-agent-loadout.test.ts +69 -0
  104. package/studio/server/api.ts +3 -155
  105. package/studio/server/index.ts +6 -5
  106. package/studio/server/state/comments.test.ts +29 -0
  107. package/studio/server/state/comments.ts +3 -3
  108. package/studio/server/state/settings.test.ts +78 -0
  109. package/studio/server/state/settings.ts +28 -1
  110. package/studio/shared/agent-effort.test.ts +12 -0
  111. package/studio/shared/agent-effort.ts +15 -0
  112. package/studio/shared/agent-models.test.ts +9 -0
  113. package/studio/shared/agent-models.ts +14 -0
  114. package/studio/shared/settings-values.test.ts +18 -0
  115. package/studio/shared/settings-values.ts +20 -0
  116. package/studio/shared/types.ts +58 -7
  117. package/src/connect-core.test.ts +0 -42
  118. package/src/connect-core.ts +0 -53
  119. package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
  120. package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
  121. package/studio/server/agent/bridge.ts +0 -188
  122. package/studio/server/agent/claude.ts +0 -678
  123. package/studio/server/agent/mock.ts +0 -186
  124. package/studio/server/agent/registry.ts +0 -29
  125. package/studio/server/agent/runner.ts +0 -487
  126. package/studio/server/state/harness-token.ts +0 -0
@@ -0,0 +1,47 @@
1
+ import { afterEach, 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 type { AgentRun } from '../../../shared/types'
7
+
8
+ import { readJson, writeJson } from '../../state/store'
9
+ import { currentRun, hydrateRun } from './live-state'
10
+ import { readLastRun } from './transcript'
11
+
12
+ const roots: string[] = []
13
+
14
+ afterEach(() => {
15
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
16
+ })
17
+
18
+ test('reconciles a run orphaned by restart and hydrates its terminal snapshot', () => {
19
+ const root = mkdtempSync(join(tmpdir(), 'studio-agent-transcript-'))
20
+ roots.push(root)
21
+ const domainId = `domain-${crypto.randomUUID()}`
22
+ const running: AgentRun = {
23
+ id: crypto.randomUUID(),
24
+ domainId,
25
+ harness: 'codex',
26
+ status: 'running',
27
+ createdAt: '2026-01-01T00:00:00.000Z',
28
+ summary: 'interrupted work',
29
+ targetCommentIds: [],
30
+ events: [],
31
+ sessionId: 'thread-1',
32
+ }
33
+ writeJson(root, '.cache/agent/last-run.json', running)
34
+
35
+ expect(readLastRun(domainId, root)).toMatchObject({
36
+ status: 'interrupted',
37
+ sessionId: 'thread-1',
38
+ error: expect.stringContaining('studio restarted'),
39
+ })
40
+ expect(readJson<AgentRun>(root, '.cache/agent/last-run.json', running).status).toBe('interrupted')
41
+
42
+ hydrateRun(domainId, root)
43
+ expect(currentRun(domainId)).toMatchObject({
44
+ status: 'interrupted',
45
+ sessionId: 'thread-1',
46
+ })
47
+ })
@@ -0,0 +1,30 @@
1
+ import type { AgentRun } from '../../../shared/types'
2
+
3
+ import { readJson, writeJson } from '../../state/store'
4
+
5
+ const LAST_RUN_FILE = '.cache/agent/last-run.json'
6
+ const runFile = (id: string) => `.cache/agent/runs/${id}.json`
7
+
8
+ /** Persist the latest run pointer and, when terminal, its transcript. */
9
+ export function persistRun(root: string, run: AgentRun, transcript = false): void {
10
+ try {
11
+ writeJson(root, LAST_RUN_FILE, run)
12
+ if (transcript) writeJson(root, runFile(run.id), run)
13
+ } catch {
14
+ /* run history is best-effort */
15
+ }
16
+ }
17
+
18
+ /** Rehydrate the latest run and reconcile one orphaned by a Studio restart. */
19
+ export function readLastRun(domainId: string, root: string): AgentRun | null {
20
+ const last = readJson<AgentRun | null>(root, LAST_RUN_FILE, null)
21
+ if (!last || last.domainId !== domainId) return null
22
+ if (last.status === 'running' || last.status === 'queued') {
23
+ last.status = 'interrupted'
24
+ last.finishedAt = last.finishedAt ?? new Date().toISOString()
25
+ last.error =
26
+ 'the studio restarted during this turn — your conversation is preserved; submit again to continue'
27
+ persistRun(root, last)
28
+ }
29
+ return last
30
+ }
@@ -0,0 +1,46 @@
1
+ import { afterEach, 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 type { AgentRun } from '../../../shared/types'
7
+
8
+ import { readUsage, recordRun } from './usage'
9
+
10
+ const roots: string[] = []
11
+
12
+ afterEach(() => {
13
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
14
+ })
15
+
16
+ function run(values: Partial<AgentRun>): AgentRun {
17
+ return {
18
+ id: crypto.randomUUID(),
19
+ domainId: 'test',
20
+ harness: 'mock',
21
+ status: 'succeeded',
22
+ createdAt: new Date().toISOString(),
23
+ summary: 'test',
24
+ targetCommentIds: [],
25
+ events: [],
26
+ ...values,
27
+ }
28
+ }
29
+
30
+ test('records only reported usage and accumulates domain totals', () => {
31
+ const root = mkdtempSync(join(tmpdir(), 'studio-agent-usage-'))
32
+ roots.push(root)
33
+ recordRun(root, run({}))
34
+ expect(readUsage(root)).toEqual({ runs: 0, tokens: 0, costUsd: 0 })
35
+
36
+ recordRun(root, run({ tokens: 10, costUsd: 0.25, finishedAt: '2026-01-01T00:00:00.000Z' }))
37
+ recordRun(root, run({ tokens: 5, costUsd: 0.1, finishedAt: '2026-01-02T00:00:00.000Z' }))
38
+ expect(readUsage(root)).toMatchObject({
39
+ runs: 2,
40
+ tokens: 15,
41
+ costUsd: 0.35,
42
+ lastRunAt: '2026-01-02T00:00:00.000Z',
43
+ lastTokens: 5,
44
+ lastCostUsd: 0.1,
45
+ })
46
+ })
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * usage.ts — domain-attributable agent spend. Accumulates tokens + USD from THIS
3
- * studio's own agent runs on THIS domain (not machine-wide — that lives in
4
- * ~/.claude/stats-cache.json and is out of scope here). Stored at
3
+ * studio's own agent runs on THIS domain (not the selected harness's machine-wide
4
+ * usage, which is out of scope here). Stored at
5
5
  * `.domain-studio/usage.json`; surfaced read-only in the Settings dialog.
6
6
  */
7
- import type { AgentRun, DomainUsage } from '../../shared/types'
7
+ import type { AgentRun, DomainUsage } from '../../../shared/types'
8
8
 
9
- import { readJson, writeJson } from './store'
9
+ import { readJson, writeJson } from '../../state/store'
10
10
 
11
11
  const PATH = 'usage.json'
12
12
 
@@ -0,0 +1,37 @@
1
+ /** Lifecycle-safe NDJSON writer for cancellable agent response streams. */
2
+ export class NdjsonChannel {
3
+ private readonly encoder = new TextEncoder()
4
+ private closed = false
5
+
6
+ constructor(
7
+ private readonly target: ReadableStreamDefaultController<Uint8Array>,
8
+ private readonly abortController: AbortController,
9
+ ) {}
10
+
11
+ send(value: unknown): boolean {
12
+ if (this.closed) return false
13
+ try {
14
+ this.target.enqueue(this.encoder.encode(`${JSON.stringify(value)}\n`))
15
+ return true
16
+ } catch {
17
+ this.cancel()
18
+ return false
19
+ }
20
+ }
21
+
22
+ close(): void {
23
+ if (this.closed) return
24
+ this.closed = true
25
+ try {
26
+ this.target.close()
27
+ } catch {
28
+ /* client already canceled */
29
+ }
30
+ }
31
+
32
+ cancel(): void {
33
+ if (this.closed) return
34
+ this.closed = true
35
+ this.abortController.abort()
36
+ }
37
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Explicit telemetry session id for studio-driven agents. Injected as
2
+ * Explicit telemetry session id for Studio-driven agents. Injected as
3
3
  * ASTRALE_SESSION into the harness child so every `astrale` call it makes
4
4
  * buckets into one surface-owned session per domain per hour (hour scoping
5
5
  * keeps a post-analysis resume from reopening an analyzed session in the
@@ -0,0 +1,69 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { setHarnessGateway } from './agent/harness/gateway/config'
7
+ import { getHarness } from './agent/harness/selection'
8
+ import { handleApi } from './api'
9
+ import { registerDomain, unregisterDomain } from './domain'
10
+
11
+ const roots: string[] = []
12
+ const domainIds: string[] = []
13
+ const originalHarness = process.env.DOMAIN_STUDIO_HARNESS
14
+ let restoreLoadout: (() => void) | undefined
15
+
16
+ afterEach(() => {
17
+ restoreLoadout?.()
18
+ restoreLoadout = undefined
19
+ if (originalHarness === undefined) delete process.env.DOMAIN_STUDIO_HARNESS
20
+ else process.env.DOMAIN_STUDIO_HARNESS = originalHarness
21
+ while (domainIds.length) unregisterDomain(domainIds.pop()!)
22
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
23
+ })
24
+
25
+ test('loadout fails closed instead of probing ambient Claude when gateway auth fails', async () => {
26
+ const root = mkdtempSync(join(tmpdir(), 'studio-loadout-gateway-'))
27
+ roots.push(root)
28
+ mkdirSync(join(root, 'schema'))
29
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
30
+ writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
31
+ writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
32
+ const handle = registerDomain(root)!
33
+ domainIds.push(handle.id)
34
+
35
+ process.env.DOMAIN_STUDIO_HARNESS = 'claude'
36
+ const harness = getHarness(root)
37
+ const originalLoadout = harness.loadout
38
+ let probes = 0
39
+ harness.loadout = async () => {
40
+ probes += 1
41
+ throw new Error('ambient Claude must not be probed')
42
+ }
43
+ restoreLoadout = () => {
44
+ harness.loadout = originalLoadout
45
+ }
46
+ setHarnessGateway(root, {
47
+ scope: 'domain',
48
+ config: {
49
+ enabled: true,
50
+ baseUrl: 'https://gateway.example/v1/models/test',
51
+ auth: { mode: 'host' },
52
+ },
53
+ })
54
+
55
+ const url = new URL(`http://127.0.0.1/api/domain/${encodeURIComponent(handle.id)}/agent/loadout`)
56
+ const response = await handleApi(new Request(url), url, () => {})
57
+ expect(response?.status).toBe(200)
58
+ const body = await response!.json()
59
+ expect(body).toMatchObject({
60
+ ok: false,
61
+ tools: [],
62
+ mcpServers: [],
63
+ skills: [],
64
+ agents: [],
65
+ })
66
+ expect(body.detail).toContain('model gateway auth failed')
67
+ expect(body.detail).toContain('no valid host-supplied token')
68
+ expect(probes).toBe(0)
69
+ })
@@ -4,16 +4,7 @@
4
4
  */
5
5
  import type { StudioEvent } from '../shared/types'
6
6
 
7
- import { buildSystemPrompt } from './agent/prompt'
8
- import { getHarness, listHarnesses } from './agent/registry'
9
- import {
10
- cancelRun,
11
- getSessionId,
12
- getSnapshot,
13
- resetConversation,
14
- setSessionId,
15
- submitRun,
16
- } from './agent/runner'
7
+ import { handleAgentRoute } from './agent/routes'
17
8
  import { getBundle, getAnatomy, getCore, invalidate } from './cache'
18
9
  import { type DomainHandle, allDomains, depsInstalled, getDomain } from './domain'
19
10
  import { schemaRefs } from './introspect/schema-refs'
@@ -47,14 +38,6 @@ import {
47
38
  import { isEnvName, readEnvModel, writeEnvUpdates } from './state/env'
48
39
  import { detectGit } from './state/git'
49
40
  import { refreshAuto } from './state/handoff'
50
- import {
51
- clearHarnessGateway,
52
- gatewayAudience,
53
- getHarnessGatewayState,
54
- resolveHarnessEnv,
55
- setHarnessGateway,
56
- } from './state/harness-gateway'
57
- import { setHostToken } from './state/harness-token'
58
41
  import {
59
42
  activeInstanceName,
60
43
  instanceStatus,
@@ -66,7 +49,6 @@ import { deleteIntegration, readIntegrations, upsertIntegration } from './state/
66
49
  import { readLayout, resetLayout, saveLayout, setNodePositions } from './state/layout'
67
50
  import { readSettings, updateSettings } from './state/settings'
68
51
  import { applyUpdates, getUpdates } from './state/updates'
69
- import { readUsage } from './state/usage'
70
52
  import { closeViewSession, getViewRuntime, launchViewSession } from './state/views'
71
53
  import { readVisibility, resetVisibility, saveVisibility } from './state/visibility'
72
54
  import { restartViewDevServer } from './view-dev-server'
@@ -331,142 +313,8 @@ export async function handleApi(req: Request, url: URL, notify: Notify): Promise
331
313
  }
332
314
  }
333
315
 
334
- // ── agent loop (live, harness-agnostic) ──
335
- if (rest === '/agent') {
336
- if (req.method === 'GET') return json(await getSnapshot(id))
337
- return badReq('use /agent/submit or /agent/cancel')
338
- }
339
- if (rest === '/agent/submit' && req.method === 'POST') {
340
- const r = await submitRun(handle, notify, {
341
- message: typeof body.message === 'string' ? body.message : undefined,
342
- resume: body.resume === true,
343
- })
344
- // 200 + {error} (not 409) — the rejections ("already running" / "no open threads")
345
- // are expected states, surfaced as a friendly client toast rather than a thrown error.
346
- return r.error ? json({ error: r.error }) : json(r.run)
347
- }
348
- if (rest === '/agent/cancel' && req.method === 'POST') {
349
- return json({ ok: cancelRun(id) })
350
- }
351
- if (rest === '/agent/reset' && req.method === 'POST') {
352
- // forget the resumable conversation — the next submit starts a fresh session
353
- return json({ ok: resetConversation(id) })
354
- }
355
- if (rest === '/agent/session') {
356
- if (req.method === 'GET') return json(getSessionId(id))
357
- if (req.method === 'POST') {
358
- const ok = setSessionId(id, typeof body.sessionId === 'string' ? body.sessionId : '')
359
- if (!ok) return badReq('the session id cannot be changed while a turn is running')
360
- return json(getSessionId(id))
361
- }
362
- return badReq('GET or POST')
363
- }
364
- if (rest === '/agent/prompt/system' && req.method === 'GET') {
365
- return json({ bridge: true, systemPrompt: buildSystemPrompt({ bridge: true }) })
366
- }
367
- if (rest === '/agent/harness' && req.method === 'GET') {
368
- const h = getHarness()
369
- const health = h.health
370
- ? await h.health()
371
- : { ok: await h.isAvailable(), bin: h.id, version: undefined, detail: undefined }
372
- const options = listHarnesses().filter((o) => o.id !== 'mock' || o.id === h.id)
373
- const message = health.ok
374
- ? `Detected${health.version ? ` — ${health.version}` : ''}`
375
- : (health.detail ?? `${h.label} is not detected. Is it installed and on your PATH?`)
376
- return json({
377
- id: h.id,
378
- label: h.label,
379
- bin: health.bin ?? h.id,
380
- ok: health.ok,
381
- version: health.version,
382
- message,
383
- options,
384
- })
385
- }
386
- // What the harness ACTUALLY loaded for this domain's cwd — skills (reconciled against
387
- // on-disk installs), MCP servers + status, tools, agents. Read-only window into the agent.
388
- if (rest === '/agent/loadout' && req.method === 'GET') {
389
- const h = getHarness()
390
- if (!h.loadout)
391
- return json({
392
- ok: false,
393
- detail: `${h.label} does not expose a loadout`,
394
- tools: [],
395
- mcpServers: [],
396
- skills: [],
397
- agents: [],
398
- builtinCommandCount: 0,
399
- probedAt: Date.now(),
400
- })
401
- const er = await resolveHarnessEnv(root)
402
- return json(await h.loadout(root, er.ok ? er.env : undefined))
403
- }
404
- // Custom model-gateway config for the harness (per-domain + studio-global), e.g.
405
- // routing Claude Code through an Astrale ai-gateway model node. The token/url are
406
- // injected into the harness CHILD only — never the studio env or the user's shell.
407
- if (rest === '/agent/harness-gateway') {
408
- if (req.method === 'GET') return json(getHarnessGatewayState(root))
409
- if (req.method === 'POST') {
410
- const scope = body.scope === 'global' ? 'global' : 'domain'
411
- if (body.action === 'set')
412
- return json(setHarnessGateway(root, { scope, config: body.config ?? {} }))
413
- if (body.action === 'clear') return json(clearHarnessGateway(root, scope))
414
- return badReq('unknown harness-gateway action')
415
- }
416
- }
417
- // EMBED seam: when the studio runs inside the Astrale GUI, the host (which holds
418
- // the shell connection) relays a delegation token here for `host` auth mode. It
419
- // is keyed by the configured gateway's audience and kept in memory only.
420
- if (rest === '/agent/harness-gateway/host-token' && req.method === 'POST') {
421
- const audience = gatewayAudience(root)
422
- if (!audience) return badReq('no gateway base URL configured for this domain')
423
- return json({ ok: setHostToken(audience, String(body.token ?? '')) })
424
- }
425
- // Domain-attributable agent spend (this studio's runs on this domain only).
426
- if (rest === '/agent/usage' && req.method === 'GET') return json(readUsage(root))
427
- // Raw SKILL.md for a skill command (the "view skill" action).
428
- if (rest === '/agent/skill' && req.method === 'GET') {
429
- const command = url.searchParams.get('command')
430
- const h = getHarness()
431
- if (!command || !h.skillContent) return notFound()
432
- const c = await h.skillContent(root, command)
433
- return c ? json(c) : notFound()
434
- }
435
- // Ask: a quick, ephemeral, FORKED side-question — streamed as newline-delimited
436
- // JSON ({delta} chunks then a final {done}|{error}). Bypasses the run lock and
437
- // never touches comments.json or the parent session.
438
- if (rest === '/agent/ask' && req.method === 'POST') {
439
- const { runAsk } = await import('./agent/ask')
440
- const ctrl = new AbortController()
441
- req.signal?.addEventListener('abort', () => ctrl.abort(), { once: true }) // client disconnect → kill the fork
442
- const enc = new TextEncoder()
443
- const stream = new ReadableStream<Uint8Array>({
444
- async start(controller) {
445
- const send = (o: unknown) => controller.enqueue(enc.encode(`${JSON.stringify(o)}\n`))
446
- try {
447
- const result = await runAsk(handle, body, ctrl.signal, (delta) => send({ delta }))
448
- send(
449
- result.isError ? { error: result.errorMessage || 'ask failed' } : { done: result.text },
450
- )
451
- } catch (e: any) {
452
- send({ error: String(e?.message ?? e) })
453
- } finally {
454
- controller.close()
455
- }
456
- },
457
- cancel() {
458
- ctrl.abort()
459
- },
460
- })
461
- return new Response(stream, {
462
- headers: { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' },
463
- })
464
- }
465
- // bridge write-back routes (token-guarded) handled by the agent bridge module
466
- if (rest.startsWith('/agent/bridge/')) {
467
- const { handleBridge } = await import('./agent/bridge')
468
- return handleBridge(handle, rest.slice('/agent/bridge/'.length), req, body, notify)
469
- }
316
+ const agentResponse = await handleAgentRoute({ req, url, rest, body, handle, notify })
317
+ if (agentResponse) return agentResponse
470
318
 
471
319
  // ── context ──
472
320
  if (rest === '/context') {
@@ -11,7 +11,7 @@
11
11
  import { existsSync, statSync } from 'node:fs'
12
12
  import { dirname, join, resolve } from 'node:path'
13
13
 
14
- import { setBridgePort } from './agent/bridge'
14
+ import { setBridgePort } from './agent/bridge/grant'
15
15
  import { handleApi } from './api'
16
16
  import { resolveTarget } from './detect'
17
17
  import { allDomains } from './domain'
@@ -90,9 +90,10 @@ function serveStatic(pathname: string): Response {
90
90
  )
91
91
  }
92
92
 
93
- // Bind to loopback by default. The studio triggers a LOCAL agent that edits code
94
- // and runs shell (bypassPermissions) — exposing the trigger on 0.0.0.0 would be
95
- // RCE on the LAN. Power users behind a trusted tunnel can opt in via DOMAIN_STUDIO_HOST.
93
+ // Bind to loopback by default. Studio can trigger a LOCAL harness that edits code
94
+ // and runs commands with the configured access level — exposing the trigger on
95
+ // 0.0.0.0 would be RCE on the LAN. Power users behind a trusted tunnel can opt in
96
+ // via DOMAIN_STUDIO_HOST.
96
97
  const HOST = process.env.DOMAIN_STUDIO_HOST || '127.0.0.1'
97
98
  const LOOPBACK = HOST === '127.0.0.1' || HOST === 'localhost' || HOST === '::1'
98
99
 
@@ -136,7 +137,7 @@ if (!LOOPBACK) {
136
137
  console.warn(
137
138
  ` ⚠ SECURITY: bound to ${HOST} (not loopback). The Submit-to-agent trigger is the\n` +
138
139
  ` only thing standing between the network and an agent that edits code + runs shell\n` +
139
- ` (bypassPermissions). Anyone who can reach this port can trigger it. Use only behind\n` +
140
+ ` with its configured access. Anyone who can reach this port can trigger it. Use only behind\n` +
140
141
  ` a trusted tunnel; unset DOMAIN_STUDIO_HOST to bind 127.0.0.1.\n`,
141
142
  )
142
143
  }
@@ -0,0 +1,29 @@
1
+ import { afterEach, 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 { readComments, upsertComment } from './comments'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ test('missing comment stores do not share entries across domains', () => {
15
+ const rootA = mkdtempSync(join(tmpdir(), 'studio-comments-a-'))
16
+ const rootB = mkdtempSync(join(tmpdir(), 'studio-comments-b-'))
17
+ roots.push(rootA, rootB)
18
+
19
+ upsertComment(rootA, {
20
+ anchors: ['A'],
21
+ anchorRefs: [{ ref: 'class.A', kind: 'schema' }],
22
+ text: 'only in A',
23
+ })
24
+
25
+ expect(readComments(rootA).comments.map((comment) => comment.thread[0]!.text)).toEqual([
26
+ 'only in A',
27
+ ])
28
+ expect(readComments(rootB).comments).toEqual([])
29
+ })
@@ -14,10 +14,10 @@ import { readJson, writeJson } from './store'
14
14
 
15
15
  const FILE = 'comments.json'
16
16
 
17
- const EMPTY: CommentStore = { schemaVersion: '', comments: [] }
18
-
19
17
  export function readComments(root: string): CommentStore {
20
- const store = readJson<CommentStore>(root, FILE, { ...EMPTY })
18
+ // Allocate a fresh array for every missing store. A module-level EMPTY object
19
+ // would share its nested `comments` array across domains until each persisted.
20
+ const store = readJson<CommentStore>(root, FILE, { schemaVersion: '', comments: [] })
21
21
  return {
22
22
  schemaVersion: store.schemaVersion ?? '',
23
23
  comments: Array.isArray(store.comments) ? store.comments : [],
@@ -0,0 +1,78 @@
1
+ import { afterEach, 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 { readSettings, updateSettings } from './settings'
7
+ import { writeJson } from './store'
8
+
9
+ const roots: string[] = []
10
+
11
+ afterEach(() => {
12
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
13
+ })
14
+
15
+ test('remembers sanitized model overrides independently per harness', () => {
16
+ const root = mkdtempSync(join(tmpdir(), 'studio-settings-models-'))
17
+ roots.push(root)
18
+
19
+ const saved = updateSettings(root, {
20
+ agentModels: {
21
+ ' Codex ': ' gpt-5.6-sol ',
22
+ claude: ' opus ',
23
+ empty: ' ',
24
+ },
25
+ })
26
+
27
+ expect(saved.agentModels).toEqual({
28
+ codex: 'gpt-5.6-sol',
29
+ claude: 'opus',
30
+ })
31
+ expect(readSettings(root).agentModels).toEqual(saved.agentModels)
32
+ })
33
+
34
+ test('rejects malformed model maps and entries without discarding unrelated settings', () => {
35
+ const root = mkdtempSync(join(tmpdir(), 'studio-settings-bad-models-'))
36
+ roots.push(root)
37
+ writeJson(root, 'settings.json', { agentModels: ['gpt-bad'], agentEffort: 'low' })
38
+
39
+ const settings = readSettings(root)
40
+ expect(settings.agentModels).toEqual({})
41
+ expect(settings.agentEffort).toBe('low')
42
+
43
+ writeJson(root, 'settings.json', {
44
+ agentModels: { codex: null, claude: { id: 'opus' }, valid: ' model-ok ' },
45
+ })
46
+ expect(readSettings(root).agentModels).toEqual({ valid: 'model-ok' })
47
+ })
48
+
49
+ test('drops destructive timing values from disk and API patches', () => {
50
+ const root = mkdtempSync(join(tmpdir(), 'studio-settings-timings-'))
51
+ roots.push(root)
52
+ writeJson(root, 'settings.json', {
53
+ introspectTimeoutMs: -1,
54
+ instancePollMs: 1_000.5,
55
+ updatesPollMs: 9_999,
56
+ viewProbeTimeoutMs: 120_001,
57
+ })
58
+
59
+ expect(readSettings(root)).toMatchObject({
60
+ introspectTimeoutMs: 20_000,
61
+ instancePollMs: 30_000,
62
+ updatesPollMs: 600_000,
63
+ viewProbeTimeoutMs: 8_000,
64
+ })
65
+
66
+ const saved = updateSettings(root, {
67
+ introspectTimeoutMs: Number.POSITIVE_INFINITY,
68
+ instancePollMs: 1_000,
69
+ updatesPollMs: 86_400_000,
70
+ viewProbeTimeoutMs: 250,
71
+ })
72
+ expect(saved).toMatchObject({
73
+ introspectTimeoutMs: 20_000,
74
+ instancePollMs: 1_000,
75
+ updatesPollMs: 86_400_000,
76
+ viewProbeTimeoutMs: 250,
77
+ })
78
+ })
@@ -4,13 +4,20 @@
4
4
  * `.domain-studio/settings.json`; missing keys fall back to DEFAULT_SETTINGS.
5
5
  * Surfaced subtly in the UI (command palette + a faint gear) for power users.
6
6
  */
7
- import { AGENT_EFFORT_LEVELS, type StudioSettings } from '../../shared/types'
7
+ import {
8
+ parseStudioNumericSetting,
9
+ STUDIO_NUMERIC_LIMITS,
10
+ type NumericStudioSetting,
11
+ } from '../../shared/settings-values'
12
+ import { AGENT_ACCESS_LEVELS, AGENT_EFFORT_LEVELS, type StudioSettings } from '../../shared/types'
8
13
  import { readJson, writeJson } from './store'
9
14
 
10
15
  const PATH = 'settings.json'
11
16
 
12
17
  export const DEFAULT_SETTINGS: StudioSettings = {
13
18
  agentEffort: 'high',
19
+ agentAccess: 'full',
20
+ agentModels: {},
14
21
  integrationsDir: 'integrations',
15
22
  introspectTimeoutMs: 20000,
16
23
  instancePollMs: 30000,
@@ -20,8 +27,28 @@ export const DEFAULT_SETTINGS: StudioSettings = {
20
27
 
21
28
  function normalizeSettings(input: Partial<StudioSettings>): Partial<StudioSettings> {
22
29
  const out = { ...input }
30
+ for (const key of Object.keys(STUDIO_NUMERIC_LIMITS) as NumericStudioSetting[]) {
31
+ if (out[key] === undefined) continue
32
+ const value = parseStudioNumericSetting(key, out[key])
33
+ if (value === null) delete out[key]
34
+ else out[key] = value
35
+ }
23
36
  if (out.agentEffort !== undefined && !AGENT_EFFORT_LEVELS.includes(out.agentEffort as any))
24
37
  delete out.agentEffort
38
+ if (out.agentAccess !== undefined && !AGENT_ACCESS_LEVELS.includes(out.agentAccess as any))
39
+ delete out.agentAccess
40
+ if (out.agentModels !== undefined) {
41
+ if (!out.agentModels || typeof out.agentModels !== 'object' || Array.isArray(out.agentModels)) {
42
+ delete out.agentModels
43
+ } else {
44
+ out.agentModels = Object.fromEntries(
45
+ Object.entries(out.agentModels)
46
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
47
+ .map(([harness, model]) => [harness.trim().toLowerCase(), model.trim()])
48
+ .filter(([harness, model]) => !!harness && !!model),
49
+ )
50
+ }
51
+ }
25
52
  return out
26
53
  }
27
54
 
@@ -0,0 +1,12 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { effectiveAgentEffort } from './agent-effort'
4
+
5
+ test('normalizes persisted effort when switching harness vocabularies', () => {
6
+ expect(effectiveAgentEffort(['low', 'medium', 'high', 'xhigh'], 'max')).toBe('xhigh')
7
+ expect(effectiveAgentEffort(['low', 'medium', 'high', 'xhigh'], 'ultracode')).toBe('xhigh')
8
+ expect(effectiveAgentEffort(['low', 'medium', 'high'], 'minimal')).toBe('low')
9
+ expect(effectiveAgentEffort(['medium', 'high'], 'xhigh')).toBe('high')
10
+ expect(effectiveAgentEffort(['medium'], 'low')).toBe('medium')
11
+ expect(effectiveAgentEffort(['low'], undefined)).toBeUndefined()
12
+ })