@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,666 @@
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, { cwd: root, stdio: ['pipe', 'pipe', 'ignore'], env: childEnv(env) })
324
+ } catch (e) {
325
+ resolve({
326
+ ok: false,
327
+ detail: `failed to spawn ${BIN}: ${String((e as Error)?.message ?? e)}`,
328
+ })
329
+ return
330
+ }
331
+ let done = false
332
+ const finish = (r: { ok: boolean; detail?: string; init?: any }) => {
333
+ if (done) return
334
+ done = true
335
+ clearTimeout(timer)
336
+ try {
337
+ child.kill('SIGKILL')
338
+ } catch {
339
+ /* already gone */
340
+ }
341
+ resolve(r)
342
+ }
343
+ const timer = setTimeout(
344
+ () => finish({ ok: false, detail: 'loadout probe timed out' }),
345
+ 15_000,
346
+ )
347
+ child.on('error', (e) =>
348
+ finish({ ok: false, detail: `failed to spawn ${BIN}: ${e.message}` }),
349
+ )
350
+ child.on('close', () => finish({ ok: false, detail: 'probe ended before an init event' }))
351
+ // -p needs an input; a single char is enough — we kill on init, before any model call
352
+ try {
353
+ child.stdin?.write('.')
354
+ child.stdin?.end()
355
+ } catch {
356
+ /* stdin may already be closed */
357
+ }
358
+ let buf = ''
359
+ child.stdout?.setEncoding('utf8')
360
+ child.stdout?.on('data', (chunk: string) => {
361
+ buf += chunk
362
+ let nl: number
363
+ while ((nl = buf.indexOf('\n')) >= 0) {
364
+ const line = buf.slice(0, nl).trim()
365
+ buf = buf.slice(nl + 1)
366
+ if (!line) continue
367
+ let ev: any
368
+ try {
369
+ ev = JSON.parse(line)
370
+ } catch {
371
+ continue
372
+ }
373
+ if (ev.type === 'system' && ev.subtype === 'init') {
374
+ finish({ ok: true, init: ev })
375
+ return
376
+ }
377
+ }
378
+ })
379
+ })
380
+ }
381
+
382
+ run(input: AgentTurnInput): Promise<AgentTurnResult> {
383
+ const {
384
+ root,
385
+ prompt,
386
+ appendSystemPrompt,
387
+ sessionId,
388
+ effort,
389
+ mcpConfigPath,
390
+ env,
391
+ signal,
392
+ onEvent,
393
+ } = input
394
+
395
+ const args = [
396
+ '-p',
397
+ '--output-format',
398
+ 'stream-json',
399
+ '--verbose',
400
+ '--permission-mode',
401
+ PERMISSION_MODE,
402
+ ]
403
+ if (sessionId) args.push('--resume', sessionId)
404
+ if (effort) args.push('--effort', effort)
405
+ if (mcpConfigPath) args.push('--mcp-config', mcpConfigPath)
406
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
407
+ args.push(...EXTRA_ARGS)
408
+
409
+ return new Promise((resolve) => {
410
+ let resolvedSession = sessionId
411
+ let finalText = ''
412
+ let costUsd: number | undefined
413
+ let numTurns: number | undefined
414
+ let tokens: number | undefined
415
+ let isError = false
416
+ let errorMessage: string | undefined
417
+ let stderr = ''
418
+
419
+ const child = spawn(BIN, args, { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv(env) })
420
+
421
+ const onAbort = () => {
422
+ try {
423
+ child.kill('SIGTERM')
424
+ } catch {
425
+ /* already gone */
426
+ }
427
+ }
428
+ if (signal.aborted) onAbort()
429
+ else signal.addEventListener('abort', onAbort, { once: true })
430
+
431
+ // feed the turn prompt on stdin
432
+ child.stdin.write(prompt)
433
+ child.stdin.end()
434
+
435
+ let buf = ''
436
+ child.stdout.setEncoding('utf8')
437
+ child.stdout.on('data', (chunk: string) => {
438
+ buf += chunk
439
+ let nl: number
440
+ while ((nl = buf.indexOf('\n')) >= 0) {
441
+ const line = buf.slice(0, nl).trim()
442
+ buf = buf.slice(nl + 1)
443
+ if (line) handleLine(line)
444
+ }
445
+ })
446
+ child.stderr.setEncoding('utf8')
447
+ child.stderr.on('data', (c: string) => {
448
+ stderr += c
449
+ })
450
+
451
+ function handleLine(line: string) {
452
+ let ev: any
453
+ try {
454
+ ev = JSON.parse(line)
455
+ } catch {
456
+ return // non-JSON noise
457
+ }
458
+ switch (ev.type) {
459
+ case 'system':
460
+ if (ev.subtype === 'init') {
461
+ if (ev.session_id) resolvedSession = ev.session_id
462
+ onEvent({ kind: 'status', text: 'session started' })
463
+ }
464
+ // hook_started / hook_response are noise — ignore
465
+ return
466
+ case 'assistant': {
467
+ const content = ev.message?.content
468
+ if (!Array.isArray(content)) return
469
+ for (const block of content) {
470
+ if (block.type === 'text' && block.text?.trim()) {
471
+ onEvent({ kind: 'message', text: block.text.trim() })
472
+ } else if (block.type === 'thinking' && block.thinking?.trim()) {
473
+ onEvent({ kind: 'thinking', text: block.thinking.trim() })
474
+ } else if (block.type === 'tool_use') {
475
+ onEvent({
476
+ kind: 'tool',
477
+ text: block.name,
478
+ tool: block.name,
479
+ target: toolTarget(block.name, block.input),
480
+ })
481
+ }
482
+ }
483
+ return
484
+ }
485
+ case 'result': {
486
+ if (typeof ev.result === 'string') finalText = ev.result
487
+ if (typeof ev.total_cost_usd === 'number') costUsd = ev.total_cost_usd
488
+ if (typeof ev.num_turns === 'number') numTurns = ev.num_turns
489
+ if (ev.usage && typeof ev.usage === 'object') {
490
+ const u = ev.usage as Record<string, number | undefined>
491
+ tokens =
492
+ (u.input_tokens ?? 0) +
493
+ (u.output_tokens ?? 0) +
494
+ (u.cache_read_input_tokens ?? 0) +
495
+ (u.cache_creation_input_tokens ?? 0)
496
+ }
497
+ if (ev.session_id) resolvedSession = ev.session_id
498
+ if (
499
+ ev.is_error ||
500
+ ev.subtype === 'error_during_execution' ||
501
+ ev.subtype === 'error_max_turns'
502
+ ) {
503
+ isError = true
504
+ errorMessage = ev.subtype || 'agent error'
505
+ }
506
+ return
507
+ }
508
+ case 'rate_limit_event': {
509
+ const info = ev.rate_limit_info
510
+ if (info && info.status && info.status !== 'allowed') {
511
+ onEvent({ kind: 'status', text: `rate limit: ${info.status}` })
512
+ }
513
+ return
514
+ }
515
+ }
516
+ }
517
+
518
+ child.on('error', (err) => {
519
+ resolve({
520
+ sessionId: resolvedSession,
521
+ finalText,
522
+ costUsd,
523
+ numTurns,
524
+ tokens,
525
+ isError: true,
526
+ errorMessage: `failed to spawn ${BIN}: ${err.message}`,
527
+ })
528
+ })
529
+
530
+ child.on('close', (code) => {
531
+ if (signal.aborted) {
532
+ resolve({
533
+ sessionId: resolvedSession,
534
+ finalText,
535
+ costUsd,
536
+ numTurns,
537
+ tokens,
538
+ isError: true,
539
+ errorMessage: 'canceled',
540
+ })
541
+ return
542
+ }
543
+ if (code !== 0 && !finalText) {
544
+ isError = true
545
+ errorMessage =
546
+ errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-400)}` : ''}`
547
+ }
548
+ // Only meaningful when we actually tried to resume: did Claude reject the id?
549
+ const resumeRejected =
550
+ !!sessionId &&
551
+ (isError || code !== 0) &&
552
+ RESUME_REJECTED.test(`${errorMessage ?? ''}\n${stderr}`)
553
+ resolve({
554
+ sessionId: resolvedSession,
555
+ finalText,
556
+ costUsd,
557
+ numTurns,
558
+ tokens,
559
+ isError,
560
+ errorMessage,
561
+ resumeRejected,
562
+ })
563
+ })
564
+ })
565
+ }
566
+
567
+ /**
568
+ * A quick, EPHEMERAL side-question. Forks the domain's live session (`--resume …
569
+ * --fork-session`) so it inherits the full conversation context but writes nothing
570
+ * back to the parent transcript. Same model/tool surface/permission mode as the
571
+ * main agent, with the same configured effort.
572
+ */
573
+ ask(input: AskInput): Promise<AskResult> {
574
+ const { root, prompt, appendSystemPrompt, sessionId, effort, env, signal, onDelta } = input
575
+
576
+ const args = [
577
+ '-p',
578
+ '--output-format',
579
+ 'stream-json',
580
+ '--verbose',
581
+ '--permission-mode',
582
+ PERMISSION_MODE,
583
+ ]
584
+ if (effort) args.push('--effort', effort)
585
+ // forking inherits context but leaves the parent untouched; --no-session-persistence
586
+ // means the fork itself isn't saved either (truly ephemeral). No fork ⇒ a fresh ask.
587
+ if (sessionId) args.push('--resume', sessionId, '--fork-session')
588
+ args.push('--no-session-persistence')
589
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
590
+ args.push(...EXTRA_ARGS)
591
+
592
+ return new Promise((resolve) => {
593
+ let finalText = ''
594
+ let stderr = ''
595
+ let isError = false
596
+ let errorMessage: string | undefined
597
+
598
+ const child = spawn(BIN, args, { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv(env) })
599
+ const onAbort = () => {
600
+ try {
601
+ child.kill('SIGTERM')
602
+ } catch {
603
+ /* already gone */
604
+ }
605
+ }
606
+ if (signal.aborted) onAbort()
607
+ else signal.addEventListener('abort', onAbort, { once: true })
608
+
609
+ child.stdin.write(prompt)
610
+ child.stdin.end()
611
+
612
+ let buf = ''
613
+ child.stdout.setEncoding('utf8')
614
+ child.stdout.on('data', (chunk: string) => {
615
+ buf += chunk
616
+ let nl: number
617
+ while ((nl = buf.indexOf('\n')) >= 0) {
618
+ const line = buf.slice(0, nl).trim()
619
+ buf = buf.slice(nl + 1)
620
+ if (!line) continue
621
+ let ev: any
622
+ try {
623
+ ev = JSON.parse(line)
624
+ } catch {
625
+ continue
626
+ }
627
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
628
+ for (const b of ev.message.content) if (b.type === 'text' && b.text) onDelta(b.text)
629
+ } else if (ev.type === 'result') {
630
+ if (typeof ev.result === 'string') finalText = ev.result
631
+ if (
632
+ ev.is_error ||
633
+ ev.subtype === 'error_during_execution' ||
634
+ ev.subtype === 'error_max_turns'
635
+ ) {
636
+ isError = true
637
+ errorMessage = ev.subtype || 'ask error'
638
+ }
639
+ }
640
+ }
641
+ })
642
+ child.stderr.setEncoding('utf8')
643
+ child.stderr.on('data', (c: string) => {
644
+ stderr += c
645
+ })
646
+
647
+ child.on('error', (err) =>
648
+ resolve({
649
+ text: finalText,
650
+ isError: true,
651
+ errorMessage: `failed to spawn ${BIN}: ${err.message}`,
652
+ }),
653
+ )
654
+ child.on('close', (code) => {
655
+ if (signal.aborted)
656
+ return resolve({ text: finalText, isError: true, errorMessage: 'canceled' })
657
+ if (code !== 0 && !finalText) {
658
+ isError = true
659
+ errorMessage =
660
+ errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-300)}` : ''}`
661
+ }
662
+ resolve({ text: finalText, isError, errorMessage })
663
+ })
664
+ })
665
+ }
666
+ }