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