@frontera-sdk/cli 1.46.6 → 1.47.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.
- package/package.json +6 -3
- package/src/commands/forge/index-commands.ts +4 -0
- package/src/commands/forge/serve.ts +144 -0
- package/src/commands/registry.ts +3 -0
- package/src/flag-help.ts +7 -1
- package/src/forge/adapter.ts +108 -0
- package/src/forge/claude-adapter.ts +205 -0
- package/src/forge/claude-commands.ts +136 -0
- package/src/forge/claude-skills.ts +189 -0
- package/src/forge/claude-translator.ts +361 -0
- package/src/forge/codex-adapter.ts +28 -0
- package/src/forge/env.ts +42 -0
- package/src/forge/hosts.ts +323 -0
- package/src/forge/http.ts +74 -0
- package/src/forge/log.ts +53 -0
- package/src/forge/projects.ts +349 -0
- package/src/forge/protocol.ts +38 -0
- package/src/forge/runs.ts +250 -0
- package/src/forge/server.ts +655 -0
- package/src/forge/sessions.ts +49 -0
- package/src/forge/store.ts +427 -0
- package/src/forge/terminals.ts +220 -0
- package/src/forge/turn-text.ts +35 -0
- package/src/kit.ts +25 -7
- package/src/vendor/sdk-sources.json +17 -17
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills Claude Code would load for a session, discovered the way it
|
|
3
|
+
* discovers them.
|
|
4
|
+
*
|
|
5
|
+
* Ported from T3 Code's `apps/server/src/provider/Drivers/ClaudeSkills.ts`,
|
|
6
|
+
* whose header states the reason plainly: the Agent SDK's init handshake
|
|
7
|
+
* surfaces skills only as slash commands without their filesystem paths, so
|
|
8
|
+
* the only way to show a name AND what it is for is to scan the same
|
|
9
|
+
* directories the CLI scans.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code reads, in order: the config directory's `skills/`, then the
|
|
12
|
+
* workspace's `.agents/skills` and `.claude/skills`, with later roots winning
|
|
13
|
+
* on a name collision. Installed plugins add their own, namespaced
|
|
14
|
+
* `<plugin>:<skill>` — which is how the Frontera kit's eight appear once
|
|
15
|
+
* `frontera setup` has run.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import type { ForgeSkill } from '@frontera-sdk/forge-contracts'
|
|
22
|
+
|
|
23
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Enough YAML for a skill header, and no more.
|
|
27
|
+
*
|
|
28
|
+
* `name` and `description` are single-line scalars in every SKILL.md the
|
|
29
|
+
* hosts ship; pulling in a parser to read two keys would be the larger risk.
|
|
30
|
+
* A description that spans lines keeps its first line, which is what a menu
|
|
31
|
+
* row shows anyway.
|
|
32
|
+
*/
|
|
33
|
+
function parseFrontmatter(contents: string): { name?: string; description?: string } | null {
|
|
34
|
+
const match = FRONTMATTER.exec(contents)
|
|
35
|
+
if (!match) return null
|
|
36
|
+
const body = match[1] ?? ''
|
|
37
|
+
const read = (key: string): string | undefined => {
|
|
38
|
+
const line = new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(body)
|
|
39
|
+
if (!line) return undefined
|
|
40
|
+
const raw = (line[1] ?? '').trim()
|
|
41
|
+
const unquoted = /^(['"])([\s\S]*)\1$/.exec(raw)
|
|
42
|
+
const value = (unquoted?.[2] ?? raw).trim()
|
|
43
|
+
return value || undefined
|
|
44
|
+
}
|
|
45
|
+
const name = read('name')
|
|
46
|
+
const description = read('description')
|
|
47
|
+
return {
|
|
48
|
+
...(name ? { name } : {}),
|
|
49
|
+
...(description ? { description } : {}),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readSkillDir(dir: string): Array<{ dirName: string; path: string; contents: string }> {
|
|
54
|
+
let entries: string[]
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(dir)
|
|
57
|
+
} catch {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
const out: Array<{ dirName: string; path: string; contents: string }> = []
|
|
61
|
+
for (const entry of [...entries].sort()) {
|
|
62
|
+
const path = join(dir, entry, 'SKILL.md')
|
|
63
|
+
try {
|
|
64
|
+
out.push({ dirName: entry, path, contents: readFileSync(path, 'utf8') })
|
|
65
|
+
} catch {
|
|
66
|
+
// Not a skill directory.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The config directory the spawned CLI would use. */
|
|
73
|
+
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
74
|
+
const configured = env.CLAUDE_CONFIG_DIR?.trim()
|
|
75
|
+
if (configured) return configured
|
|
76
|
+
return join(homedir(), '.claude')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Installed plugins, as `<marketplace>/<plugin>/<version>/skills`.
|
|
81
|
+
*
|
|
82
|
+
* Their skills are namespaced by plugin — `frontera:using-frontera` — because
|
|
83
|
+
* that is how Claude Code addresses them, and a bare `using-frontera` would
|
|
84
|
+
* collide with a project skill of the same name.
|
|
85
|
+
*/
|
|
86
|
+
function pluginSkillRoots(configDir: string): Array<{ plugin: string; dir: string }> {
|
|
87
|
+
const cache = join(configDir, 'plugins', 'cache')
|
|
88
|
+
const roots: Array<{ plugin: string; dir: string }> = []
|
|
89
|
+
let marketplaces: string[]
|
|
90
|
+
try {
|
|
91
|
+
marketplaces = readdirSync(cache)
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
for (const marketplace of marketplaces.sort()) {
|
|
96
|
+
let plugins: string[]
|
|
97
|
+
try {
|
|
98
|
+
plugins = readdirSync(join(cache, marketplace))
|
|
99
|
+
} catch {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
for (const plugin of plugins.sort()) {
|
|
103
|
+
let versions: string[]
|
|
104
|
+
try {
|
|
105
|
+
versions = readdirSync(join(cache, marketplace, plugin))
|
|
106
|
+
} catch {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
// Newest version wins when a cache holds more than one.
|
|
110
|
+
for (const version of versions.sort().reverse()) {
|
|
111
|
+
const dir = join(cache, marketplace, plugin, version, 'skills')
|
|
112
|
+
try {
|
|
113
|
+
if (statSync(dir).isDirectory()) {
|
|
114
|
+
roots.push({ plugin, dir })
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Next version.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return roots
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every skill available to a session in `cwd`, in menu order.
|
|
128
|
+
*
|
|
129
|
+
* Keyed by the id the model is addressed with, so a project skill shadowing a
|
|
130
|
+
* user one collapses to a single row — the same resolution Claude Code
|
|
131
|
+
* applies when it loads them.
|
|
132
|
+
*/
|
|
133
|
+
export function discoverClaudeSkills(cwd: string, env: NodeJS.ProcessEnv = process.env): ForgeSkill[] {
|
|
134
|
+
const configDir = claudeConfigDir(env)
|
|
135
|
+
const byId = new Map<string, ForgeSkill>()
|
|
136
|
+
|
|
137
|
+
const roots: Array<{ dir: string; source: ForgeSkill['source']; plugin?: string }> = [
|
|
138
|
+
{ dir: join(configDir, 'skills'), source: 'user' },
|
|
139
|
+
...(cwd
|
|
140
|
+
? [
|
|
141
|
+
{ dir: join(cwd, '.agents', 'skills'), source: 'project' as const },
|
|
142
|
+
{ dir: join(cwd, '.claude', 'skills'), source: 'project' as const },
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
...pluginSkillRoots(configDir).map((root) => ({
|
|
146
|
+
dir: root.dir,
|
|
147
|
+
source: 'plugin' as const,
|
|
148
|
+
plugin: root.plugin,
|
|
149
|
+
})),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
for (const root of roots) {
|
|
153
|
+
for (const found of readSkillDir(root.dir)) {
|
|
154
|
+
const frontmatter = parseFrontmatter(found.contents)
|
|
155
|
+
// No frontmatter means Claude Code will not load it either.
|
|
156
|
+
if (!frontmatter) continue
|
|
157
|
+
const name = frontmatter.name ?? found.dirName.trim()
|
|
158
|
+
if (!name) continue
|
|
159
|
+
const id = root.plugin ? `${root.plugin}:${name}` : name
|
|
160
|
+
byId.set(id, {
|
|
161
|
+
id,
|
|
162
|
+
name,
|
|
163
|
+
source: root.source,
|
|
164
|
+
path: found.path,
|
|
165
|
+
...(root.plugin ? { plugin: root.plugin } : {}),
|
|
166
|
+
...(frontmatter.description ? { description: frontmatter.description } : {}),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Cheap cache: a menu opens often, and the disk answer rarely changes. */
|
|
175
|
+
const CACHE_MS = 30_000
|
|
176
|
+
const cache = new Map<string, { at: number; skills: ForgeSkill[] }>()
|
|
177
|
+
|
|
178
|
+
export function skillsFor(cwd: string, env: NodeJS.ProcessEnv = process.env): ForgeSkill[] {
|
|
179
|
+
const hit = cache.get(cwd)
|
|
180
|
+
if (hit && Date.now() - hit.at < CACHE_MS) return hit.skills
|
|
181
|
+
const skills = discoverClaudeSkills(cwd, env)
|
|
182
|
+
cache.set(cwd, { at: Date.now(), skills })
|
|
183
|
+
return skills
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Exported for tests: forget what was read. */
|
|
187
|
+
export function clearSkillCache(): void {
|
|
188
|
+
cache.clear()
|
|
189
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import type { UIMessageChunk } from 'ai'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claude Agent SDK messages → AI SDK v6 `UIMessageChunk`s, the vocabulary the
|
|
6
|
+
* platform's chat components already render. Mirrors the shape of the Codex
|
|
7
|
+
* translator in the service (`codex-events.ts`): a stateful object fed one
|
|
8
|
+
* native message at a time, returning the chunks it implies.
|
|
9
|
+
*
|
|
10
|
+
* With `includePartialMessages` the SDK forwards raw Anthropic stream events
|
|
11
|
+
* (`message_start`, `content_block_*`, `message_stop`) alongside the complete
|
|
12
|
+
* `assistant` messages. The stream events carry the deltas; the complete
|
|
13
|
+
* messages are only a safety net for tool inputs when a partial stream was
|
|
14
|
+
* absent. Tool results arrive as `user` messages holding `tool_result`
|
|
15
|
+
* blocks. Sub-agent traffic (`parent_tool_use_id` set) is dropped: the UI
|
|
16
|
+
* shows the parent `Task` tool card, not its inner conversation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Chunk = UIMessageChunk
|
|
20
|
+
|
|
21
|
+
interface ContentBlock {
|
|
22
|
+
type: string
|
|
23
|
+
id?: string
|
|
24
|
+
name?: string
|
|
25
|
+
text?: string
|
|
26
|
+
thinking?: string
|
|
27
|
+
input?: unknown
|
|
28
|
+
tool_use_id?: string
|
|
29
|
+
content?: unknown
|
|
30
|
+
is_error?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StreamEvent {
|
|
34
|
+
type: string
|
|
35
|
+
index?: number
|
|
36
|
+
content_block?: ContentBlock
|
|
37
|
+
delta?: { type?: string; text?: string; thinking?: string; partial_json?: string }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The fields this translator reads, typed loosely so a fixture is a plain object. */
|
|
41
|
+
interface LooseMessage {
|
|
42
|
+
type: string
|
|
43
|
+
subtype?: string
|
|
44
|
+
session_id?: string
|
|
45
|
+
parent_tool_use_id?: string | null
|
|
46
|
+
isReplay?: boolean
|
|
47
|
+
event?: StreamEvent
|
|
48
|
+
message?: { content?: unknown }
|
|
49
|
+
is_error?: boolean
|
|
50
|
+
result?: string
|
|
51
|
+
errors?: string[]
|
|
52
|
+
usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface OpenBlock {
|
|
56
|
+
kind: 'text' | 'thinking' | 'tool_use' | 'other'
|
|
57
|
+
id: string
|
|
58
|
+
toolName: string
|
|
59
|
+
json: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TranslatorUsage {
|
|
63
|
+
input: number
|
|
64
|
+
output: number
|
|
65
|
+
cachedInput: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stringifyToolResult(content: unknown): string {
|
|
69
|
+
if (typeof content === 'string') return content
|
|
70
|
+
if (!Array.isArray(content)) return content == null ? '' : JSON.stringify(content)
|
|
71
|
+
return content
|
|
72
|
+
.map((part: { type?: string; text?: string }) => {
|
|
73
|
+
if (part && part.type === 'text' && typeof part.text === 'string') return part.text
|
|
74
|
+
if (part && part.type === 'image') return '[image]'
|
|
75
|
+
return JSON.stringify(part)
|
|
76
|
+
})
|
|
77
|
+
.join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseInput(json: string): Record<string, unknown> {
|
|
81
|
+
if (!json.trim()) return {}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(json) as unknown
|
|
84
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
|
85
|
+
} catch {
|
|
86
|
+
return {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ClaudeEventTranslator {
|
|
91
|
+
private started = false
|
|
92
|
+
private stepOpen = false
|
|
93
|
+
private stepHasContent = false
|
|
94
|
+
private finished = false
|
|
95
|
+
private sawStreamEvents = false
|
|
96
|
+
private partCount = 0
|
|
97
|
+
private readonly blocks = new Map<number, OpenBlock>()
|
|
98
|
+
private readonly toolStarts = new Set<string>()
|
|
99
|
+
private readonly toolInputs = new Set<string>()
|
|
100
|
+
|
|
101
|
+
sessionId: string | null = null
|
|
102
|
+
failed = false
|
|
103
|
+
errorText: string | null = null
|
|
104
|
+
usage: TranslatorUsage | null = null
|
|
105
|
+
|
|
106
|
+
constructor(private readonly messageId: string) {}
|
|
107
|
+
|
|
108
|
+
get isFinished(): boolean {
|
|
109
|
+
return this.finished
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
translate(raw: SDKMessage): Chunk[] {
|
|
113
|
+
const msg = raw as unknown as LooseMessage
|
|
114
|
+
const out: Chunk[] = []
|
|
115
|
+
if (this.finished) return out
|
|
116
|
+
|
|
117
|
+
if (!this.sessionId && typeof msg.session_id === 'string' && msg.session_id) {
|
|
118
|
+
this.sessionId = msg.session_id
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
switch (msg.type) {
|
|
122
|
+
case 'stream_event':
|
|
123
|
+
if (msg.parent_tool_use_id) break
|
|
124
|
+
if (msg.event) this.onStreamEvent(msg.event, out)
|
|
125
|
+
break
|
|
126
|
+
case 'assistant':
|
|
127
|
+
if (msg.parent_tool_use_id) break
|
|
128
|
+
this.onAssistant(msg.message?.content, out)
|
|
129
|
+
break
|
|
130
|
+
case 'user':
|
|
131
|
+
if (msg.parent_tool_use_id || msg.isReplay) break
|
|
132
|
+
this.onUser(msg.message?.content, out)
|
|
133
|
+
break
|
|
134
|
+
case 'result':
|
|
135
|
+
this.onResult(msg, out)
|
|
136
|
+
break
|
|
137
|
+
default:
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A permission prompt can reach the adapter before this translator has
|
|
145
|
+
* consumed the stream events that introduce the tool call — the SDK answers
|
|
146
|
+
* control requests as they arrive while messages queue for the consumer.
|
|
147
|
+
* The approval chunk needs the tool part to exist, so the adapter asks for
|
|
148
|
+
* it here; if the events already came through, nothing is emitted twice.
|
|
149
|
+
*/
|
|
150
|
+
ensureToolCall(toolCallId: string, toolName: string, input: unknown): Chunk[] {
|
|
151
|
+
const out: Chunk[] = []
|
|
152
|
+
this.ensureStarted(out)
|
|
153
|
+
this.ensureStep(out)
|
|
154
|
+
out.push(...this.toolStart(toolCallId, toolName))
|
|
155
|
+
out.push(...this.toolInput(toolCallId, toolName, input))
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The host threw; say so in the stream and close it. */
|
|
160
|
+
fail(errorText: string): Chunk[] {
|
|
161
|
+
const out: Chunk[] = []
|
|
162
|
+
if (this.finished) return out
|
|
163
|
+
this.failed = true
|
|
164
|
+
this.errorText = errorText
|
|
165
|
+
this.ensureStarted(out)
|
|
166
|
+
out.push({ type: 'error', errorText } as Chunk)
|
|
167
|
+
out.push(...this.finish())
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Close the stream if the host ended without a `result`. */
|
|
172
|
+
finish(): Chunk[] {
|
|
173
|
+
const out: Chunk[] = []
|
|
174
|
+
if (this.finished) return out
|
|
175
|
+
this.ensureStarted(out)
|
|
176
|
+
this.closeStep(out)
|
|
177
|
+
out.push({ type: 'finish' } as Chunk)
|
|
178
|
+
this.finished = true
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// -- internals ------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
private nextId(prefix: string): string {
|
|
185
|
+
return `${this.messageId}_${prefix}${this.partCount++}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private ensureStarted(out: Chunk[]): void {
|
|
189
|
+
if (this.started) return
|
|
190
|
+
this.started = true
|
|
191
|
+
out.push({ type: 'start', messageId: this.messageId } as Chunk)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private ensureStep(out: Chunk[]): void {
|
|
195
|
+
if (this.stepOpen) return
|
|
196
|
+
this.stepOpen = true
|
|
197
|
+
this.stepHasContent = false
|
|
198
|
+
out.push({ type: 'start-step' } as Chunk)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private closeStep(out: Chunk[]): void {
|
|
202
|
+
if (!this.stepOpen) return
|
|
203
|
+
this.stepOpen = false
|
|
204
|
+
out.push({ type: 'finish-step' } as Chunk)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A new API message is a new step, unless the open one is still empty. */
|
|
208
|
+
private newStep(out: Chunk[]): void {
|
|
209
|
+
if (this.stepOpen && this.stepHasContent) this.closeStep(out)
|
|
210
|
+
this.ensureStep(out)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private toolStart(toolCallId: string, toolName: string): Chunk[] {
|
|
214
|
+
if (this.toolStarts.has(toolCallId)) return []
|
|
215
|
+
this.toolStarts.add(toolCallId)
|
|
216
|
+
this.stepHasContent = true
|
|
217
|
+
return [{ type: 'tool-input-start', toolCallId, toolName } as Chunk]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private toolInput(toolCallId: string, toolName: string, input: unknown): Chunk[] {
|
|
221
|
+
if (this.toolInputs.has(toolCallId)) return []
|
|
222
|
+
this.toolInputs.add(toolCallId)
|
|
223
|
+
return [{ type: 'tool-input-available', toolCallId, toolName, input: input ?? {} } as Chunk]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private onStreamEvent(ev: StreamEvent, out: Chunk[]): void {
|
|
227
|
+
this.sawStreamEvents = true
|
|
228
|
+
switch (ev.type) {
|
|
229
|
+
case 'message_start': {
|
|
230
|
+
this.ensureStarted(out)
|
|
231
|
+
this.newStep(out)
|
|
232
|
+
this.blocks.clear()
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
case 'content_block_start': {
|
|
236
|
+
const block = ev.content_block
|
|
237
|
+
if (!block || ev.index === undefined) break
|
|
238
|
+
this.ensureStarted(out)
|
|
239
|
+
this.ensureStep(out)
|
|
240
|
+
if (block.type === 'text') {
|
|
241
|
+
const id = this.nextId('t')
|
|
242
|
+
this.blocks.set(ev.index, { kind: 'text', id, toolName: '', json: '' })
|
|
243
|
+
this.stepHasContent = true
|
|
244
|
+
out.push({ type: 'text-start', id } as Chunk)
|
|
245
|
+
} else if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
|
246
|
+
const id = this.nextId('r')
|
|
247
|
+
this.blocks.set(ev.index, { kind: 'thinking', id, toolName: '', json: '' })
|
|
248
|
+
this.stepHasContent = true
|
|
249
|
+
out.push({ type: 'reasoning-start', id } as Chunk)
|
|
250
|
+
} else if (block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use') {
|
|
251
|
+
const id = block.id ?? this.nextId('call')
|
|
252
|
+
const toolName = block.name ?? 'tool'
|
|
253
|
+
this.blocks.set(ev.index, { kind: 'tool_use', id, toolName, json: '' })
|
|
254
|
+
out.push(...this.toolStart(id, toolName))
|
|
255
|
+
} else {
|
|
256
|
+
this.blocks.set(ev.index, { kind: 'other', id: '', toolName: '', json: '' })
|
|
257
|
+
}
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
case 'content_block_delta': {
|
|
261
|
+
if (ev.index === undefined) break
|
|
262
|
+
const block = this.blocks.get(ev.index)
|
|
263
|
+
const delta = ev.delta
|
|
264
|
+
if (!block || !delta) break
|
|
265
|
+
if (block.kind === 'text' && delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
266
|
+
out.push({ type: 'text-delta', id: block.id, delta: delta.text } as Chunk)
|
|
267
|
+
} else if (block.kind === 'thinking' && delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
|
268
|
+
out.push({ type: 'reasoning-delta', id: block.id, delta: delta.thinking } as Chunk)
|
|
269
|
+
} else if (block.kind === 'tool_use' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
270
|
+
block.json += delta.partial_json
|
|
271
|
+
if (!this.toolInputs.has(block.id)) {
|
|
272
|
+
out.push({ type: 'tool-input-delta', toolCallId: block.id, inputTextDelta: delta.partial_json } as Chunk)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
case 'content_block_stop': {
|
|
278
|
+
if (ev.index === undefined) break
|
|
279
|
+
const block = this.blocks.get(ev.index)
|
|
280
|
+
if (!block) break
|
|
281
|
+
this.blocks.delete(ev.index)
|
|
282
|
+
if (block.kind === 'text') out.push({ type: 'text-end', id: block.id } as Chunk)
|
|
283
|
+
else if (block.kind === 'thinking') out.push({ type: 'reasoning-end', id: block.id } as Chunk)
|
|
284
|
+
else if (block.kind === 'tool_use') out.push(...this.toolInput(block.id, block.toolName, parseInput(block.json)))
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
// message_delta / message_stop / ping carry nothing the UI shows.
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private onAssistant(content: unknown, out: Chunk[]): void {
|
|
294
|
+
if (!Array.isArray(content)) return
|
|
295
|
+
this.ensureStarted(out)
|
|
296
|
+
if (!this.sawStreamEvents) this.newStep(out)
|
|
297
|
+
for (const block of content as ContentBlock[]) {
|
|
298
|
+
if (!block || typeof block !== 'object') continue
|
|
299
|
+
if (block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use') {
|
|
300
|
+
const id = block.id ?? this.nextId('call')
|
|
301
|
+
const toolName = block.name ?? 'tool'
|
|
302
|
+
this.ensureStep(out)
|
|
303
|
+
out.push(...this.toolStart(id, toolName))
|
|
304
|
+
out.push(...this.toolInput(id, toolName, block.input ?? {}))
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
// Text and thinking already streamed as deltas when partials are on;
|
|
308
|
+
// repeating them here would double every paragraph.
|
|
309
|
+
if (this.sawStreamEvents) continue
|
|
310
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
311
|
+
const id = this.nextId('t')
|
|
312
|
+
this.stepHasContent = true
|
|
313
|
+
out.push({ type: 'text-start', id } as Chunk)
|
|
314
|
+
out.push({ type: 'text-delta', id, delta: block.text } as Chunk)
|
|
315
|
+
out.push({ type: 'text-end', id } as Chunk)
|
|
316
|
+
} else if (block.type === 'thinking' && typeof block.thinking === 'string') {
|
|
317
|
+
const id = this.nextId('r')
|
|
318
|
+
this.stepHasContent = true
|
|
319
|
+
out.push({ type: 'reasoning-start', id } as Chunk)
|
|
320
|
+
out.push({ type: 'reasoning-delta', id, delta: block.thinking } as Chunk)
|
|
321
|
+
out.push({ type: 'reasoning-end', id } as Chunk)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private onUser(content: unknown, out: Chunk[]): void {
|
|
327
|
+
if (!Array.isArray(content)) return
|
|
328
|
+
for (const block of content as ContentBlock[]) {
|
|
329
|
+
if (!block || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue
|
|
330
|
+
// A result for a call the stream never introduced has no part to land
|
|
331
|
+
// on; the AI SDK reducer throws on it, so it is dropped rather than sent.
|
|
332
|
+
if (!this.toolStarts.has(block.tool_use_id)) continue
|
|
333
|
+
const text = stringifyToolResult(block.content)
|
|
334
|
+
if (block.is_error) {
|
|
335
|
+
out.push({ type: 'tool-output-error', toolCallId: block.tool_use_id, errorText: text || 'tool failed' } as Chunk)
|
|
336
|
+
} else {
|
|
337
|
+
out.push({ type: 'tool-output-available', toolCallId: block.tool_use_id, output: text } as Chunk)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private onResult(msg: LooseMessage, out: Chunk[]): void {
|
|
343
|
+
this.ensureStarted(out)
|
|
344
|
+
if (msg.usage) {
|
|
345
|
+
this.usage = {
|
|
346
|
+
input: msg.usage.input_tokens ?? 0,
|
|
347
|
+
output: msg.usage.output_tokens ?? 0,
|
|
348
|
+
cachedInput: msg.usage.cache_read_input_tokens ?? 0,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (msg.is_error || (msg.subtype && msg.subtype !== 'success')) {
|
|
352
|
+
this.failed = true
|
|
353
|
+
this.errorText =
|
|
354
|
+
(msg.errors && msg.errors.length > 0 ? msg.errors.join('\n') : null)
|
|
355
|
+
?? (typeof msg.result === 'string' && msg.result ? msg.result : null)
|
|
356
|
+
?? `Claude Code ended the turn with ${msg.subtype ?? 'an error'}`
|
|
357
|
+
out.push({ type: 'error', errorText: this.errorText } as Chunk)
|
|
358
|
+
}
|
|
359
|
+
out.push(...this.finish())
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UIMessageChunk } from 'ai'
|
|
2
|
+
|
|
3
|
+
import type { ApprovalDecision, Emit, HostAdapter, TurnContext, TurnOutcome } from './adapter'
|
|
4
|
+
|
|
5
|
+
const NOT_WIRED = 'Codex is not wired in this build yet — use Claude Code'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Placeholder until the Codex app-server adapter lands. It speaks the same
|
|
9
|
+
* `HostAdapter` contract so the run manager, the store and the surface need
|
|
10
|
+
* no change when it does; the only thing it says today is why it cannot run.
|
|
11
|
+
*/
|
|
12
|
+
export class CodexAdapter implements HostAdapter {
|
|
13
|
+
async startTurn(ctx: TurnContext, emit: Emit): Promise<TurnOutcome> {
|
|
14
|
+
const chunks: UIMessageChunk[] = [
|
|
15
|
+
{ type: 'start', messageId: ctx.messageId } as UIMessageChunk,
|
|
16
|
+
{ type: 'error', errorText: NOT_WIRED } as UIMessageChunk,
|
|
17
|
+
{ type: 'finish' } as UIMessageChunk,
|
|
18
|
+
]
|
|
19
|
+
for (const chunk of chunks) emit(chunk)
|
|
20
|
+
return { error: NOT_WIRED }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async interrupt(): Promise<void> {}
|
|
24
|
+
|
|
25
|
+
respondApproval(_approvalId: string, _decision: ApprovalDecision): boolean {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/forge/env.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { dataDir } from '../paths'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PATH every process Forge spawns sees: hosts, terminals, and the hosts'
|
|
8
|
+
* own subprocesses.
|
|
9
|
+
*
|
|
10
|
+
* `frontera` goes first so the agent and the person type the same command and
|
|
11
|
+
* reach the same binary — the CLI that owns this daemon, not whichever older
|
|
12
|
+
* copy a package manager left on the machine. The well-known tool directories
|
|
13
|
+
* follow because a daemon launched by a GUI app inherits the login PATH, not
|
|
14
|
+
* the shell's, and that is where `claude` and `codex` usually live.
|
|
15
|
+
*/
|
|
16
|
+
export function forgePath(base: string | undefined = process.env.PATH): string {
|
|
17
|
+
const home = homedir()
|
|
18
|
+
const first = [join(dataDir(), 'bin'), dirname(process.execPath)]
|
|
19
|
+
const wellKnown = [
|
|
20
|
+
join(home, '.local', 'bin'),
|
|
21
|
+
join(home, '.bun', 'bin'),
|
|
22
|
+
join(home, '.claude', 'local'),
|
|
23
|
+
'/opt/homebrew/bin',
|
|
24
|
+
'/usr/local/bin',
|
|
25
|
+
]
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
const out: string[] = []
|
|
28
|
+
for (const dir of [...first, ...(base ? base.split(delimiter) : []), ...wellKnown]) {
|
|
29
|
+
if (!dir || seen.has(dir)) continue
|
|
30
|
+
seen.add(dir)
|
|
31
|
+
out.push(dir)
|
|
32
|
+
}
|
|
33
|
+
return out.join(delimiter)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function forgeEnv(extra: Record<string, string | undefined> = {}): Record<string, string | undefined> {
|
|
37
|
+
const env: Record<string, string | undefined> = { ...process.env, PATH: forgePath(), ...extra }
|
|
38
|
+
// Set by an Electron parent to run its own binary as Node; inherited by a
|
|
39
|
+
// child it turns every `electron`-based tool on PATH into a bare Node.
|
|
40
|
+
delete env.ELECTRON_RUN_AS_NODE
|
|
41
|
+
return env
|
|
42
|
+
}
|