@flumecode/runner 0.22.0 → 0.23.1

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/dist/cli.js CHANGED
@@ -1,4 +1,653 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/version.ts
13
+ import { readFileSync as readFileSync2 } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ function readVersion() {
16
+ try {
17
+ const path = fileURLToPath(new URL("../package.json", import.meta.url));
18
+ const pkg = JSON.parse(readFileSync2(path, "utf8"));
19
+ return pkg.version ?? "unknown";
20
+ } catch {
21
+ return "unknown";
22
+ }
23
+ }
24
+ function compareVersions(a, b) {
25
+ const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
26
+ const pa = parse(a);
27
+ const pb = parse(b);
28
+ const len = Math.max(pa.length, pb.length);
29
+ for (let i = 0; i < len; i++) {
30
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
31
+ if (diff !== 0) return diff;
32
+ }
33
+ return 0;
34
+ }
35
+ var RUNNER_VERSION, RUNNER_VERSION_HEADER, RUNNER_MIN_VERSION_HEADER, RUNNER_LATEST_VERSION_HEADER;
36
+ var init_version = __esm({
37
+ "src/version.ts"() {
38
+ "use strict";
39
+ RUNNER_VERSION = readVersion();
40
+ RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
41
+ RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
42
+ RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
43
+ }
44
+ });
45
+
46
+ // src/tui-api.ts
47
+ function authHeaders(cfg) {
48
+ return {
49
+ Authorization: `Bearer ${cfg.apiToken ?? ""}`,
50
+ "x-flumecode-runner-version": RUNNER_VERSION
51
+ };
52
+ }
53
+ async function safeText2(res) {
54
+ try {
55
+ return await res.text();
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ async function get(cfg, path) {
61
+ const res = await fetch(cfg.serverUrl + path, { headers: authHeaders(cfg) });
62
+ if (!res.ok) throw new Error(await safeText2(res));
63
+ return res.json();
64
+ }
65
+ async function post(cfg, path, body) {
66
+ const res = await fetch(cfg.serverUrl + path, {
67
+ method: "POST",
68
+ headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
69
+ body: JSON.stringify(body)
70
+ });
71
+ if (!res.ok) throw new Error(await safeText2(res));
72
+ return res.json();
73
+ }
74
+ var listRepositories, listAgents, listRequests, getRequest, createRequest, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
75
+ var init_tui_api = __esm({
76
+ "src/tui-api.ts"() {
77
+ "use strict";
78
+ init_version();
79
+ listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
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
+ getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
85
+ createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
86
+ answerWidgets = (cfg, messageId, payload) => post(cfg, `/api/cli/messages/${messageId}/answer`, payload);
87
+ acceptPlan = (cfg, messageId, target, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target, mergeBranch });
88
+ startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
89
+ listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
90
+ listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
91
+ }
92
+ });
93
+
94
+ // src/tui/usePoll.ts
95
+ import { useState, useEffect, useRef } from "react";
96
+ function usePoll(fetcher, intervalMs) {
97
+ const [data, setData] = useState(null);
98
+ const fetcherRef = useRef(fetcher);
99
+ fetcherRef.current = fetcher;
100
+ useEffect(() => {
101
+ let cancelled = false;
102
+ function poll() {
103
+ fetcherRef.current().then((result) => {
104
+ if (!cancelled) setData(result);
105
+ }).catch(() => {
106
+ });
107
+ }
108
+ poll();
109
+ const id = setInterval(poll, intervalMs);
110
+ return () => {
111
+ cancelled = true;
112
+ clearInterval(id);
113
+ };
114
+ }, [intervalMs]);
115
+ return data;
116
+ }
117
+ var init_usePoll = __esm({
118
+ "src/tui/usePoll.ts"() {
119
+ "use strict";
120
+ }
121
+ });
122
+
123
+ // src/tui/StatusBar.tsx
124
+ import { Box, Text } from "ink";
125
+ import { jsx, jsxs } from "react/jsx-runtime";
126
+ function StatusBar({ jobs, runners }) {
127
+ const runningJobs = jobs?.filter((j) => j.status === "running").length ?? 0;
128
+ const onlineRunners = runners?.filter((r) => r.status === "online").length ?? 0;
129
+ return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
130
+ "Jobs running: ",
131
+ runningJobs,
132
+ " | Runners online: ",
133
+ onlineRunners
134
+ ] }) });
135
+ }
136
+ var init_StatusBar = __esm({
137
+ "src/tui/StatusBar.tsx"() {
138
+ "use strict";
139
+ }
140
+ });
141
+
142
+ // src/tui/RepoListScreen.tsx
143
+ import { useState as useState2 } from "react";
144
+ import { Box as Box2, Text as Text2, useInput } from "ink";
145
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
146
+ function RepoListScreen({ config, onSelect }) {
147
+ const repos = usePoll(() => listRepositories(config), 1e4);
148
+ const [cursor, setCursor] = useState2(0);
149
+ useInput((input, key) => {
150
+ if (!repos) return;
151
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
152
+ else if (key.downArrow) setCursor((c) => Math.min(repos.length - 1, c + 1));
153
+ else if (key.return) {
154
+ const repo = repos[cursor];
155
+ if (repo) onSelect(repo);
156
+ }
157
+ });
158
+ if (!repos) return /* @__PURE__ */ jsx2(Text2, { children: "Loading repositories\u2026" });
159
+ if (repos.length === 0) return /* @__PURE__ */ jsx2(Text2, { children: "No repositories found." });
160
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
161
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
162
+ repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
163
+ i === cursor ? "\u25B6 " : " ",
164
+ repo.fullName
165
+ ] }, repo.id))
166
+ ] });
167
+ }
168
+ var init_RepoListScreen = __esm({
169
+ "src/tui/RepoListScreen.tsx"() {
170
+ "use strict";
171
+ init_usePoll();
172
+ init_tui_api();
173
+ }
174
+ });
175
+
176
+ // src/tui/RequestListScreen.tsx
177
+ import { useState as useState3 } from "react";
178
+ import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
179
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
180
+ function RequestListScreen({ config, repo, onOpen, onNew, onBack }) {
181
+ const requests = usePoll(() => listRequests(config, repo.id), 5e3);
182
+ const [cursor, setCursor] = useState3(0);
183
+ useInput2((input, key) => {
184
+ if (key.escape) {
185
+ onBack();
186
+ return;
187
+ }
188
+ if (!requests) return;
189
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
190
+ else if (key.downArrow) setCursor((c) => Math.min(requests.length - 1, c + 1));
191
+ else if (key.return) {
192
+ const req = requests[cursor];
193
+ if (req) onOpen(req);
194
+ } else if (input === "n") {
195
+ onNew();
196
+ }
197
+ });
198
+ if (!requests) return /* @__PURE__ */ jsx3(Text3, { children: "Loading requests\u2026" });
199
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
200
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
201
+ repo.fullName,
202
+ " \u2014 Requests (\u2191\u2193 Enter open, n new, Esc back):"
203
+ ] }),
204
+ requests.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No requests yet." }),
205
+ requests.map((req, i) => /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
206
+ i === cursor ? "\u25B6 " : " ",
207
+ "[",
208
+ req.status,
209
+ "] ",
210
+ req.title
211
+ ] }, req.id))
212
+ ] });
213
+ }
214
+ var init_RequestListScreen = __esm({
215
+ "src/tui/RequestListScreen.tsx"() {
216
+ "use strict";
217
+ init_usePoll();
218
+ init_tui_api();
219
+ }
220
+ });
221
+
222
+ // src/tui/NewRequestScreen.tsx
223
+ import { useState as useState4 } from "react";
224
+ import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
225
+ import TextInput from "ink-text-input";
226
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
227
+ function NewRequestScreen({ config, repo, onCreated, onBack }) {
228
+ const agents = usePoll(() => listAgents(config), 3e4);
229
+ const [field, setField] = useState4("title");
230
+ const [title, setTitle] = useState4("");
231
+ const [body, setBody] = useState4("");
232
+ const [agentCursor, setAgentCursor] = useState4(0);
233
+ const [branch, setBranch] = useState4(repo.defaultBranch ?? "");
234
+ const [error, setError] = useState4(null);
235
+ const [submitting, setSubmitting] = useState4(false);
236
+ useInput3((_input, key) => {
237
+ if (key.escape) {
238
+ onBack();
239
+ return;
240
+ }
241
+ if (field === "title" && key.tab) {
242
+ setField("body");
243
+ return;
244
+ }
245
+ if (field === "body" && key.tab) {
246
+ setField("agent");
247
+ return;
248
+ }
249
+ if (field === "agent") {
250
+ if (key.upArrow && agents) setAgentCursor((c) => Math.max(0, c - 1));
251
+ if (key.downArrow && agents) setAgentCursor((c) => Math.min((agents.length || 1) - 1, c + 1));
252
+ if (key.tab) setField("branch");
253
+ }
254
+ if (field === "branch" && key.return && !submitting) {
255
+ void submit();
256
+ }
257
+ });
258
+ async function submit() {
259
+ if (!title.trim() || !agents?.[agentCursor]) return;
260
+ setSubmitting(true);
261
+ setError(null);
262
+ try {
263
+ const result = await createRequest(config, {
264
+ title: title.trim(),
265
+ body: body.trim(),
266
+ agentId: agents[agentCursor].id,
267
+ repoId: repo.id,
268
+ checkoutBranch: branch.trim() || void 0
269
+ });
270
+ onCreated(result.requestId);
271
+ } catch (err) {
272
+ setError(err instanceof Error ? err.message : "Unknown error");
273
+ setSubmitting(false);
274
+ }
275
+ }
276
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
277
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
278
+ "New Request in ",
279
+ repo.fullName,
280
+ " (Tab to advance, Esc back)"
281
+ ] }),
282
+ /* @__PURE__ */ jsxs4(Box4, { children: [
283
+ /* @__PURE__ */ jsx4(Text4, { children: "Title: " }),
284
+ /* @__PURE__ */ jsx4(TextInput, { value: title, onChange: setTitle, focus: field === "title" })
285
+ ] }),
286
+ /* @__PURE__ */ jsxs4(Box4, { children: [
287
+ /* @__PURE__ */ jsx4(Text4, { children: "Body: " }),
288
+ /* @__PURE__ */ jsx4(TextInput, { value: body, onChange: setBody, focus: field === "body" })
289
+ ] }),
290
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
291
+ /* @__PURE__ */ jsxs4(Text4, { children: [
292
+ "Agent",
293
+ field === "agent" ? " (\u2191\u2193 to pick, Tab next)" : "",
294
+ ":"
295
+ ] }),
296
+ (agents ?? []).map((a, i) => /* @__PURE__ */ jsxs4(Text4, { color: field === "agent" && i === agentCursor ? "cyan" : void 0, children: [
297
+ field === "agent" && i === agentCursor ? "\u25B6 " : " ",
298
+ a.name
299
+ ] }, a.id))
300
+ ] }),
301
+ /* @__PURE__ */ jsxs4(Box4, { children: [
302
+ /* @__PURE__ */ jsx4(Text4, { children: "Checkout branch (optional): " }),
303
+ /* @__PURE__ */ jsx4(TextInput, { value: branch, onChange: setBranch, focus: field === "branch" })
304
+ ] }),
305
+ submitting && /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Creating\u2026" }),
306
+ error && /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
307
+ "Error: ",
308
+ error
309
+ ] })
310
+ ] });
311
+ }
312
+ var init_NewRequestScreen = __esm({
313
+ "src/tui/NewRequestScreen.tsx"() {
314
+ "use strict";
315
+ init_usePoll();
316
+ init_tui_api();
317
+ }
318
+ });
319
+
320
+ // src/tui/WidgetAnswerScreen.tsx
321
+ import { useState as useState5 } from "react";
322
+ import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
323
+ import TextInput2 from "ink-text-input";
324
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
325
+ function renderOptionLabel(opt, i, cursor, isSingle, checked) {
326
+ const pointer = i === cursor ? "\u25B6 " : " ";
327
+ if (isSingle) {
328
+ return `${pointer}${opt.label}`;
329
+ }
330
+ return `${pointer}[${checked.has(opt.id) ? "x" : " "}] ${opt.label}`;
331
+ }
332
+ function WidgetAnswerScreen({ config, message, onDone }) {
333
+ const [widgetIndex, setWidgetIndex] = useState5(0);
334
+ const [cursor, setCursor] = useState5(0);
335
+ const [checked, setChecked] = useState5(/* @__PURE__ */ new Set());
336
+ const [customText, setCustomText] = useState5("");
337
+ const [showCustom, setShowCustom] = useState5(false);
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" }];
343
+ if (key.upArrow) {
344
+ setCursor((c) => Math.max(0, c - 1));
345
+ } else if (key.downArrow) {
346
+ setCursor((c) => Math.min(opts2.length - 1, c + 1));
347
+ } else if (key.return) {
348
+ const picked = opts2[cursor];
349
+ if (picked?.id === "__other__") {
350
+ setShowCustom(true);
351
+ } else {
352
+ void advanceOrSubmit(w, picked?.id, void 0, void 0);
353
+ }
354
+ }
355
+ }
356
+ function handleMultiInput(input, key, w) {
357
+ const opts2 = [...w.options, { id: "__other__", label: "Other" }];
358
+ if (key.upArrow) {
359
+ setCursor((c) => Math.max(0, c - 1));
360
+ } else if (key.downArrow) {
361
+ setCursor((c) => Math.min(opts2.length - 1, c + 1));
362
+ } else if (input === " ") {
363
+ const picked = opts2[cursor];
364
+ if (!picked) return;
365
+ if (picked.id === "__other__") {
366
+ setShowCustom((s) => !s);
367
+ } else {
368
+ const next = new Set(checked);
369
+ if (next.has(picked.id)) next.delete(picked.id);
370
+ else next.add(picked.id);
371
+ setChecked(next);
372
+ }
373
+ } else if (key.return) {
374
+ void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
375
+ }
376
+ }
377
+ useInput4((input, key) => {
378
+ if (!widget) return;
379
+ if (widget.type === "single_select") {
380
+ handleSingleInput(input, key, widget);
381
+ } else {
382
+ handleMultiInput(input, key, widget);
383
+ }
384
+ });
385
+ async function advanceOrSubmit(w, optionId, optionIds, custom) {
386
+ const answers = [
387
+ {
388
+ widgetId: w.id,
389
+ ...optionId !== void 0 ? { optionId } : {},
390
+ ...optionIds !== void 0 ? { optionIds } : {},
391
+ ...custom ? { customText: custom } : {}
392
+ }
393
+ ];
394
+ if (widgetIndex < message.widgets.length - 1) {
395
+ setWidgetIndex((i) => i + 1);
396
+ setCursor(0);
397
+ setChecked(/* @__PURE__ */ new Set());
398
+ setCustomText("");
399
+ setShowCustom(false);
400
+ return;
401
+ }
402
+ setSubmitting(true);
403
+ setError(null);
404
+ try {
405
+ await answerWidgets(config, message.id, { requestId: message.requestId, answers });
406
+ onDone();
407
+ } catch (err) {
408
+ setError(err instanceof Error ? err.message : "Unknown error");
409
+ setSubmitting(false);
410
+ }
411
+ }
412
+ if (!widget) return /* @__PURE__ */ jsx5(Text5, { children: "No widgets to answer." });
413
+ const isSingle = widget.type === "single_select";
414
+ const opts = [...widget.options, { id: "__other__", label: "Other" }];
415
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
416
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
417
+ "Widget ",
418
+ widgetIndex + 1,
419
+ "/",
420
+ message.widgets.length,
421
+ ": ",
422
+ widget.question
423
+ ] }),
424
+ widget.body && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: widget.body }),
425
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 to navigate, Enter to select" : "\u2191\u2193 to navigate, Space to toggle, Enter to confirm" }),
426
+ opts.map((opt, i) => /* @__PURE__ */ jsx5(Text5, { color: i === cursor ? "cyan" : void 0, children: renderOptionLabel(opt, i, cursor, isSingle, checked) }, opt.id)),
427
+ showCustom && /* @__PURE__ */ jsxs5(Box5, { children: [
428
+ /* @__PURE__ */ jsx5(Text5, { children: "Custom answer: " }),
429
+ /* @__PURE__ */ jsx5(TextInput2, { value: customText, onChange: setCustomText, focus: showCustom })
430
+ ] }),
431
+ submitting && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Submitting\u2026" }),
432
+ error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
433
+ "Error: ",
434
+ error
435
+ ] })
436
+ ] });
437
+ }
438
+ var init_WidgetAnswerScreen = __esm({
439
+ "src/tui/WidgetAnswerScreen.tsx"() {
440
+ "use strict";
441
+ init_tui_api();
442
+ }
443
+ });
444
+
445
+ // src/tui/ThreadScreen.tsx
446
+ import { useState as useState6 } from "react";
447
+ import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
448
+ import TextInput3 from "ink-text-input";
449
+ import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
450
+ function ThreadScreen({ config, repo, requestId, onBack }) {
451
+ const data = usePoll(() => getRequest(config, requestId), 3e3);
452
+ const [cursor, setCursor] = useState6(0);
453
+ const [overlay, setOverlay] = useState6({ kind: "none" });
454
+ const [targetBranch, setTargetBranch] = useState6(repo.defaultBranch ?? "main");
455
+ const [mergeBranch, setMergeBranch] = useState6(repo.defaultBranch ?? "");
456
+ const [acceptStep, setAcceptStep] = useState6("target");
457
+ const [error, setError] = useState6(null);
458
+ const messages = data?.messages ?? [];
459
+ const sessions = data?.sessions ?? [];
460
+ const selectedMsg = messages[cursor];
461
+ const implementableSession = sessions.find((s) => s.status === "planned" || s.status === "ready");
462
+ useInput5((_input, key) => {
463
+ if (overlay.kind !== "none") return;
464
+ if (key.escape) {
465
+ onBack();
466
+ return;
467
+ }
468
+ if (key.upArrow) {
469
+ setCursor((c) => Math.max(0, c - 1));
470
+ return;
471
+ }
472
+ if (key.downArrow) {
473
+ setCursor((c) => Math.min(messages.length - 1, c + 1));
474
+ return;
475
+ }
476
+ if (key.return && selectedMsg) {
477
+ if (selectedMsg.widgets?.length) {
478
+ const widgets = selectedMsg.widgets;
479
+ setOverlay({ kind: "widget", message: { ...selectedMsg, widgets, requestId } });
480
+ } else if (selectedMsg.type === "plan") {
481
+ setTargetBranch(repo.defaultBranch ?? "main");
482
+ setMergeBranch(repo.defaultBranch ?? "");
483
+ setAcceptStep("target");
484
+ setOverlay({ kind: "accept", messageId: selectedMsg.id });
485
+ }
486
+ }
487
+ if (_input === "i" && implementableSession) {
488
+ void doImplement(implementableSession.id);
489
+ }
490
+ });
491
+ async function doAcceptPlan() {
492
+ if (overlay.kind !== "accept") return;
493
+ try {
494
+ await acceptPlan(
495
+ config,
496
+ overlay.messageId,
497
+ targetBranch.trim() || "main",
498
+ mergeBranch.trim()
499
+ );
500
+ setOverlay({ kind: "accepted" });
501
+ } catch (err) {
502
+ setError(err instanceof Error ? err.message : "Unknown error");
503
+ }
504
+ }
505
+ async function doImplement(sessionId) {
506
+ try {
507
+ await startImplementing(config, sessionId);
508
+ } catch (err) {
509
+ setError(err instanceof Error ? err.message : "Unknown error");
510
+ }
511
+ }
512
+ if (overlay.kind === "widget") {
513
+ return /* @__PURE__ */ jsx6(
514
+ WidgetAnswerScreen,
515
+ {
516
+ config,
517
+ message: overlay.message,
518
+ onDone: () => setOverlay({ kind: "none" })
519
+ }
520
+ );
521
+ }
522
+ if (overlay.kind === "accept") {
523
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
524
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Accept Plan" }),
525
+ /* @__PURE__ */ jsx6(Text6, { children: "Enter target branch (base):" }),
526
+ /* @__PURE__ */ jsx6(
527
+ TextInput3,
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,
539
+ {
540
+ value: mergeBranch,
541
+ onChange: setMergeBranch,
542
+ onSubmit: () => void doAcceptPlan(),
543
+ focus: true
544
+ }
545
+ )
546
+ ] }),
547
+ error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
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 })
568
+ ] });
569
+ }
570
+ var init_ThreadScreen = __esm({
571
+ "src/tui/ThreadScreen.tsx"() {
572
+ "use strict";
573
+ init_usePoll();
574
+ init_tui_api();
575
+ init_WidgetAnswerScreen();
576
+ }
577
+ });
578
+
579
+ // src/tui/App.tsx
580
+ var App_exports = {};
581
+ __export(App_exports, {
582
+ App: () => App
583
+ });
584
+ import { useState as useState7 } from "react";
585
+ import { Box as Box7 } from "ink";
586
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
587
+ function renderScreen(screen, setScreen, config) {
588
+ switch (screen.name) {
589
+ case "repos":
590
+ return /* @__PURE__ */ jsx7(
591
+ RepoListScreen,
592
+ {
593
+ config,
594
+ onSelect: (repo) => setScreen({ name: "requests", repo })
595
+ }
596
+ );
597
+ case "requests":
598
+ return /* @__PURE__ */ jsx7(
599
+ RequestListScreen,
600
+ {
601
+ config,
602
+ 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
+ onBack: () => setScreen({ name: "repos" })
606
+ }
607
+ );
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
+ repo: screen.repo,
624
+ requestId: screen.requestId,
625
+ onBack: () => setScreen({ name: "requests", repo: screen.repo })
626
+ }
627
+ );
628
+ }
629
+ }
630
+ function App({ config }) {
631
+ const [screen, setScreen] = useState7({ name: "repos" });
632
+ const jobs = usePoll(() => listJobs(config), 3e3);
633
+ const runners = usePoll(() => listRunners(config), 3e3);
634
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
635
+ renderScreen(screen, setScreen, config),
636
+ /* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
637
+ ] });
638
+ }
639
+ var init_App = __esm({
640
+ "src/tui/App.tsx"() {
641
+ "use strict";
642
+ init_tui_api();
643
+ init_usePoll();
644
+ init_StatusBar();
645
+ init_RepoListScreen();
646
+ init_RequestListScreen();
647
+ init_NewRequestScreen();
648
+ init_ThreadScreen();
649
+ }
650
+ });
2
651
 
3
652
  // src/cli.ts
4
653
  import { createInterface } from "node:readline/promises";
@@ -15,7 +664,9 @@ function readConfig() {
15
664
  try {
16
665
  const parsed = JSON.parse(readFileSync(configPath, "utf8"));
17
666
  if (!parsed.serverUrl || !parsed.token) return null;
18
- return { serverUrl: parsed.serverUrl, token: parsed.token };
667
+ const config = { serverUrl: parsed.serverUrl, token: parsed.token };
668
+ if (parsed.apiToken) config.apiToken = parsed.apiToken;
669
+ return config;
19
670
  } catch {
20
671
  return null;
21
672
  }
@@ -26,38 +677,11 @@ function writeConfig(config) {
26
677
  }
27
678
 
28
679
  // src/run.ts
29
- import { existsSync as existsSync4 } from "node:fs";
30
- import { join as join5 } from "node:path";
31
-
32
- // src/version.ts
33
- import { readFileSync as readFileSync2 } from "node:fs";
34
- import { fileURLToPath } from "node:url";
35
- function readVersion() {
36
- try {
37
- const path = fileURLToPath(new URL("../package.json", import.meta.url));
38
- const pkg = JSON.parse(readFileSync2(path, "utf8"));
39
- return pkg.version ?? "unknown";
40
- } catch {
41
- return "unknown";
42
- }
43
- }
44
- var RUNNER_VERSION = readVersion();
45
- var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
46
- var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
47
- var RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
48
- function compareVersions(a, b) {
49
- const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
50
- const pa = parse(a);
51
- const pb = parse(b);
52
- const len = Math.max(pa.length, pb.length);
53
- for (let i = 0; i < len; i++) {
54
- const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
55
- if (diff !== 0) return diff;
56
- }
57
- return 0;
58
- }
680
+ import { existsSync as existsSync5 } from "node:fs";
681
+ import { join as join6 } from "node:path";
59
682
 
60
683
  // src/api.ts
684
+ init_version();
61
685
  var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
62
686
  var lastOutdatedWarnAt = 0;
63
687
  var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
@@ -172,6 +796,20 @@ async function fetchRelatedSessions(config, params) {
172
796
  return [];
173
797
  }
174
798
  }
799
+ async function mintUploadUrls(config, jobId, keys) {
800
+ const res = await fetch(`${config.serverUrl}/api/runner/previews/mint-upload-urls`, {
801
+ method: "POST",
802
+ headers: {
803
+ authorization: `Bearer ${config.token}`,
804
+ "content-type": "application/json",
805
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
806
+ },
807
+ body: JSON.stringify({ jobId, keys })
808
+ });
809
+ noteServerVersion(res);
810
+ if (!res.ok) throw new Error(`mint-upload-urls failed: ${res.status} ${await safeText(res)}`);
811
+ return res.json();
812
+ }
175
813
  async function safeText(res) {
176
814
  try {
177
815
  return await res.text();
@@ -320,6 +958,14 @@ var stepSchema = z2.object({
320
958
  "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."
321
959
  )
322
960
  });
961
+ var requirementSchema = z2.object({
962
+ requirement: z2.string().min(1).describe(
963
+ "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
964
+ ),
965
+ acceptanceCriteria: z2.array(z2.string().min(1)).min(1).describe(
966
+ "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
967
+ )
968
+ });
323
969
  var planInputSchema = {
324
970
  title: z2.string().min(1).max(120).describe(
325
971
  "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."
@@ -333,13 +979,10 @@ var planInputSchema = {
333
979
  "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
334
980
  ),
335
981
  assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
336
- requirements: z2.array(z2.string().min(1)).min(1).describe(
337
- "Required, human-readable statements 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. At least 1 required. " + INLINE_CODE_HINT
982
+ requirements: z2.array(requirementSchema).min(1).describe(
983
+ "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
338
984
  ),
339
985
  steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
340
- acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
341
- "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
342
- ),
343
986
  risks: z2.array(z2.string()).describe("Anything that could change the approach."),
344
987
  outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
345
988
  };
@@ -354,7 +997,21 @@ function requireRootCauseForFix(schema) {
354
997
  }
355
998
  });
356
999
  }
357
- var planSchema = requireRootCauseForFix(z2.object(planInputSchema));
1000
+ function requireAtLeastTwoCriteria(schema) {
1001
+ return schema.superRefine((plan, ctx) => {
1002
+ const total = plan.requirements.reduce((sum, r) => sum + r.acceptanceCriteria.length, 0);
1003
+ if (total < 2) {
1004
+ ctx.addIssue({
1005
+ code: z2.ZodIssueCode.custom,
1006
+ path: ["requirements"],
1007
+ message: "At least 2 acceptance criteria total across all requirements are required."
1008
+ });
1009
+ }
1010
+ });
1011
+ }
1012
+ var planSchema = requireAtLeastTwoCriteria(
1013
+ requireRootCauseForFix(z2.object(planInputSchema))
1014
+ );
358
1015
  function renderPlan(plan) {
359
1016
  const lines2 = [];
360
1017
  lines2.push(`# ${plan.title}`);
@@ -381,8 +1038,8 @@ function renderPlan(plan) {
381
1038
  }
382
1039
  lines2.push("");
383
1040
  lines2.push("## Requirements");
384
- for (const requirement of plan.requirements) {
385
- lines2.push(`- ${requirement}`);
1041
+ for (const req of plan.requirements) {
1042
+ lines2.push(`- ${req.requirement}`);
386
1043
  }
387
1044
  lines2.push("");
388
1045
  lines2.push("## Steps");
@@ -407,8 +1064,11 @@ function renderPlan(plan) {
407
1064
  }
408
1065
  lines2.push("");
409
1066
  lines2.push("## Acceptance criteria");
410
- for (const criterion of plan.acceptanceCriteria) {
411
- lines2.push(`- [ ] ${criterion}`);
1067
+ for (const req of plan.requirements) {
1068
+ lines2.push(`### ${req.requirement}`);
1069
+ for (const criterion of req.acceptanceCriteria) {
1070
+ lines2.push(`- [ ] ${criterion}`);
1071
+ }
412
1072
  }
413
1073
  if (plan.risks.length > 0) {
414
1074
  lines2.push("");
@@ -429,7 +1089,7 @@ function renderPlan(plan) {
429
1089
  return lines2.join("\n");
430
1090
  }
431
1091
  var submitPlanInputSchema = {
432
- plans: z2.array(requireRootCauseForFix(z2.object(planInputSchema))).min(1).refine(
1092
+ plans: z2.array(requireAtLeastTwoCriteria(requireRootCauseForFix(z2.object(planInputSchema)))).min(1).refine(
433
1093
  (arr) => {
434
1094
  const titles = arr.map((p) => p.title.trim()).filter((t) => t.length > 0);
435
1095
  return new Set(titles).size === titles.length;
@@ -442,7 +1102,7 @@ function createPlanTooling() {
442
1102
  let renderedPlans = null;
443
1103
  const submitPlan = tool2(
444
1104
  SUBMIT_PLAN,
445
- `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. acceptanceCriteria is required in each plan and must contain at least 2 observable, verifiable conditions. The 'title' field names each specific plan \u2014 make it concise and distinct from the request title and from sibling plan titles. requirements is required in each plan: at least 1 plain-language statement of what the change must accomplish and why (human-readable intent), separate from the machine-checkable acceptanceCriteria. 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. `,
1105
+ `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. `,
446
1106
  submitPlanInputSchema,
447
1107
  async (args) => {
448
1108
  const parsed = submitPlanSchema.parse(args);
@@ -510,6 +1170,9 @@ var acVerdictSchema = z3.object({
510
1170
  rationale: z3.string().min(1).describe("One or two sentences on why the verdict holds. " + INLINE_CODE_HINT),
511
1171
  evidence: z3.array(evidenceSchema).describe(
512
1172
  "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."
1173
+ ),
1174
+ requirement: z3.string().min(1).optional().describe(
1175
+ "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."
513
1176
  )
514
1177
  });
515
1178
  var reportInputSchema = {
@@ -534,24 +1197,47 @@ var reportInputSchema = {
534
1197
  )
535
1198
  };
536
1199
  var reportSchema = z3.object(reportInputSchema);
1200
+ function groupByRequirement(criteria) {
1201
+ const groups = /* @__PURE__ */ new Map();
1202
+ for (const ac of criteria) {
1203
+ const key = ac.requirement ?? null;
1204
+ const existing = groups.get(key);
1205
+ if (existing) {
1206
+ existing.push(ac);
1207
+ } else {
1208
+ groups.set(key, [ac]);
1209
+ }
1210
+ }
1211
+ return groups;
1212
+ }
1213
+ function renderAcVerdict(lines2, ac) {
1214
+ lines2.push("");
1215
+ lines2.push(`#### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
1216
+ lines2.push("");
1217
+ lines2.push(ac.rationale.trim());
1218
+ for (const ev of ac.evidence) {
1219
+ lines2.push("");
1220
+ lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
1221
+ lines2.push("");
1222
+ lines2.push("```diff");
1223
+ lines2.push(ev.hunk.replace(/\n+$/, ""));
1224
+ lines2.push("```");
1225
+ }
1226
+ }
537
1227
  function renderReport(report) {
538
1228
  const lines2 = [];
539
1229
  lines2.push(report.summary.trim());
540
1230
  lines2.push("", "## Files changed", "", report.filesChanged.trim());
541
1231
  if (report.acceptanceCriteria.length > 0) {
542
1232
  lines2.push("", "## Acceptance criteria");
543
- for (const ac of report.acceptanceCriteria) {
544
- lines2.push("");
545
- lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
546
- lines2.push("");
547
- lines2.push(ac.rationale.trim());
548
- for (const ev of ac.evidence) {
549
- lines2.push("");
550
- lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
1233
+ const groups = groupByRequirement(report.acceptanceCriteria);
1234
+ for (const [req, acs] of groups) {
1235
+ if (req !== null) {
551
1236
  lines2.push("");
552
- lines2.push("```diff");
553
- lines2.push(ev.hunk.replace(/\n+$/, ""));
554
- lines2.push("```");
1237
+ lines2.push(`### ${req}`);
1238
+ }
1239
+ for (const ac of acs) {
1240
+ renderAcVerdict(lines2, ac);
555
1241
  }
556
1242
  }
557
1243
  }
@@ -575,7 +1261,7 @@ function createReportTooling() {
575
1261
  let submittedReport = null;
576
1262
  const submitReport = tool3(
577
1263
  SUBMIT_REPORT,
578
- "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.",
1264
+ "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.",
579
1265
  reportInputSchema,
580
1266
  async (args) => {
581
1267
  submittedReport = reportSchema.parse(args);
@@ -1103,6 +1789,36 @@ function buildCodexPrompt(ctx) {
1103
1789
  "\n"
1104
1790
  );
1105
1791
  }
1792
+ function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
1793
+ const lines2 = [
1794
+ `You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
1795
+ `An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase page that imports the changed components and fills them with realistic fake data.`,
1796
+ `Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
1797
+ `These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
1798
+ "",
1799
+ "These coding guidelines apply to all code produced in this run:",
1800
+ "",
1801
+ loadRule("coding-guideline"),
1802
+ "",
1803
+ `# Request: ${ctx.request?.title ?? ""}`
1804
+ ];
1805
+ if (ctx.request?.body) {
1806
+ lines2.push("", ctx.request.body);
1807
+ }
1808
+ if (committedFileNames.trim()) {
1809
+ lines2.push(
1810
+ "",
1811
+ "UI files changed by this implementation (showcase these components):",
1812
+ "",
1813
+ committedFileNames.trim()
1814
+ );
1815
+ }
1816
+ lines2.push(
1817
+ "",
1818
+ "When done, reply with one short sentence confirming what showcase file you created."
1819
+ );
1820
+ return lines2.join("\n");
1821
+ }
1106
1822
  function buildInitPrompt(ctx) {
1107
1823
  return [
1108
1824
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -1278,6 +1994,13 @@ async function gitDiffStat(dir) {
1278
1994
  const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "--stat"]);
1279
1995
  return stdout2;
1280
1996
  }
1997
+ async function captureFullDiff(ctx, dir) {
1998
+ const { mergeBranch } = ctx.repo;
1999
+ if (!mergeBranch) return "";
2000
+ await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
2001
+ const { stdout: stdout2 } = await git(["-C", dir, "--no-pager", "diff", "FETCH_HEAD...HEAD"]);
2002
+ return stdout2;
2003
+ }
1281
2004
  var PreCommitError = class extends Error {
1282
2005
  constructor(log) {
1283
2006
  super("pre-commit checks failed");
@@ -1397,6 +2120,18 @@ async function openPullRequest(ctx) {
1397
2120
  }
1398
2121
  throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
1399
2122
  }
2123
+ async function gitCommittedFiles(dir) {
2124
+ const { stdout: stdout2 } = await git([
2125
+ "-C",
2126
+ dir,
2127
+ "--no-pager",
2128
+ "show",
2129
+ "--name-only",
2130
+ "--format=",
2131
+ "HEAD"
2132
+ ]);
2133
+ return stdout2;
2134
+ }
1400
2135
  async function cleanup(dir) {
1401
2136
  await rm(dir, { recursive: true, force: true });
1402
2137
  }
@@ -1456,6 +2191,267 @@ async function prNumbersForCommit(ctx, sha) {
1456
2191
  }
1457
2192
  }
1458
2193
 
2194
+ // src/preview.ts
2195
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
2196
+ import { createServer } from "node:net";
2197
+ import { join as join5, relative } from "node:path";
2198
+ import { spawn as spawn2 } from "node:child_process";
2199
+ var PREVIEW_MAX_TURNS = 60;
2200
+ var CAPTURE_TIMEOUT_MS = 3e4;
2201
+ var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
2202
+ var VIEWPORTS = [
2203
+ { name: "mobile", width: 375, height: 812 },
2204
+ { name: "tablet", width: 768, height: 1024 },
2205
+ { name: "desktop", width: 1280, height: 800 }
2206
+ ];
2207
+ var UI_EXTENSIONS = /* @__PURE__ */ new Set([
2208
+ ".tsx",
2209
+ ".jsx",
2210
+ ".vue",
2211
+ ".svelte",
2212
+ ".astro",
2213
+ ".css",
2214
+ ".scss",
2215
+ ".sass",
2216
+ ".less"
2217
+ ]);
2218
+ async function buildPreview(ctx, dir, committedFileNames, config, abort) {
2219
+ if (!changedFilesTouchUi(committedFileNames)) {
2220
+ return { status: "skipped", reason: "No UI files were changed in this run." };
2221
+ }
2222
+ try {
2223
+ const { previewId, entrypoint } = await runPreviewPass(
2224
+ ctx,
2225
+ dir,
2226
+ committedFileNames,
2227
+ config,
2228
+ abort
2229
+ );
2230
+ return { status: "ready", previewId, entrypoint };
2231
+ } catch (err) {
2232
+ return { status: "failed", reason: err instanceof Error ? err.message : String(err) };
2233
+ }
2234
+ }
2235
+ function changedFilesTouchUi(stat) {
2236
+ for (const line of stat.split("\n")) {
2237
+ const trimmed = line.trim();
2238
+ if (!trimmed) continue;
2239
+ const dot = trimmed.lastIndexOf(".");
2240
+ if (dot === -1) continue;
2241
+ const ext = trimmed.slice(dot);
2242
+ if (UI_EXTENSIONS.has(ext)) return true;
2243
+ }
2244
+ return false;
2245
+ }
2246
+ function detectFramework(dir) {
2247
+ const pm = detectPackageManager(dir);
2248
+ const pkgPath = join5(dir, "package.json");
2249
+ if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
2250
+ let pkg;
2251
+ try {
2252
+ pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2253
+ } catch {
2254
+ return { pm, family: null, devCmd: null };
2255
+ }
2256
+ const deps = {
2257
+ ...pkg.dependencies,
2258
+ ...pkg.devDependencies
2259
+ };
2260
+ const family = pickFamily(deps);
2261
+ const port = 0;
2262
+ const devCmd = family ? pickDevCmd(family, port) : null;
2263
+ return { pm, family, devCmd };
2264
+ }
2265
+ async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
2266
+ const tmpRoute = mkTempRouteDir(dir);
2267
+ try {
2268
+ gitExclude(dir, tmpRoute);
2269
+ await runClaudeCode({
2270
+ cwd: dir,
2271
+ prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
2272
+ permissionMode: ctx.permissionMode,
2273
+ abortController: abort,
2274
+ maxTurns: PREVIEW_MAX_TURNS
2275
+ });
2276
+ const showcasePath = readShowcasePath(tmpRoute);
2277
+ if (!showcasePath) {
2278
+ throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
2279
+ }
2280
+ const framework = detectFramework(dir);
2281
+ if (!framework.family) {
2282
+ throw new Error("unsupported framework \u2014 skipping preview");
2283
+ }
2284
+ const port = await findFreePort();
2285
+ const devServer = await startDevServer(dir, framework, port);
2286
+ try {
2287
+ const url = `http://localhost:${port}${showcasePath}`;
2288
+ const shots = await captureWithChromium(url, VIEWPORTS);
2289
+ const { previewId, urls } = await mintUploadUrls(config, ctx.jobId, shots.keys);
2290
+ await uploadAll(urls, shots.keys, shots.bytes);
2291
+ return { previewId, entrypoint: shots.entrypointKey };
2292
+ } finally {
2293
+ await devServer.stop();
2294
+ }
2295
+ } finally {
2296
+ rmSync(tmpRoute, { recursive: true, force: true });
2297
+ }
2298
+ }
2299
+ function pickFamily(deps) {
2300
+ if (deps["next"]) return "next";
2301
+ if (deps["@sveltejs/kit"]) return "svelte-kit";
2302
+ if (deps["nuxt"]) return "nuxt";
2303
+ if (deps["astro"]) return "astro";
2304
+ if (deps["react-scripts"]) return "cra";
2305
+ if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
2306
+ if (deps["vite"]) {
2307
+ if (deps["vue"]) return "vite-vue";
2308
+ if (deps["svelte"]) return "vite-svelte";
2309
+ if (deps["react"]) return "vite-react";
2310
+ return "vite-react";
2311
+ }
2312
+ return null;
2313
+ }
2314
+ function pickDevCmd(family, port) {
2315
+ switch (family) {
2316
+ case "next":
2317
+ return ["next", "dev", "-p", String(port)];
2318
+ case "vite-react":
2319
+ case "vite-vue":
2320
+ case "vite-svelte":
2321
+ return ["vite", "--port", String(port)];
2322
+ case "nuxt":
2323
+ return ["nuxt", "dev", "--port", String(port)];
2324
+ case "svelte-kit":
2325
+ return ["vite", "--port", String(port)];
2326
+ case "astro":
2327
+ return ["astro", "dev", "--port", String(port)];
2328
+ case "cra":
2329
+ return ["react-scripts", "start"];
2330
+ case "remix":
2331
+ return ["remix", "dev", "--port", String(port)];
2332
+ }
2333
+ }
2334
+ function mkTempRouteDir(dir) {
2335
+ const suffix = Math.random().toString(36).slice(2, 10);
2336
+ const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
2337
+ mkdirSync2(tmpRoute, { recursive: true });
2338
+ return tmpRoute;
2339
+ }
2340
+ function gitExclude(dir, tmpRoute) {
2341
+ const excludePath = join5(dir, ".git", "info", "exclude");
2342
+ const rel = relative(dir, tmpRoute);
2343
+ appendFileSync(excludePath, `
2344
+ ${rel}
2345
+ `);
2346
+ }
2347
+ function readShowcasePath(tmpRoute) {
2348
+ const sentinelPath = join5(tmpRoute, ".showcase-path");
2349
+ if (!existsSync4(sentinelPath)) return null;
2350
+ const content = readFileSync5(sentinelPath, "utf8").trim();
2351
+ return content.length > 0 ? content : null;
2352
+ }
2353
+ function findFreePort() {
2354
+ return new Promise((resolve, reject) => {
2355
+ const server = createServer();
2356
+ server.listen(0, "127.0.0.1", () => {
2357
+ const addr = server.address();
2358
+ if (!addr || typeof addr === "string") {
2359
+ server.close();
2360
+ reject(new Error("Could not determine free port"));
2361
+ return;
2362
+ }
2363
+ const port = addr.port;
2364
+ server.close((err) => {
2365
+ if (err) reject(err);
2366
+ else resolve(port);
2367
+ });
2368
+ });
2369
+ server.on("error", reject);
2370
+ });
2371
+ }
2372
+ function startDevServer(dir, framework, port) {
2373
+ return new Promise((resolve, reject) => {
2374
+ const pm = framework.pm ?? "npm";
2375
+ const devCmd = pickDevCmd(framework.family, port);
2376
+ const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
2377
+ const cmd = pm === "npm" ? "npx" : pm;
2378
+ const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
2379
+ const proc = spawn2(cmd, args, {
2380
+ cwd: dir,
2381
+ env: { ...process.env, PORT: String(port) },
2382
+ stdio: "ignore"
2383
+ });
2384
+ proc.on("error", reject);
2385
+ const baseUrl = `http://localhost:${port}`;
2386
+ const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
2387
+ const poll = () => {
2388
+ fetch(baseUrl).then((res) => {
2389
+ if (res.ok || res.status < 500) {
2390
+ resolve({
2391
+ stop: () => new Promise((res2) => {
2392
+ proc.kill();
2393
+ proc.on("close", () => res2());
2394
+ setTimeout(res2, 500);
2395
+ })
2396
+ });
2397
+ } else {
2398
+ scheduleNext();
2399
+ }
2400
+ }).catch(() => scheduleNext());
2401
+ };
2402
+ const scheduleNext = () => {
2403
+ if (Date.now() > deadline) {
2404
+ proc.kill();
2405
+ reject(
2406
+ new Error(`Dev server did not become ready within ${DEV_SERVER_READY_TIMEOUT_MS}ms`)
2407
+ );
2408
+ return;
2409
+ }
2410
+ setTimeout(poll, 1e3);
2411
+ };
2412
+ setTimeout(poll, 2e3);
2413
+ });
2414
+ }
2415
+ async function captureWithChromium(url, viewports) {
2416
+ const { chromium } = await import("playwright");
2417
+ const browser = await chromium.launch({ headless: true });
2418
+ const keys = [];
2419
+ const bytes = [];
2420
+ try {
2421
+ for (const vp of viewports) {
2422
+ const context = await browser.newContext({
2423
+ viewport: { width: vp.width, height: vp.height }
2424
+ });
2425
+ const page = await context.newPage();
2426
+ await page.goto(url, { timeout: CAPTURE_TIMEOUT_MS, waitUntil: "networkidle" });
2427
+ const screenshot = await page.screenshot({ fullPage: true, type: "png" });
2428
+ keys.push(`${vp.name}.png`);
2429
+ bytes.push(Buffer.from(screenshot));
2430
+ await context.close();
2431
+ }
2432
+ } finally {
2433
+ await browser.close();
2434
+ }
2435
+ return {
2436
+ keys,
2437
+ bytes,
2438
+ entrypointKey: keys[keys.length - 1] ?? "desktop.png"
2439
+ };
2440
+ }
2441
+ async function uploadAll(urls, keys, bytes) {
2442
+ await Promise.all(
2443
+ keys.map((key, i) => {
2444
+ const url = urls[key];
2445
+ if (!url) return Promise.resolve();
2446
+ return fetch(url, {
2447
+ method: "PUT",
2448
+ headers: { "content-type": "image/png" },
2449
+ body: bytes[i]
2450
+ });
2451
+ })
2452
+ );
2453
+ }
2454
+
1459
2455
  // src/run.ts
1460
2456
  var IDLE_MS = 5e3;
1461
2457
  var CANCEL_POLL_MS = 2500;
@@ -1650,7 +2646,7 @@ async function processChatJob(ctx, dir, config, abort) {
1650
2646
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1651
2647
  return { text: reply, widgets: result.widgets };
1652
2648
  }
1653
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2649
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1654
2650
  let documented = false;
1655
2651
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1656
2652
  try {
@@ -1739,7 +2735,7 @@ ${reply}`;
1739
2735
 
1740
2736
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1741
2737
  }
1742
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2738
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1743
2739
  let documented = false;
1744
2740
  if (wikiExists && await hasChanges(dir)) {
1745
2741
  try {
@@ -1763,12 +2759,33 @@ ${reply}`;
1763
2759
  rebase: !resumed
1764
2760
  });
1765
2761
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1766
- const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2762
+ let fullDiff;
2763
+ if (report && outcome.kind !== "none") {
2764
+ try {
2765
+ fullDiff = (await captureFullDiff(ctx, dir)).trim() || void 0;
2766
+ } catch (err) {
2767
+ console.warn(` full-diff capture skipped: ${errorMessage2(err)}`);
2768
+ }
2769
+ }
2770
+ const finalReport = report ? {
2771
+ ...report,
2772
+ ...conflictResolution ? { conflictResolution } : {},
2773
+ ...fullDiff ? { fullDiff } : {}
2774
+ } : report;
2775
+ let preview;
2776
+ if (outcome.kind !== "none") {
2777
+ console.log(` \u2026checking UI preview for implement ${ctx.jobId}`);
2778
+ const committedFileNames = await gitCommittedFiles(dir);
2779
+ preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
2780
+ if (preview.status !== "ready")
2781
+ console.log(` UI preview ${preview.status}: ${preview.reason}`);
2782
+ }
1767
2783
  return {
1768
2784
  text: reply,
1769
2785
  widgets: [],
1770
2786
  ...finalReport ? { report: finalReport } : {},
1771
- ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
2787
+ ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
2788
+ ...preview ? { preview } : {}
1772
2789
  };
1773
2790
  }
1774
2791
  async function processReviseJob(ctx, dir, resumed, config, abort) {
@@ -1795,7 +2812,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1795
2812
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1796
2813
  return { text: reply, widgets: result.widgets };
1797
2814
  }
1798
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2815
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1799
2816
  let documented = false;
1800
2817
  if (wikiExists && await hasChanges(dir)) {
1801
2818
  try {
@@ -1822,12 +2839,21 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1822
2839
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1823
2840
  }
1824
2841
  const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2842
+ let preview;
2843
+ if (outcome.kind !== "none") {
2844
+ console.log(` \u2026checking UI preview for revise ${ctx.jobId}`);
2845
+ const committedFileNames = await gitCommittedFiles(dir);
2846
+ preview = await buildPreview(ctx, dir, committedFileNames, config, abort);
2847
+ if (preview.status !== "ready")
2848
+ console.log(` UI preview ${preview.status}: ${preview.reason}`);
2849
+ }
1825
2850
  return {
1826
2851
  text: reply,
1827
2852
  widgets: [],
1828
2853
  ...finalReport ? { report: finalReport } : {},
1829
2854
  ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
1830
- ...result.plans?.length ? { plans: result.plans } : {}
2855
+ ...result.plans?.length ? { plans: result.plans } : {},
2856
+ ...preview ? { preview } : {}
1831
2857
  };
1832
2858
  }
1833
2859
  async function processResolveJob(ctx, dir, config, abort) {
@@ -1939,7 +2965,7 @@ async function pollLoop(config) {
1939
2965
  scheduleCancelPoll();
1940
2966
  try {
1941
2967
  resetUsage();
1942
- const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
2968
+ const { text, widgets, pr, plans, report, preview } = await processJob(ctx, config, abort);
1943
2969
  const usage = getUsage();
1944
2970
  await reportJob(config, ctx.jobId, {
1945
2971
  status: "done",
@@ -1948,6 +2974,7 @@ async function pollLoop(config) {
1948
2974
  pr,
1949
2975
  ...plans?.length ? { plans } : {},
1950
2976
  ...report ? { report } : {},
2977
+ ...preview ? { preview } : {},
1951
2978
  ...usage.totalTokens > 0 ? { usage } : {}
1952
2979
  });
1953
2980
  console.log(`\u2713 Job ${ctx.jobId} done`);
@@ -2027,6 +3054,8 @@ function formatJobError(ctx, err) {
2027
3054
  }
2028
3055
 
2029
3056
  // src/cli.ts
3057
+ init_version();
3058
+ import React7 from "react";
2030
3059
  var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
2031
3060
  async function login(args) {
2032
3061
  const flags = parseFlags(args);
@@ -2061,6 +3090,21 @@ async function start() {
2061
3090
  }
2062
3091
  await pollLoop(config);
2063
3092
  }
3093
+ async function runTui(args) {
3094
+ const flags = parseFlags(args);
3095
+ let config = readConfig() ?? { serverUrl: flags.server ?? DEFAULT_SERVER, token: "" };
3096
+ if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
3097
+ if (flags.token) config.apiToken = flags.token;
3098
+ if (!config.apiToken) {
3099
+ const rl = createInterface({ input: stdin, output: stdout });
3100
+ config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
3101
+ rl.close();
3102
+ }
3103
+ writeConfig(config);
3104
+ const { render } = await import("ink");
3105
+ const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3106
+ render(React7.createElement(App2, { config }));
3107
+ }
2064
3108
  function parseFlags(args) {
2065
3109
  const out = {};
2066
3110
  for (let i = 0; i < args.length; i++) {
@@ -2078,11 +3122,14 @@ if (command === "--version" || command === "-v" || command === "version") {
2078
3122
  void login(rest);
2079
3123
  } else if (command === "start") {
2080
3124
  void start();
3125
+ } else if (command === "tui") {
3126
+ void runTui(rest);
2081
3127
  } else {
2082
3128
  console.log(`FlumeCode runner v${RUNNER_VERSION}`);
2083
3129
  console.log("Usage:");
2084
3130
  console.log(" flumecode login # save server URL + token");
2085
3131
  console.log(" flumecode start # poll for and run jobs");
3132
+ console.log(" flumecode tui # open the interactive TUI");
2086
3133
  console.log(" flumecode --version # print the runner version");
2087
3134
  process.exit(command ? 1 : 0);
2088
3135
  }