@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.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- 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
|
+
}
|