@puzzmo/cli 1.0.33 → 1.0.35
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/lib/agents/claude.d.ts +7 -0
- package/lib/agents/claude.js +71 -0
- package/lib/agents/claude.js.map +1 -0
- package/lib/agents/codex.d.ts +6 -0
- package/lib/agents/codex.js +92 -0
- package/lib/agents/codex.js.map +1 -0
- package/lib/agents/copilot.d.ts +6 -0
- package/lib/agents/copilot.js +125 -0
- package/lib/agents/copilot.js.map +1 -0
- package/lib/agents/gemini.d.ts +7 -0
- package/lib/agents/gemini.js +73 -0
- package/lib/agents/gemini.js.map +1 -0
- package/lib/agents/index.d.ts +5 -0
- package/lib/agents/index.js +15 -0
- package/lib/agents/index.js.map +1 -0
- package/lib/agents/opencode.d.ts +7 -0
- package/lib/agents/opencode.js +161 -0
- package/lib/agents/opencode.js.map +1 -0
- package/lib/agents/stream-json-cli.d.ts +14 -0
- package/lib/agents/stream-json-cli.js +75 -0
- package/lib/agents/stream-json-cli.js.map +1 -0
- package/lib/agents/types.d.ts +39 -0
- package/lib/agents/types.js +2 -0
- package/lib/agents/types.js.map +1 -0
- package/lib/commands/agent-test.d.ts +5 -0
- package/lib/commands/agent-test.js +81 -0
- package/lib/commands/agent-test.js.map +1 -0
- package/lib/commands/game/create.js +6 -4
- package/lib/commands/game/create.js.map +1 -1
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -1
- package/lib/tui/pipeline.d.ts +1 -2
- package/lib/tui/pipeline.js +167 -267
- package/lib/tui/pipeline.js.map +1 -1
- package/package.json +4 -1
- package/src/agents/claude.ts +74 -0
- package/src/agents/codex.ts +84 -0
- package/src/agents/copilot.ts +120 -0
- package/src/agents/gemini.ts +78 -0
- package/src/agents/index.ts +20 -0
- package/src/agents/opencode.ts +156 -0
- package/src/agents/stream-json-cli.ts +88 -0
- package/src/agents/types.ts +24 -0
- package/src/commands/agent-test.ts +83 -0
- package/src/commands/game/create.ts +6 -4
- package/src/index.ts +5 -0
- package/src/tui/pipeline.ts +158 -268
package/src/tui/pipeline.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execSync } from "node:child_process"
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { getAgent, type Agent, type AgentEvent } from "../agents/index.js"
|
|
7
6
|
import { skillsPipeline } from "../skills/registry.js"
|
|
8
7
|
import { fetchSkillPrompt } from "../skills/mcp-client.js"
|
|
9
8
|
|
|
10
9
|
type StepState = "pending" | "running" | "success" | "failed" | "skipped"
|
|
11
10
|
type Phase = "agent" | "build" | "commit" | "idle"
|
|
12
11
|
|
|
13
|
-
// ANSI escape helpers
|
|
14
12
|
const ESC = "\x1b"
|
|
15
13
|
const CSI = `${ESC}[`
|
|
16
14
|
const moveTo = (row: number, col: number) => `${CSI}${row};${col}H`
|
|
17
15
|
const clearScreen = () => `${CSI}2J`
|
|
18
16
|
const hideCursor = () => `${CSI}?25l`
|
|
19
17
|
const showCursor = () => `${CSI}?25h`
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
18
|
+
const reset = () => `${CSI}0m`
|
|
19
|
+
const bold = (s: string) => `${CSI}1m${s}${reset()}`
|
|
20
|
+
const dim = (s: string) => `${CSI}2m${s}${reset()}`
|
|
21
|
+
const fg = (code: number, s: string) => `${CSI}${code}m${s}${reset()}`
|
|
23
22
|
|
|
24
23
|
const green = (s: string) => fg(32, s)
|
|
25
24
|
const yellow = (s: string) => fg(33, s)
|
|
@@ -51,32 +50,46 @@ const phaseColor: Record<Phase, (s: string) => string> = {
|
|
|
51
50
|
idle: gray,
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
if (agent === "codex") return { cmd: "codex", args: ["--quiet", prompt] }
|
|
58
|
-
return { cmd: agent, args: [prompt] }
|
|
59
|
-
}
|
|
53
|
+
// oxlint-disable-next-line no-control-regex
|
|
54
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
|
|
55
|
+
const stripAnsi = (s: string) => s.replace(ANSI_RE, "")
|
|
60
56
|
|
|
61
|
-
/** Fetches step instructions from the MCP server and wraps them as an agent prompt */
|
|
62
57
|
const buildPrompt = async (stepName: string, gameDir: string, repoContext: string): Promise<string> => {
|
|
63
58
|
const instructions = await fetchSkillPrompt(stepName, gameDir)
|
|
64
59
|
return `Follow these instructions. The game source is in the current directory.\n\n${repoContext}\n\n${instructions}`
|
|
65
60
|
}
|
|
66
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Format a non-streaming event into discrete lines. text/thinking are handled
|
|
64
|
+
* separately by appendText so token deltas merge into one in-progress line.
|
|
65
|
+
*/
|
|
66
|
+
const formatDiscreteEvent = (e: AgentEvent): string[] => {
|
|
67
|
+
if (e.type === "tool_use") return [`${yellow(e.name)} ${dim(e.summary)}`]
|
|
68
|
+
if (e.type === "tool_result") return [(e.ok ? green(" ✓ ") : red(" ✗ ")) + dim(e.summary)]
|
|
69
|
+
if (e.type === "system") return [dim(e.text)]
|
|
70
|
+
if (e.type === "result") {
|
|
71
|
+
const cost = e.costUSD != null ? ` ($${e.costUSD.toFixed(4)})` : ""
|
|
72
|
+
return [(e.ok ? green : red)(`Done${cost}`)]
|
|
73
|
+
}
|
|
74
|
+
if (e.type === "error") return [red(`Error: ${e.message}`)]
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
class PipelineTUI {
|
|
68
79
|
private states: StepState[]
|
|
69
80
|
private currentStep = 0
|
|
70
|
-
private outputLines: string[] = []
|
|
71
|
-
private chatLines: string[] = []
|
|
72
81
|
private phase: Phase = "idle"
|
|
73
82
|
private done = false
|
|
74
|
-
private
|
|
83
|
+
private outputLines: string[] = []
|
|
84
|
+
private currentLine = ""
|
|
85
|
+
private chatLines: string[] = []
|
|
86
|
+
private renderScheduled = false
|
|
75
87
|
private sidebarWidth = 30
|
|
76
88
|
private logsDir: string
|
|
89
|
+
private abortController: AbortController | null = null
|
|
77
90
|
|
|
78
91
|
constructor(
|
|
79
|
-
private agent:
|
|
92
|
+
private agent: Agent,
|
|
80
93
|
private gameDir: string,
|
|
81
94
|
private repoContext: string,
|
|
82
95
|
) {
|
|
@@ -93,7 +106,11 @@ class PipelineTUI {
|
|
|
93
106
|
return process.stdout.columns || 80
|
|
94
107
|
}
|
|
95
108
|
|
|
96
|
-
private get
|
|
109
|
+
private get outputWidth(): number {
|
|
110
|
+
return Math.max(this.cols - this.sidebarWidth - 1, 20)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private get contentRows(): number {
|
|
97
114
|
return Math.max(this.rows - 4, 8)
|
|
98
115
|
}
|
|
99
116
|
|
|
@@ -101,38 +118,79 @@ class PipelineTUI {
|
|
|
101
118
|
process.stdout.write(s)
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
|
|
121
|
+
private scheduleRender() {
|
|
122
|
+
if (this.renderScheduled) return
|
|
123
|
+
this.renderScheduled = true
|
|
124
|
+
setImmediate(() => {
|
|
125
|
+
this.renderScheduled = false
|
|
126
|
+
this.render()
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Lock the in-progress streaming line as a complete entry. */
|
|
131
|
+
private lockCurrent() {
|
|
132
|
+
if (this.currentLine.length === 0) return
|
|
133
|
+
this.outputLines.push(this.currentLine)
|
|
134
|
+
this.chatLines.push(stripAnsi(this.currentLine))
|
|
135
|
+
this.currentLine = ""
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Append streaming text. Newlines lock the in-progress line; the trailing
|
|
140
|
+
* fragment continues as the in-progress line. Optional styler wraps each
|
|
141
|
+
* fragment in ANSI codes (e.g. gray() for thinking).
|
|
142
|
+
*/
|
|
143
|
+
private appendText(text: string, styler?: (s: string) => string) {
|
|
144
|
+
if (text.length === 0) return
|
|
145
|
+
const parts = text.split("\n")
|
|
146
|
+
this.currentLine += styler ? styler(parts[0]) : parts[0]
|
|
147
|
+
for (let i = 1; i < parts.length; i++) {
|
|
148
|
+
this.lockCurrent()
|
|
149
|
+
this.currentLine = styler ? styler(parts[i]) : parts[i]
|
|
150
|
+
}
|
|
151
|
+
if (this.outputLines.length > 1000) this.outputLines = this.outputLines.slice(-this.contentRows * 4)
|
|
152
|
+
this.scheduleRender()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Append discrete pre-formatted lines (each becomes its own row). Locks any
|
|
157
|
+
* in-progress streaming line first so token streams don't bleed into a
|
|
158
|
+
* following tool_use marker.
|
|
159
|
+
*/
|
|
160
|
+
private appendLines(lines: string[]) {
|
|
161
|
+
if (lines.length === 0) return
|
|
162
|
+
this.lockCurrent()
|
|
163
|
+
for (const line of lines) {
|
|
164
|
+
this.outputLines.push(line)
|
|
165
|
+
this.chatLines.push(stripAnsi(line))
|
|
166
|
+
}
|
|
167
|
+
if (this.outputLines.length > 1000) this.outputLines = this.outputLines.slice(-this.contentRows * 4)
|
|
168
|
+
this.scheduleRender()
|
|
169
|
+
}
|
|
170
|
+
|
|
105
171
|
private render() {
|
|
106
|
-
const { rows,
|
|
107
|
-
const outputWidth = cols - sidebarWidth - 1
|
|
172
|
+
const { rows, sidebarWidth, outputWidth, contentRows } = this
|
|
108
173
|
let buf = ""
|
|
109
174
|
|
|
110
175
|
buf += clearScreen()
|
|
111
176
|
buf += moveTo(1, 1)
|
|
112
177
|
|
|
113
|
-
// Top border
|
|
114
178
|
buf += "╭" + "─".repeat(sidebarWidth - 2) + "┬" + "─".repeat(outputWidth) + "╮"
|
|
115
179
|
|
|
116
|
-
// Sidebar header
|
|
117
180
|
buf += moveTo(2, 1)
|
|
118
181
|
buf += "│ " + bold("Migration Steps") + " ".repeat(Math.max(0, sidebarWidth - 18)) + "│"
|
|
119
182
|
|
|
120
|
-
// Output panel header
|
|
121
183
|
const headerLabel = this.done ? "Done" : `${skillsPipeline[this.currentStep]?.name ?? ""} ${dim(`(${this.phase})`)}`
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
buf += " ".repeat(Math.max(0, outputWidth -
|
|
184
|
+
const styledHeader = phaseColor[this.phase](bold(headerLabel))
|
|
185
|
+
const headerVisible = stripAnsi(headerLabel).length
|
|
186
|
+
buf += " " + styledHeader + " ".repeat(Math.max(0, outputWidth - headerVisible - 1)) + "│"
|
|
125
187
|
|
|
126
|
-
// Separator
|
|
127
188
|
buf += moveTo(3, 1)
|
|
128
189
|
buf += "├" + "─".repeat(sidebarWidth - 2) + "┼" + "─".repeat(outputWidth) + "┤"
|
|
129
190
|
|
|
130
|
-
// Content rows
|
|
131
|
-
const contentRows = rows - 4 // top border + header + separator + bottom border
|
|
132
191
|
for (let row = 0; row < contentRows; row++) {
|
|
133
192
|
buf += moveTo(row + 4, 1)
|
|
134
193
|
|
|
135
|
-
// Sidebar column
|
|
136
194
|
const skillIndex = row
|
|
137
195
|
if (skillIndex < skillsPipeline.length) {
|
|
138
196
|
const skill = skillsPipeline[skillIndex]
|
|
@@ -142,10 +200,9 @@ class PipelineTUI {
|
|
|
142
200
|
const colorFn = stateColor[state]
|
|
143
201
|
const label = `${icon} ${skill.name}`
|
|
144
202
|
const styled = isActive ? bold(colorFn(label)) : colorFn(label)
|
|
145
|
-
const rawLen =
|
|
203
|
+
const rawLen = stripAnsi(styled).length
|
|
146
204
|
buf += "│ " + styled + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│"
|
|
147
205
|
} else if (skillIndex === skillsPipeline.length + 1) {
|
|
148
|
-
// Status line
|
|
149
206
|
const completedCount = this.states.filter((s) => s === "success").length
|
|
150
207
|
const failedCount = this.states.filter((s) => s === "failed").length
|
|
151
208
|
const status = this.done
|
|
@@ -153,248 +210,74 @@ class PipelineTUI {
|
|
|
153
210
|
? red(`${failedCount} failure(s)`)
|
|
154
211
|
: green("All complete!")
|
|
155
212
|
: dim(`${completedCount}/${skillsPipeline.length} done`)
|
|
156
|
-
const rawLen =
|
|
213
|
+
const rawLen = stripAnsi(status).length
|
|
157
214
|
buf += "│ " + status + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│"
|
|
158
215
|
} else {
|
|
159
216
|
buf += "│" + " ".repeat(sidebarWidth - 2) + "│"
|
|
160
217
|
}
|
|
161
218
|
|
|
162
|
-
|
|
163
|
-
const lineIndex =
|
|
164
|
-
const
|
|
165
|
-
const truncated =
|
|
166
|
-
const visibleLen =
|
|
219
|
+
const visibleCount = this.outputLines.length + (this.currentLine.length > 0 ? 1 : 0)
|
|
220
|
+
const lineIndex = visibleCount - contentRows + row
|
|
221
|
+
const rawLine = lineIndex < 0 ? "" : lineIndex < this.outputLines.length ? (this.outputLines[lineIndex] ?? "") : this.currentLine
|
|
222
|
+
const truncated = truncateVisible(rawLine, outputWidth - 2)
|
|
223
|
+
const visibleLen = stripAnsi(truncated).length
|
|
167
224
|
buf += " " + truncated + " ".repeat(Math.max(0, outputWidth - visibleLen - 1)) + "│"
|
|
168
225
|
}
|
|
169
226
|
|
|
170
|
-
// Bottom border
|
|
171
227
|
buf += moveTo(rows, 1)
|
|
172
228
|
buf += "╰" + "─".repeat(sidebarWidth - 2) + "┴" + "─".repeat(outputWidth) + "╯"
|
|
173
229
|
|
|
174
230
|
this.write(buf)
|
|
175
231
|
}
|
|
176
232
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
while (i < s.length && visible < maxWidth) {
|
|
188
|
-
if (s[i] === "\x1b" && s[i + 1] === "[") {
|
|
189
|
-
const end = s.indexOf("m", i)
|
|
190
|
-
if (end !== -1) {
|
|
191
|
-
i = end + 1
|
|
233
|
+
private async runAgent(prompt: string): Promise<boolean> {
|
|
234
|
+
this.abortController = new AbortController()
|
|
235
|
+
let sawError = false
|
|
236
|
+
let resultOk: boolean | null = null
|
|
237
|
+
try {
|
|
238
|
+
for await (const event of this.agent.run({ prompt, cwd: this.gameDir, signal: this.abortController.signal })) {
|
|
239
|
+
if (event.type === "error") sawError = true
|
|
240
|
+
if (event.type === "result") resultOk = event.ok
|
|
241
|
+
if (event.type === "text") {
|
|
242
|
+
this.appendText(event.text)
|
|
192
243
|
continue
|
|
193
244
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
// Include any trailing ANSI sequences (like reset codes) right after the cut point
|
|
199
|
-
while (i < s.length && s[i] === "\x1b" && s[i + 1] === "[") {
|
|
200
|
-
const end = s.indexOf("m", i)
|
|
201
|
-
if (end !== -1) {
|
|
202
|
-
i = end + 1
|
|
203
|
-
} else break
|
|
204
|
-
}
|
|
205
|
-
return s.slice(0, i)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Parse stream-json output from claude, or plain text from other agents */
|
|
209
|
-
private appendOutput(text: string) {
|
|
210
|
-
const rawLines = text.split("\n").filter((l) => l.trim())
|
|
211
|
-
for (const line of rawLines) {
|
|
212
|
-
const parsed = this.parseStreamLine(line)
|
|
213
|
-
if (parsed) {
|
|
214
|
-
for (const displayLine of parsed.split("\n")) {
|
|
215
|
-
this.outputLines.push(displayLine)
|
|
216
|
-
this.chatLines.push(this.stripAnsi(displayLine))
|
|
245
|
+
if (event.type === "thinking") {
|
|
246
|
+
this.appendText(event.text, gray)
|
|
247
|
+
continue
|
|
217
248
|
}
|
|
249
|
+
const lines = formatDiscreteEvent(event)
|
|
250
|
+
if (lines.length > 0) this.appendLines(lines)
|
|
218
251
|
}
|
|
252
|
+
} catch (e: any) {
|
|
253
|
+
this.appendLines([red(`Error: ${e.message ?? e}`)])
|
|
254
|
+
return false
|
|
255
|
+
} finally {
|
|
256
|
+
this.abortController = null
|
|
219
257
|
}
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
this.render()
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Format a tool use block into a display line */
|
|
228
|
-
private formatToolUse(t: any): string {
|
|
229
|
-
const name = t.name ?? "unknown"
|
|
230
|
-
if (name === "Edit") return `${yellow("Edit")} ${t.input?.file_path ?? ""}`
|
|
231
|
-
if (name === "Write") return `${yellow("Write")} ${t.input?.file_path ?? ""}`
|
|
232
|
-
if (name === "Read") return `${yellow("Read")} ${t.input?.file_path ?? ""}`
|
|
233
|
-
if (name === "Bash") return `${yellow("Bash")} ${t.input?.command ?? ""}`
|
|
234
|
-
if (name === "Glob") return `${yellow("Glob")} ${t.input?.pattern ?? ""}`
|
|
235
|
-
if (name === "Grep") return `${yellow("Grep")} ${t.input?.pattern ?? ""}`
|
|
236
|
-
return `${yellow(name)}`
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Extract human-readable text from a stream-json line, or pass through raw text */
|
|
240
|
-
private parseStreamLine(line: string): string | null {
|
|
241
|
-
try {
|
|
242
|
-
const obj = JSON.parse(line)
|
|
243
|
-
|
|
244
|
-
// Assistant messages - may contain text, tool_use, thinking, or a mix
|
|
245
|
-
if (obj.type === "assistant" && obj.message?.content) {
|
|
246
|
-
const content = obj.message.content as any[]
|
|
247
|
-
const parts: string[] = []
|
|
248
|
-
|
|
249
|
-
const texts = content.filter((c) => c.type === "text").map((c) => c.text)
|
|
250
|
-
if (texts.length > 0) parts.push(texts.join(""))
|
|
251
|
-
|
|
252
|
-
const toolUses = content.filter((c) => c.type === "tool_use")
|
|
253
|
-
if (toolUses.length > 0) parts.push(toolUses.map((t) => this.formatToolUse(t)).join("\n"))
|
|
254
|
-
|
|
255
|
-
// Thinking blocks - skip silently, they're just internal reasoning
|
|
256
|
-
if (parts.length > 0) return parts.join("\n")
|
|
257
|
-
if (content.some((c) => c.type === "thinking")) return null
|
|
258
|
-
return null
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Content block delta (streaming text chunks)
|
|
262
|
-
if (obj.type === "content_block_delta" && obj.delta?.text) {
|
|
263
|
-
return obj.delta.text
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Standalone tool use event
|
|
267
|
-
if (obj.type === "tool_use") {
|
|
268
|
-
return this.formatToolUse(obj)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// User messages (tool results coming back)
|
|
272
|
-
if (obj.type === "user") {
|
|
273
|
-
const content = obj.message?.content as any[] | undefined
|
|
274
|
-
if (!content) return null
|
|
275
|
-
// Show file create/update results
|
|
276
|
-
const result = obj.tool_use_result
|
|
277
|
-
if (result?.type === "create") return dim(`Created ${result.filePath}`)
|
|
278
|
-
if (result?.type === "update") return dim(`Updated ${result.filePath}`)
|
|
279
|
-
return null
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Rate limit events - suppress
|
|
283
|
-
if (obj.type === "rate_limit_event") return null
|
|
284
|
-
|
|
285
|
-
// Tool results
|
|
286
|
-
if (obj.type === "result") {
|
|
287
|
-
const cost = obj.cost_usd ? ` ($${obj.cost_usd.toFixed(4)})` : ""
|
|
288
|
-
return green(`Done${cost}`)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// System messages
|
|
292
|
-
if (obj.type === "system") {
|
|
293
|
-
if (obj.subtype === "init") return dim(`Session started`)
|
|
294
|
-
if (obj.subtype === "hook_started") return null
|
|
295
|
-
if (obj.subtype === "hook_response") return null
|
|
296
|
-
if (obj.subtype === "task_started") return dim(`Subagent: ${obj.description ?? "started"}`)
|
|
297
|
-
if (obj.subtype === "task_progress")
|
|
298
|
-
return dim(` ${obj.last_tool_name ?? "working"}${obj.description ? ": " + obj.description : ""}`)
|
|
299
|
-
if (obj.subtype === "task_notification")
|
|
300
|
-
return obj.status === "completed"
|
|
301
|
-
? dim(`Subagent done: ${obj.summary ?? ""}`)
|
|
302
|
-
: dim(`Subagent ${obj.status ?? "update"}: ${obj.summary ?? ""}`)
|
|
303
|
-
return dim(`[system:${obj.subtype}]`)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Show unrecognized types so we can add support
|
|
307
|
-
return dim(`[${obj.type}${obj.subtype ? ":" + obj.subtype : ""}]`)
|
|
308
|
-
} catch {
|
|
309
|
-
// Not JSON - show as-is
|
|
310
|
-
return line
|
|
311
|
-
}
|
|
258
|
+
this.lockCurrent()
|
|
259
|
+
if (resultOk != null) return resultOk
|
|
260
|
+
return !sawError
|
|
312
261
|
}
|
|
313
262
|
|
|
314
|
-
/** Run a shell command, capturing output into the TUI panel */
|
|
315
263
|
private runCmd(cmd: string): { success: boolean; output: string } {
|
|
316
264
|
try {
|
|
317
265
|
const output = execSync(cmd, { cwd: this.gameDir, encoding: "utf-8", stdio: "pipe" })
|
|
318
|
-
if (output.trim()) this.
|
|
266
|
+
if (output.trim()) this.appendLines(output.trim().split("\n"))
|
|
319
267
|
return { success: true, output }
|
|
320
268
|
} catch (e: any) {
|
|
321
269
|
const output = (e.stdout || "") + (e.stderr || "") || e.message
|
|
322
|
-
if (output.trim()) this.
|
|
270
|
+
if (output.trim()) this.appendLines(output.trim().split("\n"))
|
|
323
271
|
return { success: false, output }
|
|
324
272
|
}
|
|
325
273
|
}
|
|
326
274
|
|
|
327
|
-
/** Spawn agent and stream output */
|
|
328
|
-
private runAgent(prompt: string): Promise<boolean> {
|
|
329
|
-
return new Promise((resolve) => {
|
|
330
|
-
const { cmd, args } = buildAgentCmd(this.agent, prompt)
|
|
331
|
-
const env: Record<string, string | undefined> = { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }
|
|
332
|
-
// Remove env vars that prevent nested claude sessions
|
|
333
|
-
delete env.CLAUDECODE
|
|
334
|
-
const proc = spawn(cmd, args, {
|
|
335
|
-
cwd: this.gameDir,
|
|
336
|
-
env,
|
|
337
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
this.activeProc = proc
|
|
341
|
-
let resolved = false
|
|
342
|
-
let gotResult = false
|
|
343
|
-
const debugLog = path.join(this.gameDir, "pipeline-debug.log")
|
|
344
|
-
|
|
345
|
-
const finish = (success: boolean) => {
|
|
346
|
-
if (resolved) return
|
|
347
|
-
resolved = true
|
|
348
|
-
this.activeProc = null
|
|
349
|
-
resolve(success)
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
proc.stdout?.on("data", (data: Buffer) => {
|
|
353
|
-
const text = data.toString()
|
|
354
|
-
fs.appendFileSync(debugLog, text)
|
|
355
|
-
this.appendOutput(text)
|
|
356
|
-
|
|
357
|
-
// Detect the result event in stream-json - the agent is done
|
|
358
|
-
if (!gotResult) {
|
|
359
|
-
for (const line of text.split("\n")) {
|
|
360
|
-
try {
|
|
361
|
-
const obj = JSON.parse(line)
|
|
362
|
-
if (obj.type === "result") {
|
|
363
|
-
gotResult = true
|
|
364
|
-
// Give the process a moment to exit cleanly, then force-resolve
|
|
365
|
-
setTimeout(() => {
|
|
366
|
-
if (!resolved) {
|
|
367
|
-
proc.kill()
|
|
368
|
-
finish(true)
|
|
369
|
-
}
|
|
370
|
-
}, 3000)
|
|
371
|
-
}
|
|
372
|
-
} catch {}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
})
|
|
376
|
-
proc.stderr?.on("data", (data: Buffer) => {
|
|
377
|
-
fs.appendFileSync(debugLog, `[stderr] ${data.toString()}`)
|
|
378
|
-
this.appendOutput(data.toString())
|
|
379
|
-
})
|
|
380
|
-
|
|
381
|
-
proc.on("close", (code) => {
|
|
382
|
-
fs.appendFileSync(debugLog, `\n[close] code=${code}\n`)
|
|
383
|
-
finish(gotResult || code === 0)
|
|
384
|
-
})
|
|
385
|
-
proc.on("error", (err) => {
|
|
386
|
-
this.appendOutput(`Error: ${err.message}`)
|
|
387
|
-
finish(false)
|
|
388
|
-
})
|
|
389
|
-
})
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** Run one pipeline step */
|
|
393
275
|
private async runStep(stepIndex: number): Promise<boolean> {
|
|
394
276
|
const skill = skillsPipeline[stepIndex]
|
|
395
277
|
this.states[stepIndex] = "running"
|
|
396
278
|
this.currentStep = stepIndex
|
|
397
279
|
this.outputLines = []
|
|
280
|
+
this.currentLine = ""
|
|
398
281
|
this.chatLines = []
|
|
399
282
|
this.phase = "agent"
|
|
400
283
|
this.render()
|
|
@@ -403,54 +286,45 @@ class PipelineTUI {
|
|
|
403
286
|
try {
|
|
404
287
|
prompt = await buildPrompt(skill.name, this.gameDir, this.repoContext)
|
|
405
288
|
} catch (e: any) {
|
|
406
|
-
this.
|
|
289
|
+
this.appendLines([red(`Failed to fetch instructions: ${e.message}`)])
|
|
407
290
|
this.writeSkillLog(skill.name)
|
|
408
291
|
this.states[stepIndex] = "failed"
|
|
409
|
-
this.render()
|
|
410
292
|
return false
|
|
411
293
|
}
|
|
412
294
|
|
|
413
295
|
let success = await this.runAgent(prompt)
|
|
414
|
-
|
|
415
296
|
if (!success) {
|
|
416
|
-
this.
|
|
297
|
+
this.appendLines(["", dim("--- Retrying ---")])
|
|
417
298
|
success = await this.runAgent(prompt)
|
|
418
299
|
if (!success) {
|
|
419
300
|
this.writeSkillLog(skill.name)
|
|
420
301
|
this.states[stepIndex] = "failed"
|
|
421
|
-
this.render()
|
|
422
302
|
return false
|
|
423
303
|
}
|
|
424
304
|
}
|
|
425
305
|
|
|
426
|
-
// Verify build
|
|
427
306
|
this.phase = "build"
|
|
428
307
|
this.render()
|
|
429
|
-
this.
|
|
308
|
+
this.appendLines([dim("Verifying build...")])
|
|
430
309
|
const buildResult = this.runCmd("npx vite build")
|
|
431
310
|
if (!buildResult.success) {
|
|
432
|
-
this.
|
|
311
|
+
this.appendLines([dim("Asking agent to fix...")])
|
|
433
312
|
const fixPrompt = `The vite build failed after the "${skill.name}" step. Fix the build errors:\n\n${buildResult.output}`
|
|
434
313
|
await this.runAgent(fixPrompt)
|
|
435
|
-
|
|
436
314
|
const retry = this.runCmd("npx vite build")
|
|
437
315
|
if (!retry.success) {
|
|
438
316
|
this.writeSkillLog(skill.name)
|
|
439
317
|
this.states[stepIndex] = "failed"
|
|
440
|
-
this.render()
|
|
441
318
|
return false
|
|
442
319
|
}
|
|
443
320
|
}
|
|
444
321
|
|
|
445
|
-
// Commit
|
|
446
322
|
this.phase = "commit"
|
|
447
323
|
this.render()
|
|
448
324
|
this.runCmd("git add -A")
|
|
449
325
|
const commitResult = this.runCmd(`git commit -m "step: ${skill.name}"`)
|
|
450
|
-
|
|
451
|
-
else this.appendOutput("No changes to commit.")
|
|
326
|
+
this.appendLines([dim(commitResult.success ? "Committed." : "No changes to commit.")])
|
|
452
327
|
|
|
453
|
-
// Write chat log for this skill
|
|
454
328
|
this.writeSkillLog(skill.name)
|
|
455
329
|
|
|
456
330
|
this.states[stepIndex] = "success"
|
|
@@ -458,22 +332,18 @@ class PipelineTUI {
|
|
|
458
332
|
return true
|
|
459
333
|
}
|
|
460
334
|
|
|
461
|
-
/** Writes the collected chat lines for a skill to a log file */
|
|
462
335
|
private writeSkillLog(skillName: string) {
|
|
463
336
|
const logPath = path.join(this.logsDir, `${skillName}.txt`)
|
|
464
337
|
fs.writeFileSync(logPath, this.chatLines.join("\n") + "\n")
|
|
465
338
|
}
|
|
466
339
|
|
|
467
|
-
/** Set up keyboard handling (Ctrl+C to quit) */
|
|
468
340
|
private setupInput() {
|
|
469
|
-
if (process.stdin.isTTY)
|
|
470
|
-
process.stdin.setRawMode(true)
|
|
471
|
-
}
|
|
341
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
472
342
|
process.stdin.resume()
|
|
473
343
|
process.stdin.on("data", (data: Buffer) => {
|
|
474
344
|
// Ctrl+C
|
|
475
345
|
if (data[0] === 3) {
|
|
476
|
-
|
|
346
|
+
this.abortController?.abort()
|
|
477
347
|
this.cleanup()
|
|
478
348
|
process.exit(0)
|
|
479
349
|
}
|
|
@@ -482,25 +352,21 @@ class PipelineTUI {
|
|
|
482
352
|
|
|
483
353
|
private cleanup() {
|
|
484
354
|
this.write(showCursor())
|
|
485
|
-
if (process.stdin.isTTY)
|
|
486
|
-
process.stdin.setRawMode(false)
|
|
487
|
-
}
|
|
355
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
488
356
|
process.stdin.pause()
|
|
489
357
|
}
|
|
490
358
|
|
|
491
|
-
/** Run the full pipeline */
|
|
492
359
|
async run() {
|
|
493
360
|
this.write(hideCursor())
|
|
494
361
|
this.setupInput()
|
|
495
362
|
this.render()
|
|
496
363
|
|
|
497
|
-
// Redraw on terminal resize
|
|
498
364
|
process.stdout.on("resize", () => this.render())
|
|
499
365
|
|
|
500
366
|
for (let i = 0; i < skillsPipeline.length; i++) {
|
|
501
367
|
const ok = await this.runStep(i)
|
|
502
368
|
if (!ok) {
|
|
503
|
-
this.
|
|
369
|
+
this.appendLines(["", red("Pipeline stopped due to failure.")])
|
|
504
370
|
break
|
|
505
371
|
}
|
|
506
372
|
}
|
|
@@ -512,8 +378,32 @@ class PipelineTUI {
|
|
|
512
378
|
}
|
|
513
379
|
}
|
|
514
380
|
|
|
515
|
-
/**
|
|
516
|
-
|
|
381
|
+
/** Truncate a string to a visible width, preserving ANSI codes. */
|
|
382
|
+
const truncateVisible = (s: string, maxWidth: number): string => {
|
|
383
|
+
let visible = 0
|
|
384
|
+
let i = 0
|
|
385
|
+
while (i < s.length && visible < maxWidth) {
|
|
386
|
+
if (s[i] === "\x1b" && s[i + 1] === "[") {
|
|
387
|
+
const end = s.indexOf("m", i)
|
|
388
|
+
if (end !== -1) {
|
|
389
|
+
i = end + 1
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
visible++
|
|
394
|
+
i++
|
|
395
|
+
}
|
|
396
|
+
while (i < s.length && s[i] === "\x1b" && s[i + 1] === "[") {
|
|
397
|
+
const end = s.indexOf("m", i)
|
|
398
|
+
if (end !== -1) i = end + 1
|
|
399
|
+
else break
|
|
400
|
+
}
|
|
401
|
+
return s.slice(0, i)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const runPipelineTUI = async (agentName: string, gameDir: string, repoContext: string): Promise<void> => {
|
|
405
|
+
const agent = getAgent(agentName)
|
|
406
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`)
|
|
517
407
|
const tui = new PipelineTUI(agent, gameDir, repoContext)
|
|
518
408
|
await tui.run()
|
|
519
409
|
}
|