@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
@@ -1,678 +0,0 @@
1
- /**
2
- * agent/claude.ts — the Claude Code harness. Drives a LOCAL `claude` install in
3
- * headless streaming mode (no cloud API key of ours — it uses the user's own
4
- * Claude Code auth). One invocation = one turn; `--resume <sessionId>` threads
5
- * the conversation across turns so the agent remembers prior comments.
6
- *
7
- * claude -p --output-format stream-json --verbose
8
- * --permission-mode <mode> [--resume <sid>] [--mcp-config <file>]
9
- * [--append-system-prompt <proto>]
10
- * (the turn prompt is piped on stdin so it is never argv-length-bounded)
11
- */
12
- import { spawn } from 'node:child_process'
13
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
- import { homedir } from 'node:os'
15
- import { dirname, join } from 'node:path'
16
-
17
- import type { HarnessLoadout, LoadoutSkill } from '../../shared/types'
18
- import type {
19
- AgentHarness,
20
- AgentTurnInput,
21
- AgentTurnResult,
22
- AskInput,
23
- AskResult,
24
- HarnessHealth,
25
- } from './types'
26
-
27
- const BIN = process.env.DOMAIN_STUDIO_CLAUDE_BIN || 'claude'
28
- /** Phrases Claude Code emits when `--resume <id>` names a session it can no longer
29
- * find (pruned/expired/foreign cwd). Kept strict so an unrelated failure is never
30
- * mistaken for a dead session (which would needlessly drop a valid conversation). */
31
- const RESUME_REJECTED =
32
- /no conversation found|session (?:id .*)?(?:not found|does not exist|no longer exists|expired)|could not (?:find|load|resume) .*session|unknown session|invalid session id/i
33
- // bypassPermissions lets the agent edit + run commands without an interactive
34
- // prompt — correct for a local, user-initiated loop on the user's own domain.
35
- const PERMISSION_MODE = process.env.DOMAIN_STUDIO_AGENT_PERMISSION || 'bypassPermissions'
36
- const EXTRA_ARGS = (process.env.DOMAIN_STUDIO_CLAUDE_ARGS || '').split(' ').filter(Boolean)
37
-
38
- /** Build the child env: the studio's own env plus any per-domain overrides (a
39
- * custom model gateway's ANTHROPIC_*). Returns undefined when there are no
40
- * overrides so the child plainly inherits `process.env` (the prior behaviour) —
41
- * and crucially these vars live ONLY in the spawned child, never the studio
42
- * process or the user's shell. */
43
- function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv | undefined {
44
- return extra && Object.keys(extra).length ? { ...process.env, ...extra } : undefined
45
- }
46
-
47
- /** Compact a tool_use block into a one-line target for the activity log. */
48
- function toolTarget(name: string, input: Record<string, unknown> | undefined): string {
49
- if (!input) return ''
50
- const s = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
51
- switch (name) {
52
- case 'Edit':
53
- case 'Write':
54
- case 'MultiEdit':
55
- case 'NotebookEdit':
56
- return s(input.file_path ?? input.path ?? input.notebook_path)
57
- case 'Read':
58
- return s(input.file_path)
59
- case 'Bash':
60
- return s(input.command).slice(0, 200)
61
- case 'Grep':
62
- return s(input.pattern)
63
- case 'Glob':
64
- return s(input.pattern)
65
- case 'Task':
66
- return s(input.description)
67
- default: {
68
- // MCP tools (mcp__domain-studio__reply_to_thread …) + anything else
69
- const first = Object.values(input)[0]
70
- return s(first).slice(0, 200)
71
- }
72
- }
73
- }
74
-
75
- /** Pull `name` + `description` out of a SKILL.md YAML frontmatter block. Line-based
76
- * (these fields are single-line in practice) — good enough to label a skill. */
77
- function readSkillMeta(skillMd: string): { name?: string; description?: string } {
78
- let text: string
79
- try {
80
- text = readFileSync(skillMd, 'utf8')
81
- } catch {
82
- return {}
83
- }
84
- if (!text.startsWith('---')) return {}
85
- const end = text.indexOf('\n---', 3)
86
- const fm = end >= 0 ? text.slice(3, end) : text.slice(3)
87
- const out: { name?: string; description?: string } = {}
88
- for (const line of fm.split('\n')) {
89
- const m = /^(name|description)\s*:\s*(.*)$/.exec(line)
90
- if (m && out[m[1] as 'name' | 'description'] === undefined) {
91
- out[m[1] as 'name' | 'description'] = m[2].trim().replace(/^["']|["']$/g, '')
92
- }
93
- }
94
- return out
95
- }
96
-
97
- /** Scan one skills/ directory: each immediate subdir holding a SKILL.md is a skill.
98
- * `commandPrefix` namespaces plugin skills (`vercel:`). First-seen command wins so
99
- * a project skill shadows a same-named user/plugin one. */
100
- function scanSkillDir(
101
- dir: string,
102
- source: LoadoutSkill['source'],
103
- plugin: string | undefined,
104
- commandPrefix: string,
105
- out: Omit<LoadoutSkill, 'loaded'>[],
106
- seen: Set<string>,
107
- ): void {
108
- if (!existsSync(dir)) return
109
- let entries: string[]
110
- try {
111
- entries = readdirSync(dir)
112
- } catch {
113
- return
114
- }
115
- for (const entry of entries) {
116
- const skillMd = join(dir, entry, 'SKILL.md')
117
- if (!existsSync(skillMd)) continue // readFileSync follows symlinks (e.g. ~/.claude/skills/*)
118
- const command = commandPrefix + entry
119
- if (seen.has(command)) continue
120
- seen.add(command)
121
- const meta = readSkillMeta(skillMd)
122
- out.push({
123
- command,
124
- name: meta.name || entry,
125
- description: meta.description,
126
- source,
127
- plugin,
128
- path: skillMd,
129
- })
130
- }
131
- }
132
-
133
- /** The active plugins from installed_plugins.json → their on-disk skills root. */
134
- function installedPluginDirs(): { plugin: string; installPath: string }[] {
135
- const f = join(homedir(), '.claude', 'plugins', 'installed_plugins.json')
136
- let parsed: any
137
- try {
138
- parsed = JSON.parse(readFileSync(f, 'utf8'))
139
- } catch {
140
- return []
141
- }
142
- const out: { plugin: string; installPath: string }[] = []
143
- const seenPath = new Set<string>()
144
- for (const [key, entries] of Object.entries(parsed?.plugins ?? {})) {
145
- const plugin = String(key).split('@')[0]
146
- for (const e of Array.isArray(entries) ? entries : []) {
147
- const installPath = (e as any)?.installPath
148
- if (typeof installPath === 'string' && !seenPath.has(installPath)) {
149
- seenPath.add(installPath)
150
- out.push({ plugin, installPath })
151
- }
152
- }
153
- }
154
- return out
155
- }
156
-
157
- /** All skills installed on disk, pre-reconcile. Scans, in precedence order (first-seen
158
- * command wins): `.claude/skills` + `.agents/skills` from `root` up to (not incl.) the
159
- * home dir — so a domain nested in a workspace also picks up the workspace's
160
- * `.agents/skills` (where Astrale keeps astrale-cli / astrale-domain / agent-browser) —
161
- * then the user-level dirs, then enabled plugins. A skill found here but absent from the
162
- * harness's slash-commands reconciles to `loaded:false` (installed but not wired in). */
163
- function scanInstalledSkills(root: string): Omit<LoadoutSkill, 'loaded'>[] {
164
- const out: Omit<LoadoutSkill, 'loaded'>[] = []
165
- const seen = new Set<string>()
166
- const home = homedir()
167
- let cur = root
168
- for (let i = 0; i < 12 && cur !== home; i++) {
169
- scanSkillDir(join(cur, '.claude', 'skills'), 'project', undefined, '', out, seen)
170
- scanSkillDir(join(cur, '.agents', 'skills'), 'project', undefined, '', out, seen)
171
- const parent = dirname(cur)
172
- if (parent === cur) break
173
- cur = parent
174
- }
175
- scanSkillDir(join(home, '.claude', 'skills'), 'user', undefined, '', out, seen)
176
- scanSkillDir(join(home, '.agents', 'skills'), 'user', undefined, '', out, seen)
177
- for (const { plugin, installPath } of installedPluginDirs()) {
178
- scanSkillDir(join(installPath, 'skills'), 'plugin', plugin, `${plugin}:`, out, seen)
179
- }
180
- return out
181
- }
182
-
183
- /** Resolve a skill command to its SKILL.md content — for the "view skill" action.
184
- * Only reads files inside a scanned skill dir (no arbitrary path access). */
185
- function readSkillContent(
186
- root: string,
187
- command: string,
188
- ): { command: string; content: string; path: string } | null {
189
- const skill = scanInstalledSkills(root).find((s) => s.command === command)
190
- if (!skill?.path) return null
191
- try {
192
- return { command, content: readFileSync(skill.path, 'utf8'), path: skill.path }
193
- } catch {
194
- return null
195
- }
196
- }
197
-
198
- export class ClaudeCodeHarness implements AgentHarness {
199
- id = 'claude'
200
- label = 'Claude Code (local)'
201
-
202
- // cache the version probe — getSnapshot is polled, no need to spawn each time
203
- private availCache?: { at: number; ok: boolean }
204
- // cache the loadout probe per (root + env) — the Settings dialog may re-open
205
- // often; the env is part of the key so switching gateway re-probes the model
206
- private loadoutCache?: { at: number; key: string; data: HarnessLoadout }
207
-
208
- async isAvailable(): Promise<boolean> {
209
- const now = Date.now()
210
- if (this.availCache && now - this.availCache.at < 30_000) return this.availCache.ok
211
- const ok = await new Promise<boolean>((resolve) => {
212
- try {
213
- const p = spawn(BIN, ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] })
214
- p.on('error', () => resolve(false))
215
- p.on('close', (code) => resolve(code === 0))
216
- } catch {
217
- resolve(false)
218
- }
219
- })
220
- this.availCache = { at: now, ok }
221
- return ok
222
- }
223
-
224
- /** Richer install probe for the UI: is the `claude` binary found + what version? */
225
- async health(): Promise<HarnessHealth> {
226
- const probe = await new Promise<{ ok: boolean; out: string; err: string }>((resolve) => {
227
- try {
228
- const p = spawn(BIN, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
229
- let out = ''
230
- let err = ''
231
- p.stdout?.on('data', (d) => {
232
- out += d
233
- })
234
- p.stderr?.on('data', (d) => {
235
- err += d
236
- })
237
- p.on('error', (e) =>
238
- resolve({ ok: false, out: '', err: String((e as Error)?.message ?? e) }),
239
- )
240
- p.on('close', (code) => resolve({ ok: code === 0, out: out.trim(), err: err.trim() }))
241
- } catch (e) {
242
- resolve({ ok: false, out: '', err: String(e) })
243
- }
244
- })
245
- this.availCache = { at: Date.now(), ok: probe.ok }
246
- return {
247
- ok: probe.ok,
248
- version: probe.ok ? probe.out || undefined : undefined,
249
- bin: BIN,
250
- detail: probe.ok ? undefined : probe.err || `\`${BIN}\` was not found on PATH`,
251
- }
252
- }
253
-
254
- /** What did the harness ACTUALLY load for `root`? Reads the `system/init` event
255
- * of a headless probe (authoritative — reflects enable/disable, cwd scope, MCP
256
- * auth) and reconciles its slash-commands against on-disk skills. Cached ~60s. */
257
- async loadout(root: string, env?: Record<string, string>): Promise<HarnessLoadout> {
258
- const now = Date.now()
259
- const key = `${root}${JSON.stringify(env ?? {})}`
260
- if (this.loadoutCache && this.loadoutCache.key === key && now - this.loadoutCache.at < 60_000)
261
- return this.loadoutCache.data
262
- const probe = await this.probeInit(root, env)
263
- let data: HarnessLoadout
264
- if (!probe.ok || !probe.init) {
265
- data = {
266
- ok: false,
267
- detail: probe.detail,
268
- tools: [],
269
- mcpServers: [],
270
- skills: [],
271
- agents: [],
272
- builtinCommandCount: 0,
273
- probedAt: now,
274
- }
275
- } else {
276
- const init = probe.init
277
- const slash: string[] = Array.isArray(init.slash_commands) ? init.slash_commands : []
278
- const slashSet = new Set(slash)
279
- const installed = scanInstalledSkills(root)
280
- const skillCommands = new Set(installed.map((s) => s.command))
281
- data = {
282
- ok: true,
283
- model: init.model,
284
- permissionMode: init.permissionMode,
285
- apiKeySource: init.apiKeySource,
286
- cwd: init.cwd,
287
- tools: Array.isArray(init.tools) ? init.tools : [],
288
- mcpServers: Array.isArray(init.mcp_servers)
289
- ? init.mcp_servers.map((m: any) => ({
290
- name: String(m?.name ?? ''),
291
- status: String(m?.status ?? 'unknown'),
292
- }))
293
- : [],
294
- skills: installed.map((s) => ({ ...s, loaded: slashSet.has(s.command) })),
295
- agents: Array.isArray(init.agents) ? init.agents : [],
296
- builtinCommandCount: slash.filter((c) => !skillCommands.has(c)).length,
297
- probedAt: now,
298
- }
299
- }
300
- this.loadoutCache = { at: now, key, data }
301
- return data
302
- }
303
-
304
- /** The raw SKILL.md for a skill command (for the "view skill" action). */
305
- async skillContent(
306
- root: string,
307
- command: string,
308
- ): Promise<{ command: string; content: string; path: string } | null> {
309
- return readSkillContent(root, command)
310
- }
311
-
312
- /** Spawn the harness in headless stream-json mode and resolve on the first
313
- * `system/init` event, then KILL it — init is emitted before the first model
314
- * call, so this costs ~0 tokens. A 15s timeout / early close ⇒ a !ok result. */
315
- private probeInit(
316
- root: string,
317
- env?: Record<string, string>,
318
- ): Promise<{ ok: boolean; detail?: string; init?: any }> {
319
- return new Promise((resolve) => {
320
- const args = ['-p', '--output-format', 'stream-json', '--verbose', ...EXTRA_ARGS]
321
- let child: ReturnType<typeof spawn>
322
- try {
323
- child = spawn(BIN, args, {
324
- cwd: root,
325
- stdio: ['pipe', 'pipe', 'ignore'],
326
- env: childEnv(env),
327
- })
328
- } catch (e) {
329
- resolve({
330
- ok: false,
331
- detail: `failed to spawn ${BIN}: ${String((e as Error)?.message ?? e)}`,
332
- })
333
- return
334
- }
335
- let done = false
336
- const finish = (r: { ok: boolean; detail?: string; init?: any }) => {
337
- if (done) return
338
- done = true
339
- clearTimeout(timer)
340
- try {
341
- child.kill('SIGKILL')
342
- } catch {
343
- /* already gone */
344
- }
345
- resolve(r)
346
- }
347
- const timer = setTimeout(
348
- () => finish({ ok: false, detail: 'loadout probe timed out' }),
349
- 15_000,
350
- )
351
- child.on('error', (e) =>
352
- finish({ ok: false, detail: `failed to spawn ${BIN}: ${e.message}` }),
353
- )
354
- child.on('close', () => finish({ ok: false, detail: 'probe ended before an init event' }))
355
- // -p needs an input; a single char is enough — we kill on init, before any model call
356
- try {
357
- child.stdin?.write('.')
358
- child.stdin?.end()
359
- } catch {
360
- /* stdin may already be closed */
361
- }
362
- let buf = ''
363
- child.stdout?.setEncoding('utf8')
364
- child.stdout?.on('data', (chunk: string) => {
365
- buf += chunk
366
- let nl: number
367
- while ((nl = buf.indexOf('\n')) >= 0) {
368
- const line = buf.slice(0, nl).trim()
369
- buf = buf.slice(nl + 1)
370
- if (!line) continue
371
- let ev: any
372
- try {
373
- ev = JSON.parse(line)
374
- } catch {
375
- continue
376
- }
377
- if (ev.type === 'system' && ev.subtype === 'init') {
378
- finish({ ok: true, init: ev })
379
- return
380
- }
381
- }
382
- })
383
- })
384
- }
385
-
386
- run(input: AgentTurnInput): Promise<AgentTurnResult> {
387
- const {
388
- root,
389
- prompt,
390
- appendSystemPrompt,
391
- sessionId,
392
- effort,
393
- mcpConfigPath,
394
- env,
395
- signal,
396
- onEvent,
397
- } = input
398
-
399
- const args = [
400
- '-p',
401
- '--output-format',
402
- 'stream-json',
403
- '--verbose',
404
- '--permission-mode',
405
- PERMISSION_MODE,
406
- ]
407
- if (sessionId) args.push('--resume', sessionId)
408
- if (effort) args.push('--effort', effort)
409
- if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath)
410
- if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
411
- args.push(...EXTRA_ARGS)
412
-
413
- return new Promise((resolve) => {
414
- let resolvedSession = sessionId
415
- let finalText = ''
416
- let costUsd: number | undefined
417
- let numTurns: number | undefined
418
- let tokens: number | undefined
419
- let isError = false
420
- let errorMessage: string | undefined
421
- let stderr = ''
422
-
423
- const child = spawn(BIN, args, {
424
- cwd: root,
425
- stdio: ['pipe', 'pipe', 'pipe'],
426
- env: childEnv(env),
427
- })
428
-
429
- const onAbort = () => {
430
- try {
431
- child.kill('SIGTERM')
432
- } catch {
433
- /* already gone */
434
- }
435
- }
436
- if (signal.aborted) onAbort()
437
- else signal.addEventListener('abort', onAbort, { once: true })
438
-
439
- // feed the turn prompt on stdin
440
- child.stdin.write(prompt)
441
- child.stdin.end()
442
-
443
- let buf = ''
444
- child.stdout.setEncoding('utf8')
445
- child.stdout.on('data', (chunk: string) => {
446
- buf += chunk
447
- let nl: number
448
- while ((nl = buf.indexOf('\n')) >= 0) {
449
- const line = buf.slice(0, nl).trim()
450
- buf = buf.slice(nl + 1)
451
- if (line) handleLine(line)
452
- }
453
- })
454
- child.stderr.setEncoding('utf8')
455
- child.stderr.on('data', (c: string) => {
456
- stderr += c
457
- })
458
-
459
- function handleLine(line: string) {
460
- let ev: any
461
- try {
462
- ev = JSON.parse(line)
463
- } catch {
464
- return // non-JSON noise
465
- }
466
- switch (ev.type) {
467
- case 'system':
468
- if (ev.subtype === 'init') {
469
- if (ev.session_id) resolvedSession = ev.session_id
470
- onEvent({ kind: 'status', text: 'session started' })
471
- }
472
- // hook_started / hook_response are noise — ignore
473
- return
474
- case 'assistant': {
475
- const content = ev.message?.content
476
- if (!Array.isArray(content)) return
477
- for (const block of content) {
478
- if (block.type === 'text' && block.text?.trim()) {
479
- onEvent({ kind: 'message', text: block.text.trim() })
480
- } else if (block.type === 'thinking' && block.thinking?.trim()) {
481
- onEvent({ kind: 'thinking', text: block.thinking.trim() })
482
- } else if (block.type === 'tool_use') {
483
- onEvent({
484
- kind: 'tool',
485
- text: block.name,
486
- tool: block.name,
487
- target: toolTarget(block.name, block.input),
488
- })
489
- }
490
- }
491
- return
492
- }
493
- case 'result': {
494
- if (typeof ev.result === 'string') finalText = ev.result
495
- if (typeof ev.total_cost_usd === 'number') costUsd = ev.total_cost_usd
496
- if (typeof ev.num_turns === 'number') numTurns = ev.num_turns
497
- if (ev.usage && typeof ev.usage === 'object') {
498
- const u = ev.usage as Record<string, number | undefined>
499
- tokens =
500
- (u.input_tokens ?? 0) +
501
- (u.output_tokens ?? 0) +
502
- (u.cache_read_input_tokens ?? 0) +
503
- (u.cache_creation_input_tokens ?? 0)
504
- }
505
- if (ev.session_id) resolvedSession = ev.session_id
506
- if (
507
- ev.is_error ||
508
- ev.subtype === 'error_during_execution' ||
509
- ev.subtype === 'error_max_turns'
510
- ) {
511
- isError = true
512
- errorMessage = ev.subtype || 'agent error'
513
- }
514
- return
515
- }
516
- case 'rate_limit_event': {
517
- const info = ev.rate_limit_info
518
- if (info && info.status && info.status !== 'allowed') {
519
- onEvent({ kind: 'status', text: `rate limit: ${info.status}` })
520
- }
521
- return
522
- }
523
- }
524
- }
525
-
526
- child.on('error', (err) => {
527
- resolve({
528
- sessionId: resolvedSession,
529
- finalText,
530
- costUsd,
531
- numTurns,
532
- tokens,
533
- isError: true,
534
- errorMessage: `failed to spawn ${BIN}: ${err.message}`,
535
- })
536
- })
537
-
538
- child.on('close', (code) => {
539
- if (signal.aborted) {
540
- resolve({
541
- sessionId: resolvedSession,
542
- finalText,
543
- costUsd,
544
- numTurns,
545
- tokens,
546
- isError: true,
547
- errorMessage: 'canceled',
548
- })
549
- return
550
- }
551
- if (code !== 0 && !finalText) {
552
- isError = true
553
- errorMessage =
554
- errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-400)}` : ''}`
555
- }
556
- // Only meaningful when we actually tried to resume: did Claude reject the id?
557
- const resumeRejected =
558
- !!sessionId &&
559
- (isError || code !== 0) &&
560
- RESUME_REJECTED.test(`${errorMessage ?? ''}\n${stderr}`)
561
- resolve({
562
- sessionId: resolvedSession,
563
- finalText,
564
- costUsd,
565
- numTurns,
566
- tokens,
567
- isError,
568
- errorMessage,
569
- resumeRejected,
570
- })
571
- })
572
- })
573
- }
574
-
575
- /**
576
- * A quick, EPHEMERAL side-question. Forks the domain's live session (`--resume …
577
- * --fork-session`) so it inherits the full conversation context but writes nothing
578
- * back to the parent transcript. Same model/tool surface/permission mode as the
579
- * main agent, with the same configured effort.
580
- */
581
- ask(input: AskInput): Promise<AskResult> {
582
- const { root, prompt, appendSystemPrompt, sessionId, effort, env, signal, onDelta } = input
583
-
584
- const args = [
585
- '-p',
586
- '--output-format',
587
- 'stream-json',
588
- '--verbose',
589
- '--permission-mode',
590
- PERMISSION_MODE,
591
- ]
592
- if (effort) args.push('--effort', effort)
593
- // forking inherits context but leaves the parent untouched; --no-session-persistence
594
- // means the fork itself isn't saved either (truly ephemeral). No fork ⇒ a fresh ask.
595
- if (sessionId) args.push('--resume', sessionId, '--fork-session')
596
- args.push('--no-session-persistence')
597
- if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
598
- args.push(...EXTRA_ARGS)
599
-
600
- return new Promise((resolve) => {
601
- let finalText = ''
602
- let stderr = ''
603
- let isError = false
604
- let errorMessage: string | undefined
605
-
606
- const child = spawn(BIN, args, {
607
- cwd: root,
608
- stdio: ['pipe', 'pipe', 'pipe'],
609
- env: childEnv(env),
610
- })
611
- const onAbort = () => {
612
- try {
613
- child.kill('SIGTERM')
614
- } catch {
615
- /* already gone */
616
- }
617
- }
618
- if (signal.aborted) onAbort()
619
- else signal.addEventListener('abort', onAbort, { once: true })
620
-
621
- child.stdin.write(prompt)
622
- child.stdin.end()
623
-
624
- let buf = ''
625
- child.stdout.setEncoding('utf8')
626
- child.stdout.on('data', (chunk: string) => {
627
- buf += chunk
628
- let nl: number
629
- while ((nl = buf.indexOf('\n')) >= 0) {
630
- const line = buf.slice(0, nl).trim()
631
- buf = buf.slice(nl + 1)
632
- if (!line) continue
633
- let ev: any
634
- try {
635
- ev = JSON.parse(line)
636
- } catch {
637
- continue
638
- }
639
- if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
640
- for (const b of ev.message.content) if (b.type === 'text' && b.text) onDelta(b.text)
641
- } else if (ev.type === 'result') {
642
- if (typeof ev.result === 'string') finalText = ev.result
643
- if (
644
- ev.is_error ||
645
- ev.subtype === 'error_during_execution' ||
646
- ev.subtype === 'error_max_turns'
647
- ) {
648
- isError = true
649
- errorMessage = ev.subtype || 'ask error'
650
- }
651
- }
652
- }
653
- })
654
- child.stderr.setEncoding('utf8')
655
- child.stderr.on('data', (c: string) => {
656
- stderr += c
657
- })
658
-
659
- child.on('error', (err) =>
660
- resolve({
661
- text: finalText,
662
- isError: true,
663
- errorMessage: `failed to spawn ${BIN}: ${err.message}`,
664
- }),
665
- )
666
- child.on('close', (code) => {
667
- if (signal.aborted)
668
- return resolve({ text: finalText, isError: true, errorMessage: 'canceled' })
669
- if (code !== 0 && !finalText) {
670
- isError = true
671
- errorMessage =
672
- errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-300)}` : ''}`
673
- }
674
- resolve({ text: finalText, isError, errorMessage })
675
- })
676
- })
677
- }
678
- }