@flumecode/runner 0.23.0 → 0.24.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/README.md +19 -7
- package/dist/cli.js +1243 -617
- package/dist/mcp-stdio.js +51 -12
- package/package.json +1 -1
- package/skills-plugin/skills/implement-plan/SKILL.md +1 -0
- package/skills-plugin/skills/preview-ui/SKILL.md +67 -108
- package/skills-plugin/skills/request-to-plan/SKILL.md +17 -12
- package/skills-plugin/skills/revise-implementation/SKILL.md +4 -3
- package/skills-plugin/skills/setup-preview/SKILL.md +166 -0
package/dist/cli.js
CHANGED
|
@@ -62,29 +62,27 @@ async function get(cfg, path) {
|
|
|
62
62
|
if (!res.ok) throw new Error(await safeText2(res));
|
|
63
63
|
return res.json();
|
|
64
64
|
}
|
|
65
|
-
async function post(cfg, path,
|
|
65
|
+
async function post(cfg, path, body2) {
|
|
66
66
|
const res = await fetch(cfg.serverUrl + path, {
|
|
67
67
|
method: "POST",
|
|
68
68
|
headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
|
|
69
|
-
body: JSON.stringify(
|
|
69
|
+
body: JSON.stringify(body2)
|
|
70
70
|
});
|
|
71
71
|
if (!res.ok) throw new Error(await safeText2(res));
|
|
72
72
|
return res.json();
|
|
73
73
|
}
|
|
74
|
-
var listRepositories, listAgents,
|
|
74
|
+
var listRepositories, listAgents, getRequest, createRequest, addRequestComment, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
|
|
75
75
|
var init_tui_api = __esm({
|
|
76
76
|
"src/tui-api.ts"() {
|
|
77
77
|
"use strict";
|
|
78
78
|
init_version();
|
|
79
79
|
listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
|
|
80
80
|
listAgents = (cfg) => get(cfg, "/api/cli/agents").then((r) => r.agents);
|
|
81
|
-
listRequests = (cfg, repoId) => get(cfg, `/api/cli/repositories/${repoId}/requests`).then(
|
|
82
|
-
(r) => r.requests
|
|
83
|
-
);
|
|
84
81
|
getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
|
|
85
82
|
createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
|
|
83
|
+
addRequestComment = (cfg, requestId, body2) => post(cfg, `/api/cli/requests/${requestId}/comments`, { body: body2 });
|
|
86
84
|
answerWidgets = (cfg, messageId, payload) => post(cfg, `/api/cli/messages/${messageId}/answer`, payload);
|
|
87
|
-
acceptPlan = (cfg, messageId,
|
|
85
|
+
acceptPlan = (cfg, messageId, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target: { kind: "new" }, mergeBranch });
|
|
88
86
|
startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
|
|
89
87
|
listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
|
|
90
88
|
listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
|
|
@@ -121,17 +119,18 @@ var init_usePoll = __esm({
|
|
|
121
119
|
});
|
|
122
120
|
|
|
123
121
|
// src/tui/StatusBar.tsx
|
|
124
|
-
import {
|
|
125
|
-
import {
|
|
122
|
+
import { Text } from "ink";
|
|
123
|
+
import { jsxs } from "react/jsx-runtime";
|
|
126
124
|
function StatusBar({ jobs, runners }) {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
return /* @__PURE__ */
|
|
130
|
-
"
|
|
131
|
-
|
|
132
|
-
"
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
const running = jobs?.filter((j) => j.status === "running").length ?? 0;
|
|
126
|
+
const online = runners?.filter((r) => r.status === "online").length ?? 0;
|
|
127
|
+
return /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
128
|
+
"jobs ",
|
|
129
|
+
running,
|
|
130
|
+
" running \xB7 runners ",
|
|
131
|
+
online,
|
|
132
|
+
" online"
|
|
133
|
+
] });
|
|
135
134
|
}
|
|
136
135
|
var init_StatusBar = __esm({
|
|
137
136
|
"src/tui/StatusBar.tsx"() {
|
|
@@ -141,8 +140,8 @@ var init_StatusBar = __esm({
|
|
|
141
140
|
|
|
142
141
|
// src/tui/RepoListScreen.tsx
|
|
143
142
|
import { useState as useState2 } from "react";
|
|
144
|
-
import { Box
|
|
145
|
-
import { jsx
|
|
143
|
+
import { Box, Text as Text2, useInput } from "ink";
|
|
144
|
+
import { jsx, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
146
145
|
function RepoListScreen({ config, onSelect }) {
|
|
147
146
|
const repos = usePoll(() => listRepositories(config), 1e4);
|
|
148
147
|
const [cursor, setCursor] = useState2(0);
|
|
@@ -155,10 +154,10 @@ function RepoListScreen({ config, onSelect }) {
|
|
|
155
154
|
if (repo) onSelect(repo);
|
|
156
155
|
}
|
|
157
156
|
});
|
|
158
|
-
if (!repos) return /* @__PURE__ */
|
|
159
|
-
if (repos.length === 0) return /* @__PURE__ */
|
|
160
|
-
return /* @__PURE__ */ jsxs2(
|
|
161
|
-
/* @__PURE__ */
|
|
157
|
+
if (!repos) return /* @__PURE__ */ jsx(Text2, { children: "Loading repositories\u2026" });
|
|
158
|
+
if (repos.length === 0) return /* @__PURE__ */ jsx(Text2, { children: "No repositories found." });
|
|
159
|
+
return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
|
|
160
|
+
/* @__PURE__ */ jsx(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
|
|
162
161
|
repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
|
|
163
162
|
i === cursor ? "\u25B6 " : " ",
|
|
164
163
|
repo.fullName
|
|
@@ -173,188 +172,241 @@ var init_RepoListScreen = __esm({
|
|
|
173
172
|
}
|
|
174
173
|
});
|
|
175
174
|
|
|
176
|
-
// src/tui/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (key.escape) {
|
|
185
|
-
onBack();
|
|
186
|
-
return;
|
|
175
|
+
// src/tui/markdown.ts
|
|
176
|
+
function parseSpans(text) {
|
|
177
|
+
const spans = [];
|
|
178
|
+
let plain = "";
|
|
179
|
+
const flush = () => {
|
|
180
|
+
if (plain) {
|
|
181
|
+
spans.push({ text: plain });
|
|
182
|
+
plain = "";
|
|
187
183
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
184
|
+
};
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
const ch = text[i];
|
|
188
|
+
if (ch === "`") {
|
|
189
|
+
const end = text.indexOf("`", i + 1);
|
|
190
|
+
if (end > i) {
|
|
191
|
+
flush();
|
|
192
|
+
spans.push({ text: text.slice(i + 1, end), code: true });
|
|
193
|
+
i = end + 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
"
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
init_usePoll();
|
|
218
|
-
init_tui_api();
|
|
197
|
+
if (ch === "*" && text[i + 1] === "*") {
|
|
198
|
+
const end = text.indexOf("**", i + 2);
|
|
199
|
+
if (end > i + 1) {
|
|
200
|
+
flush();
|
|
201
|
+
spans.push({ text: text.slice(i + 2, end), bold: true });
|
|
202
|
+
i = end + 2;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (ch === "*" && text[i + 1] !== void 0 && text[i + 1] !== " " && text[i + 1] !== "*") {
|
|
207
|
+
const end = text.indexOf("*", i + 1);
|
|
208
|
+
if (end > i + 1) {
|
|
209
|
+
flush();
|
|
210
|
+
spans.push({ text: text.slice(i + 1, end), italic: true });
|
|
211
|
+
i = end + 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
plain += ch;
|
|
216
|
+
i++;
|
|
219
217
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const [agentCursor, setAgentCursor] = useState4(0);
|
|
233
|
-
const [branch, setBranch] = useState4("");
|
|
234
|
-
const [error, setError] = useState4(null);
|
|
235
|
-
const [submitting, setSubmitting] = useState4(false);
|
|
236
|
-
useInput3((_input, key) => {
|
|
237
|
-
if (key.escape) {
|
|
238
|
-
onBack();
|
|
239
|
-
return;
|
|
218
|
+
flush();
|
|
219
|
+
return spans;
|
|
220
|
+
}
|
|
221
|
+
function parseBlocks(src) {
|
|
222
|
+
const blocks = [];
|
|
223
|
+
const lines2 = src.split("\n");
|
|
224
|
+
let para = [];
|
|
225
|
+
let code = null;
|
|
226
|
+
const flushPara = () => {
|
|
227
|
+
if (para.length) {
|
|
228
|
+
blocks.push({ t: "p", spans: parseSpans(para.join(" ")) });
|
|
229
|
+
para = [];
|
|
240
230
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
};
|
|
232
|
+
for (const raw of lines2) {
|
|
233
|
+
if (code) {
|
|
234
|
+
if (raw.trimEnd() === "```") {
|
|
235
|
+
blocks.push({ t: "code", ...code.lang ? { lang: code.lang } : {}, lines: code.lines });
|
|
236
|
+
code = null;
|
|
237
|
+
} else {
|
|
238
|
+
code.lines.push(raw);
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
244
241
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
242
|
+
const line = raw.trimEnd();
|
|
243
|
+
const trimmed = line.trim();
|
|
244
|
+
if (trimmed.startsWith("```")) {
|
|
245
|
+
flushPara();
|
|
246
|
+
const lang = trimmed.slice(3).trim();
|
|
247
|
+
code = { ...lang ? { lang } : {}, lines: [] };
|
|
248
|
+
continue;
|
|
248
249
|
}
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (key.tab) setField("branch");
|
|
250
|
+
if (trimmed === "") {
|
|
251
|
+
flushPara();
|
|
252
|
+
continue;
|
|
253
253
|
}
|
|
254
|
-
|
|
255
|
-
|
|
254
|
+
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
|
255
|
+
if (h && h[1] && h[2] !== void 0) {
|
|
256
|
+
flushPara();
|
|
257
|
+
blocks.push({ t: "h", level: h[1].length, spans: parseSpans(h[2]) });
|
|
258
|
+
continue;
|
|
256
259
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
260
|
+
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
|
|
261
|
+
flushPara();
|
|
262
|
+
blocks.push({ t: "rule" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const indent = line.length - line.trimStart().length;
|
|
266
|
+
const li = /^[-*]\s+(.*)$/.exec(trimmed);
|
|
267
|
+
if (li && li[1] !== void 0) {
|
|
268
|
+
flushPara();
|
|
269
|
+
blocks.push({ t: "li", depth: indent >= 2 ? 1 : 0, spans: parseSpans(li[1]) });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const ol = /^(\d+)[.)]\s+(.*)$/.exec(trimmed);
|
|
273
|
+
if (ol && ol[1] && ol[2] !== void 0) {
|
|
274
|
+
flushPara();
|
|
275
|
+
blocks.push({
|
|
276
|
+
t: "li",
|
|
277
|
+
depth: indent >= 2 ? 1 : 0,
|
|
278
|
+
ordered: Number(ol[1]),
|
|
279
|
+
spans: parseSpans(ol[2])
|
|
269
280
|
});
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (trimmed.startsWith("> ") || trimmed === ">") {
|
|
284
|
+
flushPara();
|
|
285
|
+
blocks.push({ t: "quote", spans: parseSpans(trimmed.replace(/^>\s?/, "")) });
|
|
286
|
+
continue;
|
|
274
287
|
}
|
|
288
|
+
para.push(trimmed);
|
|
275
289
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
] }
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
290
|
+
if (code)
|
|
291
|
+
blocks.push({ t: "code", ...code.lang ? { lang: code.lang } : {}, lines: code.lines });
|
|
292
|
+
flushPara();
|
|
293
|
+
return blocks;
|
|
294
|
+
}
|
|
295
|
+
function parseMarkdown(src) {
|
|
296
|
+
try {
|
|
297
|
+
return parseBlocks(src);
|
|
298
|
+
} catch {
|
|
299
|
+
return [{ t: "p", spans: [{ text: src }] }];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
var init_markdown = __esm({
|
|
303
|
+
"src/tui/markdown.ts"() {
|
|
304
|
+
"use strict";
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// src/tui/Markdown.tsx
|
|
309
|
+
import { Box as Box2, Text as Text3 } from "ink";
|
|
310
|
+
import { jsx as jsx2, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
311
|
+
function Markdown({ source, width }) {
|
|
312
|
+
const blocks = parseMarkdown(source);
|
|
313
|
+
return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: blocks.map((block, i) => renderBlock(block, i, blocks, width)) });
|
|
314
|
+
}
|
|
315
|
+
function spanNodes(spans) {
|
|
316
|
+
return spans.map((s, k) => /* @__PURE__ */ jsx2(Text3, { bold: s.bold, italic: s.italic, color: s.code ? "magenta" : void 0, children: s.text }, k));
|
|
317
|
+
}
|
|
318
|
+
function renderBlock(block, i, blocks, width) {
|
|
319
|
+
switch (block.t) {
|
|
320
|
+
case "h":
|
|
321
|
+
return /* @__PURE__ */ jsx2(Box2, { marginTop: i === 0 ? 0 : 1, children: /* @__PURE__ */ jsx2(Text3, { bold: true, underline: block.level !== 3, children: spanNodes(block.spans) }) }, i);
|
|
322
|
+
case "p":
|
|
323
|
+
return /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text3, { children: spanNodes(block.spans) }) }, i);
|
|
324
|
+
case "li": {
|
|
325
|
+
const marker = block.ordered ? `${block.ordered}.` : block.depth === 1 ? "\u25E6" : "\u2022";
|
|
326
|
+
const isLastOfRun = blocks[i + 1]?.t !== "li";
|
|
327
|
+
return /* @__PURE__ */ jsxs3(Box2, { marginBottom: isLastOfRun ? 1 : 0, children: [
|
|
328
|
+
/* @__PURE__ */ jsx2(Box2, { width: block.depth === 1 ? 4 : 2, flexShrink: 0, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: marker }) }),
|
|
329
|
+
/* @__PURE__ */ jsx2(Box2, { flexGrow: 1, children: /* @__PURE__ */ jsx2(Text3, { children: spanNodes(block.spans) }) })
|
|
330
|
+
] }, i);
|
|
331
|
+
}
|
|
332
|
+
case "code":
|
|
333
|
+
return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: block.lines.map((line, k) => (
|
|
334
|
+
// An empty string renders nothing in ink, so substitute a single space.
|
|
335
|
+
/* @__PURE__ */ jsx2(Text3, { dimColor: true, children: line || " " }, k)
|
|
336
|
+
)) }, i);
|
|
337
|
+
case "quote":
|
|
338
|
+
return /* @__PURE__ */ jsxs3(Box2, { marginBottom: 1, children: [
|
|
339
|
+
/* @__PURE__ */ jsx2(Box2, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: "\u258C" }) }),
|
|
340
|
+
/* @__PURE__ */ jsx2(Box2, { flexGrow: 1, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: spanNodes(block.spans) }) })
|
|
341
|
+
] }, i);
|
|
342
|
+
case "rule":
|
|
343
|
+
return /* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text3, { dimColor: true, children: "\u2500".repeat(Math.max(8, Math.min(width - 2, 60))) }) }, i);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
var init_Markdown = __esm({
|
|
347
|
+
"src/tui/Markdown.tsx"() {
|
|
348
|
+
"use strict";
|
|
349
|
+
init_markdown();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// src/tui/Spinner.tsx
|
|
354
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
355
|
+
import { Text as Text4 } from "ink";
|
|
356
|
+
import { jsxs as jsxs4 } from "react/jsx-runtime";
|
|
357
|
+
function Spinner({ label }) {
|
|
358
|
+
const [frame, setFrame] = useState3(0);
|
|
359
|
+
useEffect2(() => {
|
|
360
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 100);
|
|
361
|
+
return () => clearInterval(id);
|
|
362
|
+
}, []);
|
|
363
|
+
return /* @__PURE__ */ jsxs4(Text4, { color: "yellow", children: [
|
|
364
|
+
FRAMES[frame],
|
|
365
|
+
" ",
|
|
366
|
+
label
|
|
310
367
|
] });
|
|
311
368
|
}
|
|
312
|
-
var
|
|
313
|
-
|
|
369
|
+
var FRAMES;
|
|
370
|
+
var init_Spinner = __esm({
|
|
371
|
+
"src/tui/Spinner.tsx"() {
|
|
314
372
|
"use strict";
|
|
315
|
-
|
|
316
|
-
init_tui_api();
|
|
373
|
+
FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
317
374
|
}
|
|
318
375
|
});
|
|
319
376
|
|
|
320
377
|
// src/tui/WidgetAnswerScreen.tsx
|
|
321
|
-
import { useState as
|
|
322
|
-
import { Box as
|
|
323
|
-
import
|
|
324
|
-
import { jsx as
|
|
325
|
-
function
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const [error, setError] = useState5(null);
|
|
339
|
-
const [submitting, setSubmitting] = useState5(false);
|
|
340
|
-
const widget = message.widgets[widgetIndex];
|
|
341
|
-
function handleSingleInput(input, key, w) {
|
|
342
|
-
const opts2 = [...w.options, { id: "__other__", label: "Other" }];
|
|
378
|
+
import { useRef as useRef2, useState as useState4 } from "react";
|
|
379
|
+
import { Box as Box3, Text as Text5, useInput as useInput2 } from "ink";
|
|
380
|
+
import TextInput from "ink-text-input";
|
|
381
|
+
import { jsx as jsx3, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
382
|
+
function WidgetAnswerScreen({ config, message: message2, onDone }) {
|
|
383
|
+
const [widgetIndex, setWidgetIndex] = useState4(0);
|
|
384
|
+
const [cursor, setCursor] = useState4(0);
|
|
385
|
+
const [checked, setChecked] = useState4(/* @__PURE__ */ new Set());
|
|
386
|
+
const [customText, setCustomText] = useState4("");
|
|
387
|
+
const [showCustom, setShowCustom] = useState4(false);
|
|
388
|
+
const [error, setError] = useState4(null);
|
|
389
|
+
const [submitting, setSubmitting] = useState4(false);
|
|
390
|
+
const answersRef = useRef2([]);
|
|
391
|
+
const summaryRef = useRef2([]);
|
|
392
|
+
const widget = message2.widgets[widgetIndex];
|
|
393
|
+
function handleSingleInput(key, w) {
|
|
394
|
+
const opts2 = [...w.options, OTHER];
|
|
343
395
|
if (key.upArrow) {
|
|
396
|
+
setShowCustom(false);
|
|
344
397
|
setCursor((c) => Math.max(0, c - 1));
|
|
345
398
|
} else if (key.downArrow) {
|
|
399
|
+
setShowCustom(false);
|
|
346
400
|
setCursor((c) => Math.min(opts2.length - 1, c + 1));
|
|
347
401
|
} else if (key.return) {
|
|
402
|
+
if (showCustom) return;
|
|
348
403
|
const picked = opts2[cursor];
|
|
349
|
-
if (picked?.id ===
|
|
350
|
-
|
|
351
|
-
} else {
|
|
352
|
-
void advanceOrSubmit(w, picked?.id, void 0, void 0);
|
|
353
|
-
}
|
|
404
|
+
if (picked?.id === OTHER.id) setShowCustom(true);
|
|
405
|
+
else if (picked) void advanceOrSubmit(w, picked.id, void 0, void 0);
|
|
354
406
|
}
|
|
355
407
|
}
|
|
356
408
|
function handleMultiInput(input, key, w) {
|
|
357
|
-
const opts2 = [...w.options,
|
|
409
|
+
const opts2 = [...w.options, OTHER];
|
|
358
410
|
if (key.upArrow) {
|
|
359
411
|
setCursor((c) => Math.max(0, c - 1));
|
|
360
412
|
} else if (key.downArrow) {
|
|
@@ -362,7 +414,7 @@ function WidgetAnswerScreen({ config, message, onDone }) {
|
|
|
362
414
|
} else if (input === " ") {
|
|
363
415
|
const picked = opts2[cursor];
|
|
364
416
|
if (!picked) return;
|
|
365
|
-
if (picked.id ===
|
|
417
|
+
if (picked.id === OTHER.id) {
|
|
366
418
|
setShowCustom((s) => !s);
|
|
367
419
|
} else {
|
|
368
420
|
const next = new Set(checked);
|
|
@@ -374,24 +426,26 @@ function WidgetAnswerScreen({ config, message, onDone }) {
|
|
|
374
426
|
void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
|
|
375
427
|
}
|
|
376
428
|
}
|
|
377
|
-
|
|
378
|
-
if (!widget) return;
|
|
379
|
-
if (widget.type === "single_select")
|
|
380
|
-
|
|
381
|
-
} else {
|
|
382
|
-
handleMultiInput(input, key, widget);
|
|
383
|
-
}
|
|
429
|
+
useInput2((input, key) => {
|
|
430
|
+
if (!widget || submitting) return;
|
|
431
|
+
if (widget.type === "single_select") handleSingleInput(key, widget);
|
|
432
|
+
else handleMultiInput(input, key, widget);
|
|
384
433
|
});
|
|
434
|
+
const onCustomSubmit = (value) => {
|
|
435
|
+
if (!widget || widget.type !== "single_select" || submitting) return;
|
|
436
|
+
const text = value.trim();
|
|
437
|
+
if (!text) return;
|
|
438
|
+
void advanceOrSubmit(widget, void 0, void 0, text);
|
|
439
|
+
};
|
|
385
440
|
async function advanceOrSubmit(w, optionId, optionIds, custom) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (widgetIndex < message.widgets.length - 1) {
|
|
441
|
+
answersRef.current.push({
|
|
442
|
+
widgetId: w.id,
|
|
443
|
+
...optionId !== void 0 ? { optionId } : {},
|
|
444
|
+
...optionIds !== void 0 ? { optionIds } : {},
|
|
445
|
+
...custom ? { customText: custom } : {}
|
|
446
|
+
});
|
|
447
|
+
summaryRef.current.push(`${w.question} \u2192 ${answerLabel(w, optionId, optionIds, custom)}`);
|
|
448
|
+
if (widgetIndex < message2.widgets.length - 1) {
|
|
395
449
|
setWidgetIndex((i) => i + 1);
|
|
396
450
|
setCursor(0);
|
|
397
451
|
setChecked(/* @__PURE__ */ new Set());
|
|
@@ -402,177 +456,396 @@ function WidgetAnswerScreen({ config, message, onDone }) {
|
|
|
402
456
|
setSubmitting(true);
|
|
403
457
|
setError(null);
|
|
404
458
|
try {
|
|
405
|
-
await answerWidgets(config,
|
|
406
|
-
|
|
459
|
+
await answerWidgets(config, message2.id, {
|
|
460
|
+
requestId: message2.requestId,
|
|
461
|
+
answers: answersRef.current
|
|
462
|
+
});
|
|
463
|
+
onDone(summaryRef.current);
|
|
407
464
|
} catch (err) {
|
|
465
|
+
answersRef.current.pop();
|
|
466
|
+
summaryRef.current.pop();
|
|
408
467
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
409
468
|
setSubmitting(false);
|
|
410
469
|
}
|
|
411
470
|
}
|
|
412
|
-
if (!widget) return /* @__PURE__ */
|
|
471
|
+
if (!widget) return /* @__PURE__ */ jsx3(Text5, { children: "No widgets to answer." });
|
|
413
472
|
const isSingle = widget.type === "single_select";
|
|
414
|
-
const opts = [...widget.options,
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
473
|
+
const opts = [...widget.options, OTHER];
|
|
474
|
+
const total = opts.length;
|
|
475
|
+
const start2 = total <= WINDOW ? 0 : Math.min(Math.max(0, cursor - Math.floor(WINDOW / 2)), total - WINDOW);
|
|
476
|
+
const end = Math.min(total, start2 + WINDOW);
|
|
477
|
+
const moreAbove = start2;
|
|
478
|
+
const moreBelow = total - end;
|
|
479
|
+
return /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
|
|
480
|
+
/* @__PURE__ */ jsxs5(Box3, { children: [
|
|
481
|
+
/* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text5, { bold: true, children: "\u25CF" }) }),
|
|
482
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsxs5(Text5, { children: [
|
|
483
|
+
/* @__PURE__ */ jsx3(Text5, { bold: true, children: widget.question }),
|
|
484
|
+
/* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` (${widgetIndex + 1}/${message2.widgets.length})` })
|
|
485
|
+
] }) })
|
|
423
486
|
] }),
|
|
424
|
-
widget.body
|
|
425
|
-
/* @__PURE__ */
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
487
|
+
widget.body ? /* @__PURE__ */ jsx3(Box3, { marginTop: 1, marginLeft: 2, children: /* @__PURE__ */ jsx3(Markdown, { source: widget.body, width: 76 }) }) : null,
|
|
488
|
+
/* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", marginTop: 1, children: [
|
|
489
|
+
moreAbove > 0 && /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` \u2026 ${moreAbove} more above` }),
|
|
490
|
+
opts.slice(start2, end).map((opt, vi) => {
|
|
491
|
+
const i = start2 + vi;
|
|
492
|
+
const onCursor = i === cursor;
|
|
493
|
+
return /* @__PURE__ */ jsxs5(Box3, { children: [
|
|
494
|
+
/* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: onCursor ? /* @__PURE__ */ jsx3(Text5, { color: "cyan", bold: true, children: "\u276F " }) : /* @__PURE__ */ jsx3(Text5, { children: " " }) }),
|
|
495
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: onCursor ? "cyan" : void 0, children: [
|
|
496
|
+
!isSingle && (checked.has(opt.id) ? /* @__PURE__ */ jsx3(Text5, { color: "green", children: "\u25C9 " }) : /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: "\u25EF " })),
|
|
497
|
+
opt.label
|
|
498
|
+
] }) })
|
|
499
|
+
] }, opt.id);
|
|
500
|
+
}),
|
|
501
|
+
moreBelow > 0 && /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: ` \u2026 ${moreBelow} more below` })
|
|
430
502
|
] }),
|
|
431
|
-
|
|
503
|
+
showCustom && /* @__PURE__ */ jsxs5(Box3, { marginTop: 1, children: [
|
|
504
|
+
/* @__PURE__ */ jsx3(Box3, { width: 2, flexShrink: 0, children: /* @__PURE__ */ jsx3(Text5, { color: "cyan", bold: true, children: "\u203A" }) }),
|
|
505
|
+
/* @__PURE__ */ jsx3(
|
|
506
|
+
TextInput,
|
|
507
|
+
{
|
|
508
|
+
value: customText,
|
|
509
|
+
onChange: setCustomText,
|
|
510
|
+
focus: showCustom,
|
|
511
|
+
onSubmit: isSingle ? onCustomSubmit : void 0
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
] }),
|
|
515
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 move \xB7 enter select" : "\u2191\u2193 move \xB7 space toggle \xB7 enter confirm" }) }),
|
|
516
|
+
submitting && /* @__PURE__ */ jsx3(Spinner, { label: "submitting\u2026" }),
|
|
432
517
|
error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
|
|
433
518
|
"Error: ",
|
|
434
519
|
error
|
|
435
520
|
] })
|
|
436
521
|
] });
|
|
437
522
|
}
|
|
523
|
+
function answerLabel(w, optionId, optionIds, custom) {
|
|
524
|
+
const pickedIds = [...optionId ? [optionId] : [], ...optionIds ?? []];
|
|
525
|
+
const labels = pickedIds.map((id) => w.options.find((o) => o.id === id)?.label).filter((label) => label !== void 0);
|
|
526
|
+
if (custom) labels.push(custom);
|
|
527
|
+
return labels.length ? labels.join(", ") : "(no selection)";
|
|
528
|
+
}
|
|
529
|
+
var OTHER, WINDOW;
|
|
438
530
|
var init_WidgetAnswerScreen = __esm({
|
|
439
531
|
"src/tui/WidgetAnswerScreen.tsx"() {
|
|
440
532
|
"use strict";
|
|
441
533
|
init_tui_api();
|
|
534
|
+
init_Markdown();
|
|
535
|
+
init_Spinner();
|
|
536
|
+
OTHER = { id: "__other__", label: "Other" };
|
|
537
|
+
WINDOW = 10;
|
|
442
538
|
}
|
|
443
539
|
});
|
|
444
540
|
|
|
445
|
-
// src/tui/
|
|
446
|
-
import {
|
|
447
|
-
import {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
541
|
+
// src/tui/Message.tsx
|
|
542
|
+
import { Box as Box4, Text as Text6 } from "ink";
|
|
543
|
+
import { Fragment, jsx as jsx4, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
544
|
+
function Message({ msg, width }) {
|
|
545
|
+
return /* @__PURE__ */ jsxs6(Box4, { marginBottom: 1, flexDirection: "row", children: [
|
|
546
|
+
/* @__PURE__ */ jsx4(Box4, { width: 2, flexShrink: 0, children: glyph(msg) }),
|
|
547
|
+
/* @__PURE__ */ jsx4(Box4, { flexDirection: "column", flexGrow: 1, children: body(msg, width) })
|
|
548
|
+
] });
|
|
549
|
+
}
|
|
550
|
+
function glyph(msg) {
|
|
551
|
+
if (msg.role === "user") {
|
|
552
|
+
return /* @__PURE__ */ jsx4(Text6, { color: "cyan", bold: true, children: "\u203A" });
|
|
553
|
+
}
|
|
554
|
+
if (msg.kind === "success") return /* @__PURE__ */ jsx4(Text6, { color: "green", children: "\u2713" });
|
|
555
|
+
if (msg.kind === "error") return /* @__PURE__ */ jsx4(Text6, { color: "red", children: "\u2717" });
|
|
556
|
+
if (msg.kind === "info" || msg.role === "system") return /* @__PURE__ */ jsx4(Text6, { dimColor: true, children: "\xB7" });
|
|
557
|
+
return /* @__PURE__ */ jsx4(Text6, { bold: true, children: "\u25CF" });
|
|
558
|
+
}
|
|
559
|
+
function body(msg, width) {
|
|
560
|
+
if (msg.role === "user") return /* @__PURE__ */ jsx4(Text6, { color: "cyan", children: msg.text });
|
|
561
|
+
if (msg.kind === "markdown") return /* @__PURE__ */ jsx4(Markdown, { source: msg.text, width: width - 2 });
|
|
562
|
+
if (msg.kind === "plan") {
|
|
563
|
+
return /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
564
|
+
/* @__PURE__ */ jsx4(Text6, { bold: true, children: "Plan" }),
|
|
565
|
+
/* @__PURE__ */ jsx4(Text6, { dimColor: true, children: "\u2500".repeat(Math.max(8, Math.min(width - 4, 50))) }),
|
|
566
|
+
/* @__PURE__ */ jsx4(Markdown, { source: msg.text, width: width - 2 })
|
|
567
|
+
] });
|
|
568
|
+
}
|
|
569
|
+
if (msg.kind === "info" || msg.kind === "success" || msg.kind === "error") {
|
|
570
|
+
return /* @__PURE__ */ jsx4(Text6, { dimColor: true, children: msg.text });
|
|
571
|
+
}
|
|
572
|
+
return /* @__PURE__ */ jsx4(Text6, { children: msg.text });
|
|
573
|
+
}
|
|
574
|
+
var init_Message = __esm({
|
|
575
|
+
"src/tui/Message.tsx"() {
|
|
576
|
+
"use strict";
|
|
577
|
+
init_Markdown();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// src/tui/ConversationScreen.tsx
|
|
582
|
+
import { useCallback, useEffect as useEffect3, useRef as useRef3, useState as useState5 } from "react";
|
|
583
|
+
import { Box as Box5, Static, Text as Text7, useInput as useInput3, useStdout } from "ink";
|
|
584
|
+
import TextInput2 from "ink-text-input";
|
|
585
|
+
import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
586
|
+
function deriveTitle(text) {
|
|
587
|
+
const firstLine = (text.split("\n")[0] ?? "").trim();
|
|
588
|
+
const t = firstLine.length > 0 ? firstLine : text.trim();
|
|
589
|
+
return t.length > 72 ? `${t.slice(0, 69).trimEnd()}\u2026` : t;
|
|
590
|
+
}
|
|
591
|
+
function ConversationScreen({ config, repo, onBack }) {
|
|
592
|
+
const idRef = useRef3(0);
|
|
593
|
+
const [msgs, setMsgs] = useState5([
|
|
594
|
+
{
|
|
595
|
+
id: 0,
|
|
596
|
+
role: "system",
|
|
597
|
+
kind: "info",
|
|
598
|
+
text: `${repo.fullName} \u2014 describe what you'd like to build. Your message becomes a request; the FlumeCode agent (your runner) takes it from there.`
|
|
467
599
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
600
|
+
]);
|
|
601
|
+
const [input, setInput] = useState5("");
|
|
602
|
+
const historyRef = useRef3([]);
|
|
603
|
+
const [histIndex, setHistIndex] = useState5(null);
|
|
604
|
+
const [phase, setPhase] = useState5("compose");
|
|
605
|
+
const [error, setError] = useState5(null);
|
|
606
|
+
const [agentPending, setAgentPending] = useState5(false);
|
|
607
|
+
const [requestId, setRequestId] = useState5(null);
|
|
608
|
+
const planMessageId = useRef3(null);
|
|
609
|
+
const shownIds = useRef3(/* @__PURE__ */ new Set());
|
|
610
|
+
const [widgetMsg, setWidgetMsg] = useState5(null);
|
|
611
|
+
const { stdout: stdout2 } = useStdout();
|
|
612
|
+
const width = Math.min(stdout2?.columns ?? 80, 100);
|
|
613
|
+
const runners = usePoll(() => listRunners(config), 15e3);
|
|
614
|
+
const noRunnerOnline = runners !== null && !runners.some((r) => r.status === "online");
|
|
615
|
+
const push = useCallback((role, text, kind = "text") => {
|
|
616
|
+
setMsgs((prev) => [...prev, { id: ++idRef.current, role, kind, text }]);
|
|
617
|
+
}, []);
|
|
618
|
+
const createFromIdea = useCallback(
|
|
619
|
+
async (idea) => {
|
|
620
|
+
setPhase("creating");
|
|
621
|
+
try {
|
|
622
|
+
const agents = await listAgents(config);
|
|
623
|
+
const agentId = agents[0]?.id;
|
|
624
|
+
if (!agentId) throw new Error("No agent is configured for this repository.");
|
|
625
|
+
const { requestId: id } = await createRequest(config, {
|
|
626
|
+
title: deriveTitle(idea),
|
|
627
|
+
body: idea,
|
|
628
|
+
agentId,
|
|
629
|
+
repoId: repo.id,
|
|
630
|
+
// Fixed to the repo's default branch (same as the merge target below).
|
|
631
|
+
checkoutBranch: repo.defaultBranch ?? "main"
|
|
632
|
+
});
|
|
633
|
+
setRequestId(id);
|
|
634
|
+
setError(null);
|
|
635
|
+
push("system", `Request created \u2014 ${config.serverUrl}/requests/${id}`, "success");
|
|
636
|
+
push(
|
|
637
|
+
"system",
|
|
638
|
+
"The agent (your runner) is working on it. Needs a connected runner; Esc to leave.",
|
|
639
|
+
"info"
|
|
640
|
+
);
|
|
641
|
+
setPhase("thread");
|
|
642
|
+
} catch (err) {
|
|
643
|
+
setError(
|
|
644
|
+
`Could not create the request: ${err instanceof Error ? err.message : String(err)}`
|
|
645
|
+
);
|
|
646
|
+
setPhase("compose");
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
[config, push, repo]
|
|
650
|
+
);
|
|
651
|
+
const postFollowUp = useCallback(
|
|
652
|
+
async (text) => {
|
|
653
|
+
if (!requestId) return;
|
|
654
|
+
try {
|
|
655
|
+
await addRequestComment(config, requestId, text);
|
|
656
|
+
setError(null);
|
|
657
|
+
} catch (err) {
|
|
658
|
+
setError(`Could not send: ${err instanceof Error ? err.message : String(err)}`);
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
[config, requestId]
|
|
662
|
+
);
|
|
663
|
+
const doImplement = useCallback(async () => {
|
|
664
|
+
const msgId = planMessageId.current;
|
|
665
|
+
if (!msgId) return;
|
|
666
|
+
setPhase("working");
|
|
667
|
+
try {
|
|
668
|
+
const mergeBranch = repo.defaultBranch ?? "main";
|
|
669
|
+
const { session } = await acceptPlan(config, msgId, mergeBranch);
|
|
670
|
+
push(
|
|
671
|
+
"system",
|
|
672
|
+
`Coding session created \u2014 ${config.serverUrl}/repositories/${repo.id}/sessions/${session.id}`,
|
|
673
|
+
"success"
|
|
674
|
+
);
|
|
675
|
+
await startImplementing(config, session.id);
|
|
676
|
+
push(
|
|
677
|
+
"system",
|
|
678
|
+
"Implementation started \u2014 the runner will build it and open a PR. Track it in the status bar or the web.",
|
|
679
|
+
"success"
|
|
680
|
+
);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
push(
|
|
683
|
+
"system",
|
|
684
|
+
`Could not start implementing: ${err instanceof Error ? err.message : String(err)}`,
|
|
685
|
+
"error"
|
|
686
|
+
);
|
|
471
687
|
}
|
|
472
|
-
|
|
473
|
-
|
|
688
|
+
setPhase("thread");
|
|
689
|
+
}, [config, push, repo]);
|
|
690
|
+
const onWidgetDone = useCallback(
|
|
691
|
+
(summary) => {
|
|
692
|
+
setWidgetMsg(null);
|
|
693
|
+
for (const line of summary) push("system", line, "info");
|
|
694
|
+
push("system", "the agent is continuing\u2026", "info");
|
|
695
|
+
setPhase("thread");
|
|
696
|
+
},
|
|
697
|
+
[push]
|
|
698
|
+
);
|
|
699
|
+
useEffect3(() => {
|
|
700
|
+
if (phase !== "thread" || !requestId) return;
|
|
701
|
+
let live = true;
|
|
702
|
+
const poll = async () => {
|
|
703
|
+
try {
|
|
704
|
+
const data = await getRequest(config, requestId);
|
|
705
|
+
if (!live) return;
|
|
706
|
+
let pending = false;
|
|
707
|
+
for (const m of data.messages) {
|
|
708
|
+
if (shownIds.current.has(m.id)) continue;
|
|
709
|
+
if (m.authorType && m.authorType !== "agent") {
|
|
710
|
+
shownIds.current.add(m.id);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (m.type === "plan" && m.body?.trim()) {
|
|
714
|
+
shownIds.current.add(m.id);
|
|
715
|
+
planMessageId.current = m.id;
|
|
716
|
+
push("assistant", m.body.trim(), "plan");
|
|
717
|
+
setAgentPending(false);
|
|
718
|
+
setPhase("confirm-implement");
|
|
719
|
+
live = false;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const ws = m.widgets;
|
|
723
|
+
if (ws && ws.length) {
|
|
724
|
+
shownIds.current.add(m.id);
|
|
725
|
+
if (m.body?.trim()) push("assistant", m.body.trim(), "markdown");
|
|
726
|
+
push("system", "The agent is asking \u2014 pick an option below.", "info");
|
|
727
|
+
setWidgetMsg({ id: m.id, widgets: ws, requestId });
|
|
728
|
+
setAgentPending(false);
|
|
729
|
+
setPhase("answering-widget");
|
|
730
|
+
live = false;
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (m.type === "comment" && m.body?.trim()) {
|
|
734
|
+
shownIds.current.add(m.id);
|
|
735
|
+
push("assistant", m.body.trim(), "markdown");
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (m.authorType === "agent" && !m.body?.trim()) pending = true;
|
|
739
|
+
}
|
|
740
|
+
setAgentPending(pending);
|
|
741
|
+
} catch {
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
void poll();
|
|
745
|
+
const iv = setInterval(() => void poll(), 3e3);
|
|
746
|
+
return () => {
|
|
747
|
+
live = false;
|
|
748
|
+
clearInterval(iv);
|
|
749
|
+
};
|
|
750
|
+
}, [phase, requestId, config, push]);
|
|
751
|
+
useInput3((ch, key) => {
|
|
752
|
+
if (phase === "answering-widget") return;
|
|
753
|
+
if (key.escape) {
|
|
754
|
+
if (phase === "compose" || phase === "thread") onBack();
|
|
755
|
+
else if (phase === "confirm-implement") {
|
|
756
|
+
push("system", "Holding off on implementation.", "info");
|
|
757
|
+
setPhase("thread");
|
|
758
|
+
}
|
|
474
759
|
return;
|
|
475
760
|
}
|
|
476
|
-
if (key.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
761
|
+
if ((phase === "compose" || phase === "thread") && (key.upArrow || key.downArrow)) {
|
|
762
|
+
const h = historyRef.current;
|
|
763
|
+
if (h.length === 0) return;
|
|
764
|
+
if (key.upArrow) {
|
|
765
|
+
const idx = histIndex === null ? h.length - 1 : Math.max(0, histIndex - 1);
|
|
766
|
+
setHistIndex(idx);
|
|
767
|
+
setInput(h[idx] ?? "");
|
|
768
|
+
} else if (histIndex !== null && histIndex < h.length - 1) {
|
|
769
|
+
const idx = histIndex + 1;
|
|
770
|
+
setHistIndex(idx);
|
|
771
|
+
setInput(h[idx] ?? "");
|
|
772
|
+
} else {
|
|
773
|
+
setHistIndex(null);
|
|
774
|
+
setInput("");
|
|
485
775
|
}
|
|
776
|
+
return;
|
|
486
777
|
}
|
|
487
|
-
if (
|
|
488
|
-
void doImplement(
|
|
778
|
+
if (phase === "confirm-implement") {
|
|
779
|
+
if (ch === "y" || ch === "Y") void doImplement();
|
|
780
|
+
else if (ch === "n" || ch === "N") {
|
|
781
|
+
push("system", "Holding off on implementation.", "info");
|
|
782
|
+
setPhase("thread");
|
|
783
|
+
}
|
|
489
784
|
}
|
|
490
785
|
});
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
);
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
786
|
+
const onSubmit = () => {
|
|
787
|
+
const text = input.trim();
|
|
788
|
+
if (!text) return;
|
|
789
|
+
const h = historyRef.current;
|
|
790
|
+
if (h[h.length - 1] !== text) h.push(text);
|
|
791
|
+
setHistIndex(null);
|
|
792
|
+
setInput("");
|
|
793
|
+
if (phase === "compose") {
|
|
794
|
+
push("user", text);
|
|
795
|
+
void createFromIdea(text);
|
|
796
|
+
} else if (phase === "thread") {
|
|
797
|
+
push("user", text);
|
|
798
|
+
void postFollowUp(text);
|
|
503
799
|
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
/* @__PURE__ */
|
|
525
|
-
/* @__PURE__ */
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
value: targetBranch,
|
|
530
|
-
onChange: setTargetBranch,
|
|
531
|
-
onSubmit: () => setAcceptStep("merge"),
|
|
532
|
-
focus: acceptStep === "target"
|
|
533
|
-
}
|
|
534
|
-
),
|
|
535
|
-
acceptStep === "merge" && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
536
|
-
/* @__PURE__ */ jsx6(Text6, { children: "Enter merge branch name:" }),
|
|
537
|
-
/* @__PURE__ */ jsx6(
|
|
538
|
-
TextInput3,
|
|
800
|
+
};
|
|
801
|
+
const inputActive = phase === "compose" || phase === "thread";
|
|
802
|
+
const staticItems = [{ kind: "header", id: -1 }, ...msgs];
|
|
803
|
+
return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", children: [
|
|
804
|
+
/* @__PURE__ */ jsx5(Static, { items: staticItems, children: (item) => item.kind === "header" ? /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
|
|
805
|
+
"\u273B FlumeCode \xB7 ",
|
|
806
|
+
repo.fullName,
|
|
807
|
+
" ",
|
|
808
|
+
/* @__PURE__ */ jsx5(Text7, { dimColor: true, children: "esc to go back" })
|
|
809
|
+
] }) }, String(item.id)) : /* @__PURE__ */ jsx5(Message, { msg: item, width }, String(item.id)) }),
|
|
810
|
+
phase === "creating" && /* @__PURE__ */ jsx5(Spinner, { label: "creating request\u2026" }),
|
|
811
|
+
phase === "working" && /* @__PURE__ */ jsx5(Spinner, { label: "working\u2026" }),
|
|
812
|
+
phase === "confirm-implement" && /* @__PURE__ */ jsxs7(Text7, { children: [
|
|
813
|
+
/* @__PURE__ */ jsx5(Text7, { color: "green", bold: true, children: "\u276F Start implementing?" }),
|
|
814
|
+
/* @__PURE__ */ jsx5(Text7, { dimColor: true, children: " y = yes \xB7 n = keep refining" })
|
|
815
|
+
] }),
|
|
816
|
+
phase === "answering-widget" && widgetMsg && /* @__PURE__ */ jsx5(WidgetAnswerScreen, { config, message: widgetMsg, onDone: onWidgetDone }),
|
|
817
|
+
phase === "thread" && agentPending && /* @__PURE__ */ jsx5(Spinner, { label: "waiting for the agent\u2026" }),
|
|
818
|
+
phase === "thread" && agentPending && noRunnerOnline && /* @__PURE__ */ jsx5(Text7, { color: "yellow", children: "\u26A0 no runner online \u2014 start one with `flumecode start`" }),
|
|
819
|
+
inputActive && /* @__PURE__ */ jsxs7(Fragment2, { children: [
|
|
820
|
+
error && /* @__PURE__ */ jsx5(Text7, { color: "red", children: error }),
|
|
821
|
+
/* @__PURE__ */ jsxs7(Box5, { borderStyle: "round", borderColor: inputActive ? "cyan" : "gray", paddingX: 1, children: [
|
|
822
|
+
/* @__PURE__ */ jsx5(Text7, { color: "cyan", bold: true, children: "\u203A " }),
|
|
823
|
+
/* @__PURE__ */ jsx5(
|
|
824
|
+
TextInput2,
|
|
539
825
|
{
|
|
540
|
-
value:
|
|
541
|
-
onChange:
|
|
542
|
-
|
|
826
|
+
value: input,
|
|
827
|
+
onChange: (v) => {
|
|
828
|
+
setInput(v);
|
|
829
|
+
if (histIndex !== null) setHistIndex(null);
|
|
830
|
+
},
|
|
831
|
+
onSubmit,
|
|
543
832
|
focus: true
|
|
544
|
-
}
|
|
833
|
+
},
|
|
834
|
+
`input-${histIndex ?? "live"}`
|
|
545
835
|
)
|
|
546
836
|
] }),
|
|
547
|
-
|
|
548
|
-
] })
|
|
549
|
-
}
|
|
550
|
-
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
551
|
-
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Thread (\u2191\u2193 navigate, Enter act on selected, i implement, Esc back)" }),
|
|
552
|
-
!data && /* @__PURE__ */ jsx6(Text6, { children: "Loading\u2026" }),
|
|
553
|
-
overlay.kind === "accepted" && /* @__PURE__ */ jsx6(Text6, { color: "green", children: "Plan accepted! Job queued." }),
|
|
554
|
-
messages.map((msg, i) => /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", marginY: 0, children: /* @__PURE__ */ jsxs6(Text6, { color: i === cursor ? "cyan" : void 0, children: [
|
|
555
|
-
i === cursor ? "\u25B6 " : " ",
|
|
556
|
-
"[",
|
|
557
|
-
msg.type,
|
|
558
|
-
"] ",
|
|
559
|
-
msg.body?.slice(0, 80) ?? "",
|
|
560
|
-
msg.widgets?.length ? ` (${msg.widgets.length} widget${msg.widgets.length > 1 ? "s" : ""})` : ""
|
|
561
|
-
] }) }, msg.id)),
|
|
562
|
-
implementableSession && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
563
|
-
"Press i to start implementing session ",
|
|
564
|
-
implementableSession.id.slice(0, 8),
|
|
565
|
-
"\u2026"
|
|
566
|
-
] }),
|
|
567
|
-
error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
|
|
837
|
+
/* @__PURE__ */ jsx5(Text7, { dimColor: true, children: phase === "compose" ? "enter to create a request \xB7 esc back" : "enter send \xB7 esc back" })
|
|
838
|
+
] })
|
|
568
839
|
] });
|
|
569
840
|
}
|
|
570
|
-
var
|
|
571
|
-
"src/tui/
|
|
841
|
+
var init_ConversationScreen = __esm({
|
|
842
|
+
"src/tui/ConversationScreen.tsx"() {
|
|
572
843
|
"use strict";
|
|
573
|
-
init_usePoll();
|
|
574
844
|
init_tui_api();
|
|
575
845
|
init_WidgetAnswerScreen();
|
|
846
|
+
init_Message();
|
|
847
|
+
init_Spinner();
|
|
848
|
+
init_usePoll();
|
|
576
849
|
}
|
|
577
850
|
});
|
|
578
851
|
|
|
@@ -581,58 +854,37 @@ var App_exports = {};
|
|
|
581
854
|
__export(App_exports, {
|
|
582
855
|
App: () => App
|
|
583
856
|
});
|
|
584
|
-
import { useState as
|
|
585
|
-
import { Box as
|
|
586
|
-
import { jsx as
|
|
857
|
+
import { useState as useState6 } from "react";
|
|
858
|
+
import { Box as Box6 } from "ink";
|
|
859
|
+
import { jsx as jsx6, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
587
860
|
function renderScreen(screen, setScreen, config) {
|
|
588
861
|
switch (screen.name) {
|
|
589
862
|
case "repos":
|
|
590
|
-
return /* @__PURE__ */
|
|
863
|
+
return /* @__PURE__ */ jsx6(
|
|
591
864
|
RepoListScreen,
|
|
592
865
|
{
|
|
593
866
|
config,
|
|
594
|
-
onSelect: (repo) => setScreen({ name: "
|
|
867
|
+
onSelect: (repo) => setScreen({ name: "conversation", repo })
|
|
595
868
|
}
|
|
596
869
|
);
|
|
597
|
-
case "
|
|
598
|
-
return /* @__PURE__ */
|
|
599
|
-
|
|
870
|
+
case "conversation":
|
|
871
|
+
return /* @__PURE__ */ jsx6(
|
|
872
|
+
ConversationScreen,
|
|
600
873
|
{
|
|
601
874
|
config,
|
|
602
875
|
repo: screen.repo,
|
|
603
|
-
onOpen: (req) => setScreen({ name: "thread", repo: screen.repo, requestId: req.id }),
|
|
604
|
-
onNew: () => setScreen({ name: "new-request", repo: screen.repo }),
|
|
605
876
|
onBack: () => setScreen({ name: "repos" })
|
|
606
877
|
}
|
|
607
878
|
);
|
|
608
|
-
case "new-request":
|
|
609
|
-
return /* @__PURE__ */ jsx7(
|
|
610
|
-
NewRequestScreen,
|
|
611
|
-
{
|
|
612
|
-
config,
|
|
613
|
-
repo: screen.repo,
|
|
614
|
-
onCreated: (requestId) => setScreen({ name: "thread", repo: screen.repo, requestId }),
|
|
615
|
-
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
616
|
-
}
|
|
617
|
-
);
|
|
618
|
-
case "thread":
|
|
619
|
-
return /* @__PURE__ */ jsx7(
|
|
620
|
-
ThreadScreen,
|
|
621
|
-
{
|
|
622
|
-
config,
|
|
623
|
-
requestId: screen.requestId,
|
|
624
|
-
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
625
|
-
}
|
|
626
|
-
);
|
|
627
879
|
}
|
|
628
880
|
}
|
|
629
881
|
function App({ config }) {
|
|
630
|
-
const [screen, setScreen] =
|
|
882
|
+
const [screen, setScreen] = useState6({ name: "repos" });
|
|
631
883
|
const jobs = usePoll(() => listJobs(config), 3e3);
|
|
632
884
|
const runners = usePoll(() => listRunners(config), 3e3);
|
|
633
|
-
return /* @__PURE__ */
|
|
885
|
+
return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
|
|
634
886
|
renderScreen(screen, setScreen, config),
|
|
635
|
-
/* @__PURE__ */
|
|
887
|
+
/* @__PURE__ */ jsx6(StatusBar, { jobs, runners })
|
|
636
888
|
] });
|
|
637
889
|
}
|
|
638
890
|
var init_App = __esm({
|
|
@@ -642,15 +894,17 @@ var init_App = __esm({
|
|
|
642
894
|
init_usePoll();
|
|
643
895
|
init_StatusBar();
|
|
644
896
|
init_RepoListScreen();
|
|
645
|
-
|
|
646
|
-
init_NewRequestScreen();
|
|
647
|
-
init_ThreadScreen();
|
|
897
|
+
init_ConversationScreen();
|
|
648
898
|
}
|
|
649
899
|
});
|
|
650
900
|
|
|
651
901
|
// src/cli.ts
|
|
652
902
|
import { createInterface } from "node:readline/promises";
|
|
653
903
|
import { stdin, stdout } from "node:process";
|
|
904
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
905
|
+
import { openSync } from "node:fs";
|
|
906
|
+
import { homedir as homedir2 } from "node:os";
|
|
907
|
+
import { join as join7 } from "node:path";
|
|
654
908
|
|
|
655
909
|
// src/config.ts
|
|
656
910
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -662,8 +916,8 @@ function readConfig() {
|
|
|
662
916
|
if (!existsSync(configPath)) return null;
|
|
663
917
|
try {
|
|
664
918
|
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
665
|
-
if (!parsed.serverUrl || !parsed.token) return null;
|
|
666
|
-
const config = { serverUrl: parsed.serverUrl, token: parsed.token };
|
|
919
|
+
if (!parsed.serverUrl || !parsed.token && !parsed.apiToken) return null;
|
|
920
|
+
const config = { serverUrl: parsed.serverUrl, token: parsed.token ?? "" };
|
|
667
921
|
if (parsed.apiToken) config.apiToken = parsed.apiToken;
|
|
668
922
|
return config;
|
|
669
923
|
} catch {
|
|
@@ -675,6 +929,118 @@ function writeConfig(config) {
|
|
|
675
929
|
writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
676
930
|
}
|
|
677
931
|
|
|
932
|
+
// src/browser-login.ts
|
|
933
|
+
import { createServer } from "node:http";
|
|
934
|
+
import { randomBytes } from "node:crypto";
|
|
935
|
+
import { hostname } from "node:os";
|
|
936
|
+
import { spawn } from "node:child_process";
|
|
937
|
+
var LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
938
|
+
function openBrowser(url) {
|
|
939
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
940
|
+
try {
|
|
941
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
942
|
+
child.on("error", () => {
|
|
943
|
+
});
|
|
944
|
+
child.unref();
|
|
945
|
+
} catch {
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function respondHtml(res, status, title, body2) {
|
|
949
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title><style>
|
|
950
|
+
body{font:16px/1.5 system-ui,sans-serif;background:#0b0b0f;color:#e8e8ea;display:grid;place-items:center;height:100vh;margin:0}
|
|
951
|
+
.box{max-width:28rem;padding:2rem;text-align:center}
|
|
952
|
+
h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#a0a0a8;margin:0}
|
|
953
|
+
</style></head><body><div class="box"><h1>${title}</h1><p>${body2}</p></div></body></html>`;
|
|
954
|
+
res.statusCode = status;
|
|
955
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
956
|
+
res.end(html);
|
|
957
|
+
}
|
|
958
|
+
function browserLogin(opts) {
|
|
959
|
+
const print = opts.print ?? ((m) => console.log(m));
|
|
960
|
+
const open = opts.open ?? openBrowser;
|
|
961
|
+
const state = randomBytes(16).toString("hex");
|
|
962
|
+
return new Promise((resolve, reject) => {
|
|
963
|
+
let timer;
|
|
964
|
+
const server = createServer((req, res) => {
|
|
965
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
966
|
+
if (url.pathname !== "/" && url.pathname !== "/callback") {
|
|
967
|
+
res.statusCode = 404;
|
|
968
|
+
res.end("Not found");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const params = url.searchParams;
|
|
972
|
+
if (params.get("state") !== state) {
|
|
973
|
+
respondHtml(
|
|
974
|
+
res,
|
|
975
|
+
400,
|
|
976
|
+
"Login mismatch",
|
|
977
|
+
"This authorization did not match the pending login. Run `flumecode login` again."
|
|
978
|
+
);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const error = params.get("error");
|
|
982
|
+
if (error) {
|
|
983
|
+
respondHtml(
|
|
984
|
+
res,
|
|
985
|
+
200,
|
|
986
|
+
"Login canceled",
|
|
987
|
+
"You can close this tab and return to the terminal."
|
|
988
|
+
);
|
|
989
|
+
cleanup2();
|
|
990
|
+
reject(
|
|
991
|
+
new Error(
|
|
992
|
+
error === "denied" ? "Login was denied in the browser." : `Login failed: ${error}`
|
|
993
|
+
)
|
|
994
|
+
);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
respondHtml(
|
|
998
|
+
res,
|
|
999
|
+
200,
|
|
1000
|
+
"You're signed in",
|
|
1001
|
+
"Authorization complete \u2014 close this tab and return to the terminal."
|
|
1002
|
+
);
|
|
1003
|
+
cleanup2();
|
|
1004
|
+
resolve({
|
|
1005
|
+
cliToken: params.get("cli") ?? void 0,
|
|
1006
|
+
runnerToken: params.get("runner") ?? void 0
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
const cleanup2 = () => {
|
|
1010
|
+
clearTimeout(timer);
|
|
1011
|
+
server.close();
|
|
1012
|
+
};
|
|
1013
|
+
server.on("error", (err) => {
|
|
1014
|
+
cleanup2();
|
|
1015
|
+
reject(err);
|
|
1016
|
+
});
|
|
1017
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1018
|
+
const addr = server.address();
|
|
1019
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1020
|
+
if (!port) {
|
|
1021
|
+
cleanup2();
|
|
1022
|
+
reject(new Error("Could not start the local login listener."));
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const authorizeUrl = `${opts.serverUrl}/cli/authorize?` + new URLSearchParams({
|
|
1026
|
+
port: String(port),
|
|
1027
|
+
state,
|
|
1028
|
+
host: hostname(),
|
|
1029
|
+
scope: opts.scope
|
|
1030
|
+
}).toString();
|
|
1031
|
+
print("Opening your browser to authorize this machine\u2026");
|
|
1032
|
+
print(`If it doesn't open, paste this URL:
|
|
1033
|
+
${authorizeUrl}
|
|
1034
|
+
`);
|
|
1035
|
+
open(authorizeUrl);
|
|
1036
|
+
});
|
|
1037
|
+
timer = setTimeout(() => {
|
|
1038
|
+
cleanup2();
|
|
1039
|
+
reject(new Error("Timed out waiting for browser authorization (5 min)."));
|
|
1040
|
+
}, LOGIN_TIMEOUT_MS);
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
678
1044
|
// src/run.ts
|
|
679
1045
|
import { existsSync as existsSync5 } from "node:fs";
|
|
680
1046
|
import { join as join6 } from "node:path";
|
|
@@ -734,8 +1100,8 @@ async function pollJobCanceling(config, jobId) {
|
|
|
734
1100
|
});
|
|
735
1101
|
noteServerVersion(res);
|
|
736
1102
|
if (!res.ok) return false;
|
|
737
|
-
const
|
|
738
|
-
return
|
|
1103
|
+
const body2 = await res.json();
|
|
1104
|
+
return body2.canceling === true;
|
|
739
1105
|
}
|
|
740
1106
|
async function reportJob(config, jobId, result) {
|
|
741
1107
|
const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
|
|
@@ -957,7 +1323,18 @@ var stepSchema = z2.object({
|
|
|
957
1323
|
"Per-file pseudo code. Provide an entry for every non-documentation file this step touches. Each entry contains the file path and pseudo code describing the changes to that file."
|
|
958
1324
|
)
|
|
959
1325
|
});
|
|
1326
|
+
var requirementSchema = z2.object({
|
|
1327
|
+
requirement: z2.string().min(1).describe(
|
|
1328
|
+
"A human-readable statement of what this change must accomplish and why, in plain language a non-technical reader can follow. Distinct from acceptanceCriteria: requirements explain intent/rationale; acceptance criteria are the machine-checkable proof. " + INLINE_CODE_HINT
|
|
1329
|
+
),
|
|
1330
|
+
acceptanceCriteria: z2.array(z2.string().min(1)).min(1).describe(
|
|
1331
|
+
"Concrete, deterministically-checkable conditions that prove this requirement is satisfied. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. " + INLINE_CODE_HINT
|
|
1332
|
+
)
|
|
1333
|
+
});
|
|
960
1334
|
var planInputSchema = {
|
|
1335
|
+
dependsOn: z2.array(z2.number().int().positive()).optional().describe(
|
|
1336
|
+
"1-based positions of EARLIER plans in this same submission that must be merged or closed before this one. Use ONLY when the work must be strictly serial; omit for independent plans."
|
|
1337
|
+
),
|
|
961
1338
|
title: z2.string().min(1).max(120).describe(
|
|
962
1339
|
"A concise, descriptive name for THIS plan. Must be distinct from the request title and from any sibling plans on the same request. Keep it under 120 characters."
|
|
963
1340
|
),
|
|
@@ -970,13 +1347,10 @@ var planInputSchema = {
|
|
|
970
1347
|
"Why the user is making this request \u2014 the underlying motivation or problem the change addresses. Fill this especially when the request content/context does NOT already state the why (ask the user in the Clarify phase); omit when there is no additional motivation to record. Useful for future understanding of the system. " + INLINE_CODE_HINT
|
|
971
1348
|
),
|
|
972
1349
|
assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
|
|
973
|
-
requirements: z2.array(
|
|
974
|
-
"Required, human-readable statements of what this change must accomplish and why,
|
|
1350
|
+
requirements: z2.array(requirementSchema).min(1).describe(
|
|
1351
|
+
"Required, human-readable statements of what this change must accomplish and why, each carrying its own acceptanceCriteria. At least 1 requirement required; at least 2 acceptance criteria total across all requirements. " + INLINE_CODE_HINT
|
|
975
1352
|
),
|
|
976
1353
|
steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
|
|
977
|
-
acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
|
|
978
|
-
"Concrete, deterministically-checkable conditions that together define done. Each names a trigger/precondition and the exact observable result (run X -> output Y; file Z contains W; f(a) returns b) \u2014 no vague adjectives, not a restatement of a step. The set must collectively cover every step's change. At least 2 required. " + INLINE_CODE_HINT
|
|
979
|
-
),
|
|
980
1354
|
risks: z2.array(z2.string()).describe("Anything that could change the approach."),
|
|
981
1355
|
outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
|
|
982
1356
|
};
|
|
@@ -991,7 +1365,21 @@ function requireRootCauseForFix(schema) {
|
|
|
991
1365
|
}
|
|
992
1366
|
});
|
|
993
1367
|
}
|
|
994
|
-
|
|
1368
|
+
function requireAtLeastTwoCriteria(schema) {
|
|
1369
|
+
return schema.superRefine((plan, ctx) => {
|
|
1370
|
+
const total = plan.requirements.reduce((sum, r) => sum + r.acceptanceCriteria.length, 0);
|
|
1371
|
+
if (total < 2) {
|
|
1372
|
+
ctx.addIssue({
|
|
1373
|
+
code: z2.ZodIssueCode.custom,
|
|
1374
|
+
path: ["requirements"],
|
|
1375
|
+
message: "At least 2 acceptance criteria total across all requirements are required."
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
var planSchema = requireAtLeastTwoCriteria(
|
|
1381
|
+
requireRootCauseForFix(z2.object(planInputSchema))
|
|
1382
|
+
);
|
|
995
1383
|
function renderPlan(plan) {
|
|
996
1384
|
const lines2 = [];
|
|
997
1385
|
lines2.push(`# ${plan.title}`);
|
|
@@ -1018,8 +1406,8 @@ function renderPlan(plan) {
|
|
|
1018
1406
|
}
|
|
1019
1407
|
lines2.push("");
|
|
1020
1408
|
lines2.push("## Requirements");
|
|
1021
|
-
for (const
|
|
1022
|
-
lines2.push(`- ${requirement}`);
|
|
1409
|
+
for (const req of plan.requirements) {
|
|
1410
|
+
lines2.push(`- ${req.requirement}`);
|
|
1023
1411
|
}
|
|
1024
1412
|
lines2.push("");
|
|
1025
1413
|
lines2.push("## Steps");
|
|
@@ -1044,8 +1432,11 @@ function renderPlan(plan) {
|
|
|
1044
1432
|
}
|
|
1045
1433
|
lines2.push("");
|
|
1046
1434
|
lines2.push("## Acceptance criteria");
|
|
1047
|
-
for (const
|
|
1048
|
-
lines2.push(
|
|
1435
|
+
for (const req of plan.requirements) {
|
|
1436
|
+
lines2.push(`### ${req.requirement}`);
|
|
1437
|
+
for (const criterion of req.acceptanceCriteria) {
|
|
1438
|
+
lines2.push(`- [ ] ${criterion}`);
|
|
1439
|
+
}
|
|
1049
1440
|
}
|
|
1050
1441
|
if (plan.risks.length > 0) {
|
|
1051
1442
|
lines2.push("");
|
|
@@ -1065,25 +1456,42 @@ function renderPlan(plan) {
|
|
|
1065
1456
|
lines2.push(PLAN_MARKER);
|
|
1066
1457
|
return lines2.join("\n");
|
|
1067
1458
|
}
|
|
1459
|
+
function validateDependsOnReferences(arr, ctx) {
|
|
1460
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1461
|
+
const deps = arr[i]?.dependsOn ?? [];
|
|
1462
|
+
for (const dep of deps) {
|
|
1463
|
+
if (dep > i || dep < 1) {
|
|
1464
|
+
ctx.addIssue({
|
|
1465
|
+
code: z2.ZodIssueCode.custom,
|
|
1466
|
+
path: [i, "dependsOn"],
|
|
1467
|
+
message: "dependsOn must reference an earlier plan (1-based) in this submission."
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1068
1473
|
var submitPlanInputSchema = {
|
|
1069
|
-
plans: z2.array(requireRootCauseForFix(z2.object(planInputSchema))).min(1).refine(
|
|
1474
|
+
plans: z2.array(requireAtLeastTwoCriteria(requireRootCauseForFix(z2.object(planInputSchema)))).min(1).refine(
|
|
1070
1475
|
(arr) => {
|
|
1071
1476
|
const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
|
|
1072
1477
|
return new Set(titles).size === titles.length;
|
|
1073
1478
|
},
|
|
1074
1479
|
{ message: "Each plan must have a distinct non-empty title" }
|
|
1075
|
-
)
|
|
1480
|
+
).superRefine(validateDependsOnReferences)
|
|
1076
1481
|
};
|
|
1077
1482
|
var submitPlanSchema = z2.object(submitPlanInputSchema);
|
|
1483
|
+
function toPlanDraft(plan) {
|
|
1484
|
+
return { body: renderPlan(plan), dependsOn: plan.dependsOn ?? [] };
|
|
1485
|
+
}
|
|
1078
1486
|
function createPlanTooling() {
|
|
1079
1487
|
let renderedPlans = null;
|
|
1080
1488
|
const submitPlan = tool2(
|
|
1081
1489
|
SUBMIT_PLAN,
|
|
1082
|
-
`Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once.
|
|
1490
|
+
`Submit ALL your plans in a single call \u2014 one entry per plan; each becomes its own independently-acceptable Accept-as-plan draft. Do NOT call submit_plan more than once. requirements is required in each plan: an array of objects, each with a requirement (plain-language intent) and an acceptanceCriteria array (machine-checkable proof for that requirement). At least 1 requirement required; at least 2 acceptance criteria total across all requirements. Each requirement's acceptanceCriteria must be non-empty (at least 1 item). The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. When a plan's scope is "fix", rootCause is required: a non-empty explanation of the underlying cause of the bug (not just the symptom). motivation is optional: the user's stated or asked-for reason for the request. dependsOn is optional: a list of 1-based positions of EARLIER plans in this same submission that must be merged or closed before this one \u2014 use only when plans must be worked strictly serially.`,
|
|
1083
1491
|
submitPlanInputSchema,
|
|
1084
1492
|
async (args) => {
|
|
1085
1493
|
const parsed = submitPlanSchema.parse(args);
|
|
1086
|
-
renderedPlans = parsed.plans.map(
|
|
1494
|
+
renderedPlans = parsed.plans.map(toPlanDraft);
|
|
1087
1495
|
return {
|
|
1088
1496
|
content: [
|
|
1089
1497
|
{
|
|
@@ -1147,6 +1555,9 @@ var acVerdictSchema = z3.object({
|
|
|
1147
1555
|
rationale: z3.string().min(1).describe("One or two sentences on why the verdict holds. " + INLINE_CODE_HINT),
|
|
1148
1556
|
evidence: z3.array(evidenceSchema).describe(
|
|
1149
1557
|
"Diff hunks proving the verdict, copied verbatim from git --no-pager diff. Across ALL criteria the evidence must collectively cover every hunk in the diff \u2014 each changed hunk appears under at least one criterion. Cite the relevant hunk(s) for a met criterion; may be empty for not_met / unclear."
|
|
1558
|
+
),
|
|
1559
|
+
requirement: z3.string().min(1).optional().describe(
|
|
1560
|
+
"The verbatim requirement text from the plan that this criterion appears under. Used for grouping criteria by requirement in the rendered report. Optional \u2014 omit for resolve runs (no plan) and legacy plans without grouping."
|
|
1150
1561
|
)
|
|
1151
1562
|
});
|
|
1152
1563
|
var reportInputSchema = {
|
|
@@ -1171,24 +1582,47 @@ var reportInputSchema = {
|
|
|
1171
1582
|
)
|
|
1172
1583
|
};
|
|
1173
1584
|
var reportSchema = z3.object(reportInputSchema);
|
|
1585
|
+
function groupByRequirement(criteria) {
|
|
1586
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1587
|
+
for (const ac of criteria) {
|
|
1588
|
+
const key = ac.requirement ?? null;
|
|
1589
|
+
const existing = groups.get(key);
|
|
1590
|
+
if (existing) {
|
|
1591
|
+
existing.push(ac);
|
|
1592
|
+
} else {
|
|
1593
|
+
groups.set(key, [ac]);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return groups;
|
|
1597
|
+
}
|
|
1598
|
+
function renderAcVerdict(lines2, ac) {
|
|
1599
|
+
lines2.push("");
|
|
1600
|
+
lines2.push(`#### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
|
|
1601
|
+
lines2.push("");
|
|
1602
|
+
lines2.push(ac.rationale.trim());
|
|
1603
|
+
for (const ev of ac.evidence) {
|
|
1604
|
+
lines2.push("");
|
|
1605
|
+
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
1606
|
+
lines2.push("");
|
|
1607
|
+
lines2.push("```diff");
|
|
1608
|
+
lines2.push(ev.hunk.replace(/\n+$/, ""));
|
|
1609
|
+
lines2.push("```");
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1174
1612
|
function renderReport(report) {
|
|
1175
1613
|
const lines2 = [];
|
|
1176
1614
|
lines2.push(report.summary.trim());
|
|
1177
1615
|
lines2.push("", "## Files changed", "", report.filesChanged.trim());
|
|
1178
1616
|
if (report.acceptanceCriteria.length > 0) {
|
|
1179
1617
|
lines2.push("", "## Acceptance criteria");
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
lines2.push("");
|
|
1184
|
-
lines2.push(ac.rationale.trim());
|
|
1185
|
-
for (const ev of ac.evidence) {
|
|
1186
|
-
lines2.push("");
|
|
1187
|
-
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
1618
|
+
const groups = groupByRequirement(report.acceptanceCriteria);
|
|
1619
|
+
for (const [req, acs] of groups) {
|
|
1620
|
+
if (req !== null) {
|
|
1188
1621
|
lines2.push("");
|
|
1189
|
-
lines2.push(
|
|
1190
|
-
|
|
1191
|
-
|
|
1622
|
+
lines2.push(`### ${req}`);
|
|
1623
|
+
}
|
|
1624
|
+
for (const ac of acs) {
|
|
1625
|
+
renderAcVerdict(lines2, ac);
|
|
1192
1626
|
}
|
|
1193
1627
|
}
|
|
1194
1628
|
}
|
|
@@ -1212,7 +1646,7 @@ function createReportTooling() {
|
|
|
1212
1646
|
let submittedReport = null;
|
|
1213
1647
|
const submitReport = tool3(
|
|
1214
1648
|
SUBMIT_REPORT,
|
|
1215
|
-
"Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion, each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. `cicd` (optional) holds Verify-phase check results (one entry per command with `command`, `status` `passed`/`failed`, and `output` on failure); omit when no verification setup exists. Do NOT include a PR link \u2014 the runner appends it.",
|
|
1649
|
+
"Submit the final implementation report as structured data. Call this exactly once, at the end of the run. `acceptanceCriteria` must contain one entry per plan criterion (same count and order), each with a met / not_met / unclear verdict and the diff hunk(s) that prove it. Set `requirement` on each entry to the verbatim text of the `### <requirement>` heading the criterion appeared under in the plan \u2014 this groups criteria by requirement in the rendered report. Omit `requirement` for resolve runs (no plan) or when the plan has no requirement headings. `summary`, `filesChanged`, `codeQuality`, and `caveats` are the four named markdown sections. `cicd` (optional) holds Verify-phase check results (one entry per command with `command`, `status` `passed`/`failed`, and `output` on failure); omit when no verification setup exists. Do NOT include a PR link \u2014 the runner appends it.",
|
|
1216
1650
|
reportInputSchema,
|
|
1217
1651
|
async (args) => {
|
|
1218
1652
|
submittedReport = reportSchema.parse(args);
|
|
@@ -1295,7 +1729,7 @@ async function runClaudeCode(opts) {
|
|
|
1295
1729
|
const { mcpServer, collected } = createWidgetTooling();
|
|
1296
1730
|
const { mcpServer: planServer, getPlans } = createPlanTooling();
|
|
1297
1731
|
const { mcpServer: reportServer, getReport } = createReportTooling();
|
|
1298
|
-
for await (const
|
|
1732
|
+
for await (const message2 of query({
|
|
1299
1733
|
prompt: opts.prompt,
|
|
1300
1734
|
options: {
|
|
1301
1735
|
cwd: opts.cwd,
|
|
@@ -1319,8 +1753,8 @@ async function runClaudeCode(opts) {
|
|
|
1319
1753
|
allowedTools: [...WIDGET_TOOL_NAMES, PLAN_TOOL_NAME, REPORT_TOOL_NAME, "Task"]
|
|
1320
1754
|
}
|
|
1321
1755
|
})) {
|
|
1322
|
-
if (
|
|
1323
|
-
const content =
|
|
1756
|
+
if (message2.type === "assistant") {
|
|
1757
|
+
const content = message2.message?.content;
|
|
1324
1758
|
if (Array.isArray(content)) {
|
|
1325
1759
|
for (const block of content) {
|
|
1326
1760
|
if (block && block.type === "text" && typeof block.text === "string") {
|
|
@@ -1331,8 +1765,8 @@ async function runClaudeCode(opts) {
|
|
|
1331
1765
|
}
|
|
1332
1766
|
}
|
|
1333
1767
|
}
|
|
1334
|
-
} else if (
|
|
1335
|
-
const content =
|
|
1768
|
+
} else if (message2.type === "user") {
|
|
1769
|
+
const content = message2.message?.content;
|
|
1336
1770
|
if (Array.isArray(content)) {
|
|
1337
1771
|
for (const block of content) {
|
|
1338
1772
|
if (block && block.type === "tool_result") {
|
|
@@ -1340,10 +1774,10 @@ async function runClaudeCode(opts) {
|
|
|
1340
1774
|
}
|
|
1341
1775
|
}
|
|
1342
1776
|
}
|
|
1343
|
-
} else if (
|
|
1344
|
-
finalText =
|
|
1777
|
+
} else if (message2.type === "result") {
|
|
1778
|
+
finalText = message2.result ?? "";
|
|
1345
1779
|
logEvent("result", finalText);
|
|
1346
|
-
const resultMsg =
|
|
1780
|
+
const resultMsg = message2;
|
|
1347
1781
|
if (resultMsg.usage) {
|
|
1348
1782
|
usageAcc.inputTokens += resultMsg.usage.input_tokens ?? 0;
|
|
1349
1783
|
usageAcc.outputTokens += resultMsg.usage.output_tokens ?? 0;
|
|
@@ -1351,8 +1785,8 @@ async function runClaudeCode(opts) {
|
|
|
1351
1785
|
usageAcc.cacheReadTokens += resultMsg.usage.cache_read_input_tokens ?? 0;
|
|
1352
1786
|
}
|
|
1353
1787
|
usageAcc.costUsd += resultMsg.total_cost_usd ?? 0;
|
|
1354
|
-
} else if (
|
|
1355
|
-
logEvent("system", JSON.stringify(
|
|
1788
|
+
} else if (message2.type === "system") {
|
|
1789
|
+
logEvent("system", JSON.stringify(message2));
|
|
1356
1790
|
}
|
|
1357
1791
|
}
|
|
1358
1792
|
process.stdout.write("\n");
|
|
@@ -1363,7 +1797,7 @@ async function runClaudeCode(opts) {
|
|
|
1363
1797
|
}
|
|
1364
1798
|
|
|
1365
1799
|
// src/codex.ts
|
|
1366
|
-
import { spawn } from "node:child_process";
|
|
1800
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1367
1801
|
import { mkdtempSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
|
|
1368
1802
|
import { join as join2 } from "node:path";
|
|
1369
1803
|
import { tmpdir } from "node:os";
|
|
@@ -1373,7 +1807,7 @@ async function runCodex(opts) {
|
|
|
1373
1807
|
const tmpDir = mkdtempSync(join2(tmpdir(), "flume-codex-"));
|
|
1374
1808
|
const outFile = join2(tmpDir, "flume-mcp.jsonl");
|
|
1375
1809
|
writeFileSync2(outFile, "");
|
|
1376
|
-
const child =
|
|
1810
|
+
const child = spawn2(
|
|
1377
1811
|
"codex",
|
|
1378
1812
|
[
|
|
1379
1813
|
"exec",
|
|
@@ -1426,7 +1860,8 @@ async function runCodex(opts) {
|
|
|
1426
1860
|
}
|
|
1427
1861
|
const raw = existsSync2(outFile) ? readFileSync3(outFile, "utf8") : "";
|
|
1428
1862
|
const records = raw.split("\n").filter(Boolean).map(parseJsonLine).filter((r) => r !== null);
|
|
1429
|
-
const
|
|
1863
|
+
const rawPlans = records.filter((r) => r.kind === "plans").flatMap((r) => r.plans ?? []);
|
|
1864
|
+
const plans = rawPlans.map((body2) => ({ body: body2, dependsOn: [] }));
|
|
1430
1865
|
const widgets = records.filter((r) => r.kind === "widget").map((r) => r.widget);
|
|
1431
1866
|
return {
|
|
1432
1867
|
text: "",
|
|
@@ -1450,14 +1885,14 @@ async function checkClaudeCode() {
|
|
|
1450
1885
|
try {
|
|
1451
1886
|
let sawResult = false;
|
|
1452
1887
|
const run = (async () => {
|
|
1453
|
-
for await (const
|
|
1888
|
+
for await (const message2 of query2({
|
|
1454
1889
|
prompt: "Reply with the single word: ok",
|
|
1455
1890
|
options: { permissionMode: "bypassPermissions", maxTurns: 1 }
|
|
1456
1891
|
})) {
|
|
1457
|
-
if (
|
|
1892
|
+
if (message2.type === "result") {
|
|
1458
1893
|
sawResult = true;
|
|
1459
|
-
const isError =
|
|
1460
|
-
const subtype =
|
|
1894
|
+
const isError = message2.is_error === true;
|
|
1895
|
+
const subtype = message2.subtype;
|
|
1461
1896
|
if (isError)
|
|
1462
1897
|
throw new Error(
|
|
1463
1898
|
subtype ? `Claude Code error: ${subtype}` : "Claude Code returned an error"
|
|
@@ -1472,9 +1907,9 @@ async function checkClaudeCode() {
|
|
|
1472
1907
|
return { ready: false, error: errorMessage(err) };
|
|
1473
1908
|
}
|
|
1474
1909
|
}
|
|
1475
|
-
function withTimeout(p, ms,
|
|
1910
|
+
function withTimeout(p, ms, message2) {
|
|
1476
1911
|
return new Promise((resolve, reject) => {
|
|
1477
|
-
const timer = setTimeout(() => reject(new Error(
|
|
1912
|
+
const timer = setTimeout(() => reject(new Error(message2)), ms);
|
|
1478
1913
|
p.then(
|
|
1479
1914
|
(v) => {
|
|
1480
1915
|
clearTimeout(timer);
|
|
@@ -1536,6 +1971,13 @@ function appendRule(lines2, intro, ruleName) {
|
|
|
1536
1971
|
lines2.push("", intro, "", loadRule(ruleName));
|
|
1537
1972
|
}
|
|
1538
1973
|
var WRITING_INTRO = "These technical-writing guidelines apply to the plan and report prose you author in this run:";
|
|
1974
|
+
var SETUP_PREVIEW_MARKER = "<!-- flumecode:setup-preview -->";
|
|
1975
|
+
function setupPreviewDirective(mode) {
|
|
1976
|
+
if (mode === "plan") {
|
|
1977
|
+
return "This request installs FlumeCode's **UI Preview plugin**. Use the `flumecode:setup-preview` skill as your authority: analyse this app read-only to determine exactly what the plugin needs \u2014 the app directory, the install and dev-server commands, any env for a hermetic boot, the globs that count as a UI change, and the showcase strategy \u2014 and fold that into the plan you submit (the precise `.flumecode/plugins/ui-preview.json` fields to write and the `.flumecode/wiki/components/ui-preview.md` record to add). Do not write files; you are read-only.";
|
|
1978
|
+
}
|
|
1979
|
+
return "This request installs FlumeCode's **UI Preview plugin**. The `flumecode:setup-preview` skill is the authority on what to produce \u2014 `.flumecode/plugins/ui-preview.json` (the machine recipe later previews execute) and a `.flumecode/wiki/components/ui-preview.md` record, plus any packages future previews need. As the orchestrator, direct your implementation subagent to follow `flumecode:setup-preview` exactly, and your review subagent to verify the manifest and wiki record against it.";
|
|
1980
|
+
}
|
|
1539
1981
|
var LANGUAGE_DIRECTIVE = "First, determine the dominant natural language of the incoming thread (the request title/body and the user's messages). Use that one language for EVERYTHING you author this run - your reply body, any plan or report fields, AND every clarifying question and its widget options. Never mix languages: if the thread is in English, your questions and options must be in English too. Keep code identifiers, file paths, and quoted code verbatim.";
|
|
1540
1982
|
function turnHeading(turn, agentName) {
|
|
1541
1983
|
if (turn.role === "user") return "User";
|
|
@@ -1572,6 +2014,9 @@ function buildPrompt(ctx) {
|
|
|
1572
2014
|
loadRule("coding-guideline")
|
|
1573
2015
|
);
|
|
1574
2016
|
}
|
|
2017
|
+
if (ctx.request?.body?.includes(SETUP_PREVIEW_MARKER)) {
|
|
2018
|
+
lines2.push("", setupPreviewDirective(ctx.permissionMode));
|
|
2019
|
+
}
|
|
1575
2020
|
appendRule(lines2, WRITING_INTRO, "technical-writing");
|
|
1576
2021
|
lines2.push("", `# Request: ${ctx.request?.title ?? ""}`);
|
|
1577
2022
|
if (ctx.request?.body) {
|
|
@@ -1740,12 +2185,19 @@ function buildCodexPrompt(ctx) {
|
|
|
1740
2185
|
"\n"
|
|
1741
2186
|
);
|
|
1742
2187
|
}
|
|
1743
|
-
function buildPreviewPrompt(ctx, committedFileNames,
|
|
2188
|
+
function buildPreviewPrompt(ctx, committedFileNames, manifest, sentinelPath) {
|
|
1744
2189
|
const lines2 = [
|
|
1745
2190
|
`You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
|
|
1746
|
-
`An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase
|
|
1747
|
-
`
|
|
1748
|
-
`
|
|
2191
|
+
`An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase that renders the changed UI with realistic fake data, following the recipe below for this repo's stack.`,
|
|
2192
|
+
`Record the showcase's URL path (exactly, no trailing newline) in the file \`${sentinelPath}\` \u2014 the runner reads it to navigate to your showcase.`,
|
|
2193
|
+
`Your showcase is EPHEMERAL: the runner reverts the working tree after capturing it, so you may edit whatever the recipe requires (including registering a temporary route in tracked files). Do NOT commit or push.`,
|
|
2194
|
+
"",
|
|
2195
|
+
"## Preview recipe (from this repo's ui-preview plugin manifest)",
|
|
2196
|
+
"",
|
|
2197
|
+
`- App directory: \`${manifest.appDir}\``,
|
|
2198
|
+
`- Showcase strategy: ${manifest.showcase.kind}`,
|
|
2199
|
+
"",
|
|
2200
|
+
manifest.showcase.instructions || "(no recipe instructions were recorded \u2014 infer from the app)",
|
|
1749
2201
|
"",
|
|
1750
2202
|
"These coding guidelines apply to all code produced in this run:",
|
|
1751
2203
|
"",
|
|
@@ -1759,14 +2211,14 @@ function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
|
|
|
1759
2211
|
if (committedFileNames.trim()) {
|
|
1760
2212
|
lines2.push(
|
|
1761
2213
|
"",
|
|
1762
|
-
"UI files changed by this implementation (showcase these
|
|
2214
|
+
"UI files changed by this implementation (showcase these):",
|
|
1763
2215
|
"",
|
|
1764
2216
|
committedFileNames.trim()
|
|
1765
2217
|
);
|
|
1766
2218
|
}
|
|
1767
2219
|
lines2.push(
|
|
1768
2220
|
"",
|
|
1769
|
-
"When done, reply with one short sentence confirming what showcase
|
|
2221
|
+
"When done, reply with one short sentence confirming what showcase you created and its URL path."
|
|
1770
2222
|
);
|
|
1771
2223
|
return lines2.join("\n");
|
|
1772
2224
|
}
|
|
@@ -1781,7 +2233,8 @@ function buildInitPrompt(ctx) {
|
|
|
1781
2233
|
|
|
1782
2234
|
// src/types.ts
|
|
1783
2235
|
function jobTitle(ctx) {
|
|
1784
|
-
|
|
2236
|
+
if (ctx.kind === "init") return "Initialize FlumeCode wiki";
|
|
2237
|
+
return ctx.request?.title ?? "request";
|
|
1785
2238
|
}
|
|
1786
2239
|
|
|
1787
2240
|
// src/workspace.ts
|
|
@@ -1945,6 +2398,13 @@ async function gitDiffStat(dir) {
|
|
|
1945
2398
|
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
|
|
1946
2399
|
return stdout2;
|
|
1947
2400
|
}
|
|
2401
|
+
async function captureFullDiff(ctx, dir) {
|
|
2402
|
+
const { mergeBranch } = ctx.repo;
|
|
2403
|
+
if (!mergeBranch) return "";
|
|
2404
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
2405
|
+
const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "FETCH_HEAD...HEAD"]);
|
|
2406
|
+
return stdout2;
|
|
2407
|
+
}
|
|
1948
2408
|
var PreCommitError = class extends Error {
|
|
1949
2409
|
constructor(log) {
|
|
1950
2410
|
super("pre-commit checks failed");
|
|
@@ -2037,7 +2497,7 @@ async function openPullRequest(ctx) {
|
|
|
2037
2497
|
"content-type": "application/json"
|
|
2038
2498
|
};
|
|
2039
2499
|
const title = jobTitle(ctx);
|
|
2040
|
-
const
|
|
2500
|
+
const body2 = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
2041
2501
|
const res = await fetch(`${apiBase}/pulls`, {
|
|
2042
2502
|
method: "POST",
|
|
2043
2503
|
headers,
|
|
@@ -2045,7 +2505,7 @@ async function openPullRequest(ctx) {
|
|
|
2045
2505
|
title: `FlumeCode: ${title}`,
|
|
2046
2506
|
head: checkoutBranch,
|
|
2047
2507
|
base: mergeBranch,
|
|
2048
|
-
body
|
|
2508
|
+
body: body2
|
|
2049
2509
|
})
|
|
2050
2510
|
});
|
|
2051
2511
|
if (res.status === 201) {
|
|
@@ -2136,80 +2596,170 @@ async function prNumbersForCommit(ctx, sha) {
|
|
|
2136
2596
|
}
|
|
2137
2597
|
|
|
2138
2598
|
// src/preview.ts
|
|
2139
|
-
import {
|
|
2140
|
-
import {
|
|
2141
|
-
import {
|
|
2142
|
-
import {
|
|
2599
|
+
import { execFile as execFile2, spawn as spawn3 } from "node:child_process";
|
|
2600
|
+
import { existsSync as existsSync4, mkdtempSync as mkdtempSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
|
|
2601
|
+
import { createServer as createServer2 } from "node:net";
|
|
2602
|
+
import { tmpdir as tmpdir3 } from "node:os";
|
|
2603
|
+
import { join as join5 } from "node:path";
|
|
2604
|
+
import { promisify as promisify2 } from "node:util";
|
|
2605
|
+
var exec2 = promisify2(execFile2);
|
|
2143
2606
|
var PREVIEW_MAX_TURNS = 60;
|
|
2144
2607
|
var CAPTURE_TIMEOUT_MS = 3e4;
|
|
2145
2608
|
var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
|
|
2609
|
+
var INSTALL_TIMEOUT_MS = 3e5;
|
|
2610
|
+
var MANIFEST_REL = ".flumecode/plugins/ui-preview.json";
|
|
2146
2611
|
var VIEWPORTS = [
|
|
2147
2612
|
{ name: "mobile", width: 375, height: 812 },
|
|
2148
2613
|
{ name: "tablet", width: 768, height: 1024 },
|
|
2149
2614
|
{ name: "desktop", width: 1280, height: 800 }
|
|
2150
2615
|
];
|
|
2151
|
-
var
|
|
2152
|
-
"
|
|
2153
|
-
"
|
|
2154
|
-
"
|
|
2155
|
-
"
|
|
2156
|
-
"
|
|
2157
|
-
"
|
|
2158
|
-
"
|
|
2159
|
-
"
|
|
2160
|
-
"
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
if (UI_EXTENSIONS.has(ext)) return true;
|
|
2170
|
-
}
|
|
2171
|
-
return false;
|
|
2172
|
-
}
|
|
2173
|
-
function detectFramework(dir) {
|
|
2174
|
-
const pm = detectPackageManager(dir);
|
|
2175
|
-
const pkgPath = join5(dir, "package.json");
|
|
2176
|
-
if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
|
|
2177
|
-
let pkg;
|
|
2616
|
+
var DEFAULT_UI_GLOBS = [
|
|
2617
|
+
"**/*.tsx",
|
|
2618
|
+
"**/*.jsx",
|
|
2619
|
+
"**/*.vue",
|
|
2620
|
+
"**/*.svelte",
|
|
2621
|
+
"**/*.astro",
|
|
2622
|
+
"**/*.css",
|
|
2623
|
+
"**/*.scss",
|
|
2624
|
+
"**/*.sass",
|
|
2625
|
+
"**/*.less",
|
|
2626
|
+
"**/*.html",
|
|
2627
|
+
"**/*.htm",
|
|
2628
|
+
"**/*.erb",
|
|
2629
|
+
"**/*.haml",
|
|
2630
|
+
"**/*.slim"
|
|
2631
|
+
];
|
|
2632
|
+
async function buildPreview(ctx, dir, committedFileNames, config, abort) {
|
|
2633
|
+
let manifest;
|
|
2178
2634
|
try {
|
|
2179
|
-
|
|
2180
|
-
} catch {
|
|
2181
|
-
return {
|
|
2635
|
+
manifest = readPreviewManifest(dir);
|
|
2636
|
+
} catch (err) {
|
|
2637
|
+
return { status: "failed", reason: `UI Preview plugin manifest is invalid: ${message(err)}` };
|
|
2638
|
+
}
|
|
2639
|
+
if (!manifest) {
|
|
2640
|
+
return {
|
|
2641
|
+
status: "skipped",
|
|
2642
|
+
reason: `UI Preview plugin not initialized \u2014 a repo member can enable it in repo settings (${MANIFEST_REL} is absent).`
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
if (!changedFilesTouchUi(committedFileNames, manifest.uiGlobs)) {
|
|
2646
|
+
return { status: "skipped", reason: "No UI files were changed in this run." };
|
|
2647
|
+
}
|
|
2648
|
+
if (!manifest.devServer.trim()) {
|
|
2649
|
+
return {
|
|
2650
|
+
status: "blocked",
|
|
2651
|
+
reason: "The UI Preview plugin needs manual setup before previews can run.",
|
|
2652
|
+
...manifest.manualSteps ? { manualSteps: manifest.manualSteps } : {}
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
try {
|
|
2656
|
+
const { previewId, entrypoint } = await runPreviewPass(
|
|
2657
|
+
ctx,
|
|
2658
|
+
dir,
|
|
2659
|
+
committedFileNames,
|
|
2660
|
+
manifest,
|
|
2661
|
+
config,
|
|
2662
|
+
abort
|
|
2663
|
+
);
|
|
2664
|
+
return { status: "ready", previewId, entrypoint };
|
|
2665
|
+
} catch (err) {
|
|
2666
|
+
if (manifest.manualSteps) {
|
|
2667
|
+
return { status: "blocked", reason: message(err), manualSteps: manifest.manualSteps };
|
|
2668
|
+
}
|
|
2669
|
+
return { status: "failed", reason: message(err) };
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
function readPreviewManifest(repoRoot) {
|
|
2673
|
+
const path = join5(repoRoot, MANIFEST_REL);
|
|
2674
|
+
if (!existsSync4(path)) return null;
|
|
2675
|
+
let raw;
|
|
2676
|
+
try {
|
|
2677
|
+
raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
2678
|
+
} catch (err) {
|
|
2679
|
+
throw new Error(`${MANIFEST_REL} is not valid JSON: ${message(err)}`);
|
|
2182
2680
|
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2681
|
+
if (typeof raw !== "object" || raw === null) {
|
|
2682
|
+
throw new Error(`${MANIFEST_REL} must be a JSON object`);
|
|
2683
|
+
}
|
|
2684
|
+
const o = raw;
|
|
2685
|
+
const devServer = typeof o.devServer === "string" ? o.devServer : "";
|
|
2686
|
+
const manualSteps = typeof o.manualSteps === "string" ? o.manualSteps : null;
|
|
2687
|
+
if (!devServer.trim() && !manualSteps) {
|
|
2688
|
+
throw new Error(`${MANIFEST_REL} must declare either "devServer" or "manualSteps"`);
|
|
2689
|
+
}
|
|
2690
|
+
const showcaseRaw = typeof o.showcase === "object" && o.showcase !== null ? o.showcase : {};
|
|
2691
|
+
return {
|
|
2692
|
+
version: typeof o.version === "number" ? o.version : 1,
|
|
2693
|
+
appDir: typeof o.appDir === "string" && o.appDir.trim() ? o.appDir : ".",
|
|
2694
|
+
install: stringArray(o.install),
|
|
2695
|
+
devServer,
|
|
2696
|
+
readyPath: typeof o.readyPath === "string" && o.readyPath.trim() ? o.readyPath : "/",
|
|
2697
|
+
...isStringRecord(o.env) ? { env: o.env } : {},
|
|
2698
|
+
uiGlobs: stringArray(o.uiGlobs),
|
|
2699
|
+
showcase: {
|
|
2700
|
+
kind: typeof showcaseRaw.kind === "string" ? showcaseRaw.kind : "custom",
|
|
2701
|
+
instructions: typeof showcaseRaw.instructions === "string" ? showcaseRaw.instructions : ""
|
|
2702
|
+
},
|
|
2703
|
+
manualSteps
|
|
2186
2704
|
};
|
|
2187
|
-
const family = pickFamily(deps);
|
|
2188
|
-
const port = 0;
|
|
2189
|
-
const devCmd = family ? pickDevCmd(family, port) : null;
|
|
2190
|
-
return { pm, family, devCmd };
|
|
2191
2705
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2706
|
+
function stringArray(v) {
|
|
2707
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
2708
|
+
}
|
|
2709
|
+
function isStringRecord(v) {
|
|
2710
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
|
2711
|
+
}
|
|
2712
|
+
function changedFilesTouchUi(stat, uiGlobs = []) {
|
|
2713
|
+
const files = stat.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2714
|
+
const regexes = (uiGlobs.length ? uiGlobs : DEFAULT_UI_GLOBS).map(globToRegExp);
|
|
2715
|
+
return files.some((file) => regexes.some((re) => re.test(file)));
|
|
2716
|
+
}
|
|
2717
|
+
function globToRegExp(glob) {
|
|
2718
|
+
let re = "";
|
|
2719
|
+
for (let i = 0; i < glob.length; i++) {
|
|
2720
|
+
const c = glob[i];
|
|
2721
|
+
if (c === "*") {
|
|
2722
|
+
if (glob[i + 1] === "*") {
|
|
2723
|
+
i++;
|
|
2724
|
+
if (glob[i + 1] === "/") {
|
|
2725
|
+
i++;
|
|
2726
|
+
re += "(?:.*/)?";
|
|
2727
|
+
} else {
|
|
2728
|
+
re += ".*";
|
|
2729
|
+
}
|
|
2730
|
+
} else {
|
|
2731
|
+
re += "[^/]*";
|
|
2732
|
+
}
|
|
2733
|
+
} else if (c === "?") {
|
|
2734
|
+
re += "[^/]";
|
|
2735
|
+
} else if (".+^${}()|[]\\".includes(c)) {
|
|
2736
|
+
re += `\\${c}`;
|
|
2737
|
+
} else {
|
|
2738
|
+
re += c;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
return new RegExp(`^${re}$`);
|
|
2742
|
+
}
|
|
2743
|
+
async function runPreviewPass(ctx, dir, committedFileNames, manifest, config, abort) {
|
|
2744
|
+
const appDir = manifest.appDir === "." ? dir : join5(dir, manifest.appDir);
|
|
2745
|
+
await assertCleanTree(dir);
|
|
2746
|
+
const scratch = mkdtempSync2(join5(tmpdir3(), "flume-showcase-"));
|
|
2747
|
+
const sentinelPath = join5(scratch, ".showcase-path");
|
|
2194
2748
|
try {
|
|
2195
|
-
gitExclude(dir, tmpRoute);
|
|
2196
2749
|
await runClaudeCode({
|
|
2197
2750
|
cwd: dir,
|
|
2198
|
-
prompt: buildPreviewPrompt(ctx, committedFileNames,
|
|
2751
|
+
prompt: buildPreviewPrompt(ctx, committedFileNames, manifest, sentinelPath),
|
|
2199
2752
|
permissionMode: ctx.permissionMode,
|
|
2200
2753
|
abortController: abort,
|
|
2201
2754
|
maxTurns: PREVIEW_MAX_TURNS
|
|
2202
2755
|
});
|
|
2203
|
-
const showcasePath =
|
|
2756
|
+
const showcasePath = readSentinel(sentinelPath);
|
|
2204
2757
|
if (!showcasePath) {
|
|
2205
|
-
throw new Error("preview-ui skill did not
|
|
2206
|
-
}
|
|
2207
|
-
const framework = detectFramework(dir);
|
|
2208
|
-
if (!framework.family) {
|
|
2209
|
-
throw new Error("unsupported framework \u2014 skipping preview");
|
|
2758
|
+
throw new Error("preview-ui skill did not record a showcase URL path \u2014 skipping preview");
|
|
2210
2759
|
}
|
|
2760
|
+
await runInstall(appDir, manifest.install);
|
|
2211
2761
|
const port = await findFreePort();
|
|
2212
|
-
const devServer = await startDevServer(
|
|
2762
|
+
const devServer = await startDevServer(appDir, manifest, port);
|
|
2213
2763
|
try {
|
|
2214
2764
|
const url = `http://localhost:${port}${showcasePath}`;
|
|
2215
2765
|
const shots = await captureWithChromium(url, VIEWPORTS);
|
|
@@ -2220,111 +2770,52 @@ async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
|
|
|
2220
2770
|
await devServer.stop();
|
|
2221
2771
|
}
|
|
2222
2772
|
} finally {
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
}
|
|
2226
|
-
function pickFamily(deps) {
|
|
2227
|
-
if (deps["next"]) return "next";
|
|
2228
|
-
if (deps["@sveltejs/kit"]) return "svelte-kit";
|
|
2229
|
-
if (deps["nuxt"]) return "nuxt";
|
|
2230
|
-
if (deps["astro"]) return "astro";
|
|
2231
|
-
if (deps["react-scripts"]) return "cra";
|
|
2232
|
-
if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
|
|
2233
|
-
if (deps["vite"]) {
|
|
2234
|
-
if (deps["vue"]) return "vite-vue";
|
|
2235
|
-
if (deps["svelte"]) return "vite-svelte";
|
|
2236
|
-
if (deps["react"]) return "vite-react";
|
|
2237
|
-
return "vite-react";
|
|
2238
|
-
}
|
|
2239
|
-
return null;
|
|
2240
|
-
}
|
|
2241
|
-
function pickDevCmd(family, port) {
|
|
2242
|
-
switch (family) {
|
|
2243
|
-
case "next":
|
|
2244
|
-
return ["next", "dev", "-p", String(port)];
|
|
2245
|
-
case "vite-react":
|
|
2246
|
-
case "vite-vue":
|
|
2247
|
-
case "vite-svelte":
|
|
2248
|
-
return ["vite", "--port", String(port)];
|
|
2249
|
-
case "nuxt":
|
|
2250
|
-
return ["nuxt", "dev", "--port", String(port)];
|
|
2251
|
-
case "svelte-kit":
|
|
2252
|
-
return ["vite", "--port", String(port)];
|
|
2253
|
-
case "astro":
|
|
2254
|
-
return ["astro", "dev", "--port", String(port)];
|
|
2255
|
-
case "cra":
|
|
2256
|
-
return ["react-scripts", "start"];
|
|
2257
|
-
case "remix":
|
|
2258
|
-
return ["remix", "dev", "--port", String(port)];
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2261
|
-
function mkTempRouteDir(dir) {
|
|
2262
|
-
const suffix = Math.random().toString(36).slice(2, 10);
|
|
2263
|
-
const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
|
|
2264
|
-
mkdirSync2(tmpRoute, { recursive: true });
|
|
2265
|
-
return tmpRoute;
|
|
2266
|
-
}
|
|
2267
|
-
function gitExclude(dir, tmpRoute) {
|
|
2268
|
-
const excludePath = join5(dir, ".git", "info", "exclude");
|
|
2269
|
-
const rel = relative(dir, tmpRoute);
|
|
2270
|
-
appendFileSync(excludePath, `
|
|
2271
|
-
${rel}
|
|
2272
|
-
`);
|
|
2773
|
+
await restoreTree(dir);
|
|
2774
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
2775
|
+
}
|
|
2273
2776
|
}
|
|
2274
|
-
function
|
|
2275
|
-
const
|
|
2276
|
-
if (
|
|
2277
|
-
|
|
2278
|
-
|
|
2777
|
+
async function assertCleanTree(dir) {
|
|
2778
|
+
const { stdout: stdout2 } = await exec2("git", ["-C", dir, "status", "--porcelain"]);
|
|
2779
|
+
if (stdout2.trim()) {
|
|
2780
|
+
throw new Error("working tree is not clean before the showcase pass \u2014 refusing to restore");
|
|
2781
|
+
}
|
|
2279
2782
|
}
|
|
2280
|
-
function
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
reject(new Error("Could not determine free port"));
|
|
2288
|
-
return;
|
|
2289
|
-
}
|
|
2290
|
-
const port = addr.port;
|
|
2291
|
-
server.close((err) => {
|
|
2292
|
-
if (err) reject(err);
|
|
2293
|
-
else resolve(port);
|
|
2294
|
-
});
|
|
2295
|
-
});
|
|
2296
|
-
server.on("error", reject);
|
|
2297
|
-
});
|
|
2783
|
+
async function restoreTree(dir) {
|
|
2784
|
+
try {
|
|
2785
|
+
await exec2("git", ["-C", dir, "checkout", "--", "."]);
|
|
2786
|
+
await exec2("git", ["-C", dir, "clean", "-fd"]);
|
|
2787
|
+
} catch (err) {
|
|
2788
|
+
console.warn(` preview: working-tree restore failed: ${message(err)}`);
|
|
2789
|
+
}
|
|
2298
2790
|
}
|
|
2299
|
-
function
|
|
2791
|
+
async function runInstall(appDir, commands) {
|
|
2792
|
+
for (const cmd of commands) {
|
|
2793
|
+
try {
|
|
2794
|
+
await runShell(cmd, appDir, {}, INSTALL_TIMEOUT_MS);
|
|
2795
|
+
} catch (err) {
|
|
2796
|
+
console.warn(` preview: install step "${cmd}" failed: ${message(err)}`);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function startDevServer(appDir, manifest, port) {
|
|
2300
2801
|
return new Promise((resolve, reject) => {
|
|
2301
|
-
const
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
cwd: dir,
|
|
2308
|
-
env: { ...process.env, PORT: String(port) },
|
|
2309
|
-
stdio: "ignore"
|
|
2802
|
+
const command2 = manifest.devServer.replaceAll("{PORT}", String(port));
|
|
2803
|
+
const proc = spawn3(command2, {
|
|
2804
|
+
cwd: appDir,
|
|
2805
|
+
shell: true,
|
|
2806
|
+
stdio: "ignore",
|
|
2807
|
+
env: { ...process.env, PORT: String(port), ...manifest.env ?? {} }
|
|
2310
2808
|
});
|
|
2311
2809
|
proc.on("error", reject);
|
|
2312
|
-
const
|
|
2810
|
+
const readyUrl = `http://127.0.0.1:${port}${manifest.readyPath}`;
|
|
2313
2811
|
const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
|
|
2812
|
+
const stop = () => new Promise((res) => {
|
|
2813
|
+
proc.kill();
|
|
2814
|
+
proc.on("close", () => res());
|
|
2815
|
+
setTimeout(res, 500);
|
|
2816
|
+
});
|
|
2314
2817
|
const poll = () => {
|
|
2315
|
-
fetch(
|
|
2316
|
-
if (res.ok || res.status < 500) {
|
|
2317
|
-
resolve({
|
|
2318
|
-
stop: () => new Promise((res2) => {
|
|
2319
|
-
proc.kill();
|
|
2320
|
-
proc.on("close", () => res2());
|
|
2321
|
-
setTimeout(res2, 500);
|
|
2322
|
-
})
|
|
2323
|
-
});
|
|
2324
|
-
} else {
|
|
2325
|
-
scheduleNext();
|
|
2326
|
-
}
|
|
2327
|
-
}).catch(() => scheduleNext());
|
|
2818
|
+
fetch(readyUrl).then((r) => r.ok || r.status < 500 ? resolve({ stop }) : scheduleNext()).catch(() => scheduleNext());
|
|
2328
2819
|
};
|
|
2329
2820
|
const scheduleNext = () => {
|
|
2330
2821
|
if (Date.now() > deadline) {
|
|
@@ -2339,6 +2830,50 @@ function startDevServer(dir, framework, port) {
|
|
|
2339
2830
|
setTimeout(poll, 2e3);
|
|
2340
2831
|
});
|
|
2341
2832
|
}
|
|
2833
|
+
function runShell(command2, cwd, env, timeoutMs) {
|
|
2834
|
+
return new Promise((resolve, reject) => {
|
|
2835
|
+
const proc = spawn3(command2, {
|
|
2836
|
+
cwd,
|
|
2837
|
+
shell: true,
|
|
2838
|
+
stdio: "ignore",
|
|
2839
|
+
env: { ...process.env, ...env }
|
|
2840
|
+
});
|
|
2841
|
+
const timer = setTimeout(() => {
|
|
2842
|
+
proc.kill();
|
|
2843
|
+
reject(new Error(`"${command2}" timed out after ${timeoutMs}ms`));
|
|
2844
|
+
}, timeoutMs);
|
|
2845
|
+
proc.on("error", (err) => {
|
|
2846
|
+
clearTimeout(timer);
|
|
2847
|
+
reject(err);
|
|
2848
|
+
});
|
|
2849
|
+
proc.on("close", (code) => {
|
|
2850
|
+
clearTimeout(timer);
|
|
2851
|
+
if (code === 0) resolve();
|
|
2852
|
+
else reject(new Error(`"${command2}" exited with code ${code}`));
|
|
2853
|
+
});
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
function readSentinel(sentinelPath) {
|
|
2857
|
+
if (!existsSync4(sentinelPath)) return null;
|
|
2858
|
+
const content = readFileSync5(sentinelPath, "utf8").trim();
|
|
2859
|
+
return content.length > 0 ? content : null;
|
|
2860
|
+
}
|
|
2861
|
+
function findFreePort() {
|
|
2862
|
+
return new Promise((resolve, reject) => {
|
|
2863
|
+
const server = createServer2();
|
|
2864
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2865
|
+
const addr = server.address();
|
|
2866
|
+
if (!addr || typeof addr === "string") {
|
|
2867
|
+
server.close();
|
|
2868
|
+
reject(new Error("Could not determine free port"));
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
const port = addr.port;
|
|
2872
|
+
server.close((err) => err ? reject(err) : resolve(port));
|
|
2873
|
+
});
|
|
2874
|
+
server.on("error", reject);
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2342
2877
|
async function captureWithChromium(url, viewports) {
|
|
2343
2878
|
const { chromium } = await import("playwright");
|
|
2344
2879
|
const browser = await chromium.launch({ headless: true });
|
|
@@ -2378,6 +2913,9 @@ async function uploadAll(urls, keys, bytes) {
|
|
|
2378
2913
|
})
|
|
2379
2914
|
);
|
|
2380
2915
|
}
|
|
2916
|
+
function message(err) {
|
|
2917
|
+
return err instanceof Error ? err.message : String(err);
|
|
2918
|
+
}
|
|
2381
2919
|
|
|
2382
2920
|
// src/run.ts
|
|
2383
2921
|
var IDLE_MS = 5e3;
|
|
@@ -2686,18 +3224,26 @@ ${reply}`;
|
|
|
2686
3224
|
rebase: !resumed
|
|
2687
3225
|
});
|
|
2688
3226
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
2689
|
-
|
|
3227
|
+
let fullDiff;
|
|
3228
|
+
if (report && outcome.kind !== "none") {
|
|
3229
|
+
try {
|
|
3230
|
+
fullDiff = (await captureFullDiff(ctx, dir)).trim() || void 0;
|
|
3231
|
+
} catch (err) {
|
|
3232
|
+
console.warn(` full-diff capture skipped: ${errorMessage2(err)}`);
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
const finalReport = report ? {
|
|
3236
|
+
...report,
|
|
3237
|
+
...conflictResolution ? { conflictResolution } : {},
|
|
3238
|
+
...fullDiff ? { fullDiff } : {}
|
|
3239
|
+
} : report;
|
|
2690
3240
|
let preview;
|
|
2691
3241
|
if (outcome.kind !== "none") {
|
|
3242
|
+
console.log(` \u2026checking UI preview for implement ${ctx.jobId}`);
|
|
2692
3243
|
const committedFileNames = await gitCommittedFiles(dir);
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
|
|
2697
|
-
} catch (err) {
|
|
2698
|
-
console.warn(` preview skipped: ${errorMessage2(err)}`);
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
3244
|
+
preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
|
|
3245
|
+
if (preview.status !== "ready")
|
|
3246
|
+
console.log(` UI preview ${preview.status}: ${preview.reason}`);
|
|
2701
3247
|
}
|
|
2702
3248
|
return {
|
|
2703
3249
|
text: reply,
|
|
@@ -2721,7 +3267,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
2721
3267
|
});
|
|
2722
3268
|
const report = result.report ?? void 0;
|
|
2723
3269
|
let reply = (report ? renderReport(report) : result.text.trim()) || "(the agent produced no reply)";
|
|
2724
|
-
if (result.plans?.length) reply = result.plans[0] ?? reply;
|
|
3270
|
+
if (result.plans?.length) reply = result.plans[0]?.body ?? reply;
|
|
2725
3271
|
if (installResult.status === "failed") {
|
|
2726
3272
|
reply += `
|
|
2727
3273
|
|
|
@@ -2760,15 +3306,11 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
2760
3306
|
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
2761
3307
|
let preview;
|
|
2762
3308
|
if (outcome.kind !== "none") {
|
|
3309
|
+
console.log(` \u2026checking UI preview for revise ${ctx.jobId}`);
|
|
2763
3310
|
const committedFileNames = await gitCommittedFiles(dir);
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
|
|
2768
|
-
} catch (err) {
|
|
2769
|
-
console.warn(` preview skipped: ${errorMessage2(err)}`);
|
|
2770
|
-
}
|
|
2771
|
-
}
|
|
3311
|
+
preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
|
|
3312
|
+
if (preview.status !== "ready")
|
|
3313
|
+
console.log(` UI preview ${preview.status}: ${preview.reason}`);
|
|
2772
3314
|
}
|
|
2773
3315
|
return {
|
|
2774
3316
|
text: reply,
|
|
@@ -2978,37 +3520,55 @@ function formatJobError(ctx, err) {
|
|
|
2978
3520
|
|
|
2979
3521
|
// src/cli.ts
|
|
2980
3522
|
init_version();
|
|
2981
|
-
import
|
|
3523
|
+
import React6 from "react";
|
|
2982
3524
|
var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
|
|
2983
3525
|
async function login(args) {
|
|
2984
3526
|
const flags = parseFlags(args);
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
serverUrl = answer || DEFAULT_SERVER;
|
|
2992
|
-
}
|
|
2993
|
-
if (!token) {
|
|
2994
|
-
token = (await rl.question("Runner token (flrun_\u2026): ")).trim();
|
|
2995
|
-
}
|
|
2996
|
-
rl.close();
|
|
3527
|
+
const serverUrl = (flags.server ?? DEFAULT_SERVER).replace(/\/+$/, "");
|
|
3528
|
+
const explicitRunner = flags.runnerToken ?? flags.token;
|
|
3529
|
+
const explicitCli = flags.cliToken;
|
|
3530
|
+
if (explicitRunner || explicitCli) {
|
|
3531
|
+
saveCredentials(serverUrl, { runnerToken: explicitRunner, cliToken: explicitCli });
|
|
3532
|
+
return;
|
|
2997
3533
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3534
|
+
const scope = flags.cliOnly ? "cli" : flags.runnerOnly ? "runner" : "cli,runner";
|
|
3535
|
+
console.log(`Logging in to ${serverUrl} (override with --server)
|
|
3536
|
+
`);
|
|
3537
|
+
let result;
|
|
3538
|
+
try {
|
|
3539
|
+
result = await browserLogin({ serverUrl, scope });
|
|
3540
|
+
} catch (err) {
|
|
3541
|
+
console.error(`
|
|
3542
|
+
Login failed: ${err.message}`);
|
|
3543
|
+
console.error("\nOr log in non-interactively with tokens from the web app:");
|
|
3544
|
+
console.error(" flumecode login --server <url> --runner-token flrun_\u2026 --cli-token flcli_\u2026");
|
|
3001
3545
|
process.exit(1);
|
|
3002
3546
|
}
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3547
|
+
saveCredentials(serverUrl, result);
|
|
3548
|
+
}
|
|
3549
|
+
function saveCredentials(serverUrl, creds) {
|
|
3550
|
+
const existing = readConfig();
|
|
3551
|
+
const config = { serverUrl, token: creds.runnerToken ?? existing?.token ?? "" };
|
|
3552
|
+
const apiToken = creds.cliToken ?? existing?.apiToken;
|
|
3553
|
+
if (apiToken) config.apiToken = apiToken;
|
|
3554
|
+
writeConfig(config);
|
|
3555
|
+
const have = [];
|
|
3556
|
+
if (config.token) have.push("runner token");
|
|
3557
|
+
if (config.apiToken) have.push("CLI token");
|
|
3558
|
+
console.log(`Saved ${have.join(" + ") || "server URL"} to ${configPath}.`);
|
|
3559
|
+
if (config.token && config.apiToken) {
|
|
3560
|
+
console.log("Next: `flumecode start --tui` (worker + TUI together on this machine)");
|
|
3561
|
+
} else if (config.apiToken) {
|
|
3562
|
+
console.log("Next: `flumecode tui`");
|
|
3563
|
+
} else if (config.token) {
|
|
3564
|
+
console.log("Next: `flumecode start`");
|
|
3565
|
+
}
|
|
3006
3566
|
}
|
|
3007
3567
|
async function start() {
|
|
3008
3568
|
process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
3009
3569
|
const config = readConfig();
|
|
3010
|
-
if (!config) {
|
|
3011
|
-
console.error("
|
|
3570
|
+
if (!config || !config.token) {
|
|
3571
|
+
console.error("No runner token configured. Run: flumecode login");
|
|
3012
3572
|
process.exit(1);
|
|
3013
3573
|
}
|
|
3014
3574
|
await pollLoop(config);
|
|
@@ -3026,13 +3586,57 @@ async function runTui(args) {
|
|
|
3026
3586
|
writeConfig(config);
|
|
3027
3587
|
const { render } = await import("ink");
|
|
3028
3588
|
const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
|
|
3029
|
-
render(
|
|
3589
|
+
render(React6.createElement(App2, { config }));
|
|
3590
|
+
}
|
|
3591
|
+
async function startWithTui(args) {
|
|
3592
|
+
process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
3593
|
+
const flags = parseFlags(args);
|
|
3594
|
+
const existing = readConfig();
|
|
3595
|
+
if (!existing || !existing.token) {
|
|
3596
|
+
console.error("No runner token configured. Run `flumecode login` first.");
|
|
3597
|
+
process.exit(1);
|
|
3598
|
+
}
|
|
3599
|
+
const config = { ...existing };
|
|
3600
|
+
if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
|
|
3601
|
+
if (flags.token) config.apiToken = flags.token;
|
|
3602
|
+
if (!config.apiToken) {
|
|
3603
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
3604
|
+
config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
|
|
3605
|
+
rl.close();
|
|
3606
|
+
}
|
|
3607
|
+
writeConfig(config);
|
|
3608
|
+
const logPath = join7(homedir2(), ".flume", "runner.log");
|
|
3609
|
+
const fd = openSync(logPath, "a");
|
|
3610
|
+
const entry = process.argv[1];
|
|
3611
|
+
const worker = entry ? spawn4(process.execPath, [...process.execArgv, entry, "start"], {
|
|
3612
|
+
stdio: ["ignore", fd, fd],
|
|
3613
|
+
env: process.env
|
|
3614
|
+
}) : null;
|
|
3615
|
+
const cleanup2 = () => {
|
|
3616
|
+
try {
|
|
3617
|
+
worker?.kill();
|
|
3618
|
+
} catch {
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
process.on("exit", cleanup2);
|
|
3622
|
+
console.log(`Runner worker started in the background (logs: ${logPath}).`);
|
|
3623
|
+
const { render } = await import("ink");
|
|
3624
|
+
const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
|
|
3625
|
+
const app = render(React6.createElement(App2, { config }));
|
|
3626
|
+
await app.waitUntilExit();
|
|
3627
|
+
cleanup2();
|
|
3628
|
+
process.exit(0);
|
|
3030
3629
|
}
|
|
3031
3630
|
function parseFlags(args) {
|
|
3032
3631
|
const out = {};
|
|
3033
3632
|
for (let i = 0; i < args.length; i++) {
|
|
3034
|
-
|
|
3035
|
-
|
|
3633
|
+
const a = args[i];
|
|
3634
|
+
if (a === "--server") out.server = args[++i];
|
|
3635
|
+
else if (a === "--token") out.token = args[++i];
|
|
3636
|
+
else if (a === "--cli-token") out.cliToken = args[++i];
|
|
3637
|
+
else if (a === "--runner-token") out.runnerToken = args[++i];
|
|
3638
|
+
else if (a === "--cli-only") out.cliOnly = true;
|
|
3639
|
+
else if (a === "--runner-only") out.runnerOnly = true;
|
|
3036
3640
|
}
|
|
3037
3641
|
return out;
|
|
3038
3642
|
}
|
|
@@ -3044,15 +3648,37 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
3044
3648
|
} else if (command === "login") {
|
|
3045
3649
|
void login(rest);
|
|
3046
3650
|
} else if (command === "start") {
|
|
3047
|
-
void
|
|
3651
|
+
if (rest.includes("--tui")) void startWithTui(rest);
|
|
3652
|
+
else void start();
|
|
3048
3653
|
} else if (command === "tui") {
|
|
3049
3654
|
void runTui(rest);
|
|
3050
3655
|
} else {
|
|
3051
3656
|
console.log(`FlumeCode runner v${RUNNER_VERSION}`);
|
|
3052
|
-
console.log("
|
|
3053
|
-
console.log("
|
|
3054
|
-
console.log("
|
|
3055
|
-
console.log("
|
|
3056
|
-
console.log("
|
|
3657
|
+
console.log("");
|
|
3658
|
+
console.log("Usage: flumecode <command> [--server URL] [--token TOKEN]");
|
|
3659
|
+
console.log("");
|
|
3660
|
+
console.log("Commands:");
|
|
3661
|
+
console.log(" login Authorize this machine in the browser (GitHub sign-in), provisioning");
|
|
3662
|
+
console.log(" both the runner token (flrun_\u2026) and CLI token (flcli_\u2026) into config.");
|
|
3663
|
+
console.log(
|
|
3664
|
+
" Flags: --cli-only / --runner-only \xB7 or non-interactive --runner-token /"
|
|
3665
|
+
);
|
|
3666
|
+
console.log(" --cli-token to paste tokens straight from the web app.");
|
|
3667
|
+
console.log(" start Run the worker: claim queued jobs and drive your local Claude Code");
|
|
3668
|
+
console.log(" start --tui All-in-one (single machine): run the worker in the background +");
|
|
3669
|
+
console.log(" the interactive TUI in the foreground");
|
|
3670
|
+
console.log(
|
|
3671
|
+
" tui Open the interactive terminal UI \u2014 talk through a repo, shape a request,"
|
|
3672
|
+
);
|
|
3673
|
+
console.log(
|
|
3674
|
+
" answer the agent's questions, and start implementing. Authenticates as"
|
|
3675
|
+
);
|
|
3676
|
+
console.log(" you with a CLI token (flcli_\u2026) from the web app's Settings.");
|
|
3677
|
+
console.log(" --version Print the runner version");
|
|
3678
|
+
console.log("");
|
|
3679
|
+
console.log(
|
|
3680
|
+
"`start` (the worker) and `tui` (a client) are separate roles with different tokens;"
|
|
3681
|
+
);
|
|
3682
|
+
console.log("`start --tui` just runs both together on one machine. See apps/runner/docs/tui.md.");
|
|
3057
3683
|
process.exit(command ? 1 : 0);
|
|
3058
3684
|
}
|