@flumecode/runner 0.21.0-beta.1 → 0.23.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/dist/cli.js CHANGED
@@ -1,4 +1,652 @@
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("");
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, 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("main");
455
+ const [mergeBranch, setMergeBranch] = useState6("");
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("main");
482
+ setMergeBranch("");
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() || "flumecode/session"
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
+ requestId: screen.requestId,
624
+ onBack: () => setScreen({ name: "requests", repo: screen.repo })
625
+ }
626
+ );
627
+ }
628
+ }
629
+ function App({ config }) {
630
+ const [screen, setScreen] = useState7({ name: "repos" });
631
+ const jobs = usePoll(() => listJobs(config), 3e3);
632
+ const runners = usePoll(() => listRunners(config), 3e3);
633
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
634
+ renderScreen(screen, setScreen, config),
635
+ /* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
636
+ ] });
637
+ }
638
+ var init_App = __esm({
639
+ "src/tui/App.tsx"() {
640
+ "use strict";
641
+ init_tui_api();
642
+ init_usePoll();
643
+ init_StatusBar();
644
+ init_RepoListScreen();
645
+ init_RequestListScreen();
646
+ init_NewRequestScreen();
647
+ init_ThreadScreen();
648
+ }
649
+ });
2
650
 
3
651
  // src/cli.ts
4
652
  import { createInterface } from "node:readline/promises";
@@ -15,7 +663,9 @@ function readConfig() {
15
663
  try {
16
664
  const parsed = JSON.parse(readFileSync(configPath, "utf8"));
17
665
  if (!parsed.serverUrl || !parsed.token) return null;
18
- return { serverUrl: parsed.serverUrl, token: parsed.token };
666
+ const config = { serverUrl: parsed.serverUrl, token: parsed.token };
667
+ if (parsed.apiToken) config.apiToken = parsed.apiToken;
668
+ return config;
19
669
  } catch {
20
670
  return null;
21
671
  }
@@ -26,38 +676,11 @@ function writeConfig(config) {
26
676
  }
27
677
 
28
678
  // 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
- }
679
+ import { existsSync as existsSync5 } from "node:fs";
680
+ import { join as join6 } from "node:path";
59
681
 
60
682
  // src/api.ts
683
+ init_version();
61
684
  var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
62
685
  var lastOutdatedWarnAt = 0;
63
686
  var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
@@ -172,6 +795,20 @@ async function fetchRelatedSessions(config, params) {
172
795
  return [];
173
796
  }
174
797
  }
798
+ async function mintUploadUrls(config, jobId, keys) {
799
+ const res = await fetch(`${config.serverUrl}/api/runner/previews/mint-upload-urls`, {
800
+ method: "POST",
801
+ headers: {
802
+ authorization: `Bearer ${config.token}`,
803
+ "content-type": "application/json",
804
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
805
+ },
806
+ body: JSON.stringify({ jobId, keys })
807
+ });
808
+ noteServerVersion(res);
809
+ if (!res.ok) throw new Error(`mint-upload-urls failed: ${res.status} ${await safeText(res)}`);
810
+ return res.json();
811
+ }
175
812
  async function safeText(res) {
176
813
  try {
177
814
  return await res.text();
@@ -1065,56 +1702,35 @@ function buildRepairPrompt(ctx, hookLog) {
1065
1702
  ];
1066
1703
  return lines2.join("\n");
1067
1704
  }
1068
- function buildReleasePrompt(ctx, baseChecks) {
1069
- const task = `Use the \`flumecode:create-release\` skill to handle this turn. You are driving a release: first analyse commits since the last tag, propose version bumps, and ask the user to confirm via widgets (Phase 1); once the user's widget answers appear in the thread, apply the bumps to package.json files and update CHANGELOG.md (Phase 2). Do NOT commit or push \u2014 the runner handles that and opens the bump PR.`;
1705
+ function buildReleasePrompt(ctx) {
1706
+ const task = `Use the \`flumecode:create-release\` skill to handle this turn. You are driving a release: first analyse commits since the last tag, propose version bumps, and ask the user to confirm via widgets (Phase 1); once the user's widget answers appear in the thread, emit the final report with the confirmed versions (Phase 2). Do NOT edit package.json or CHANGELOG.md, do NOT commit, push, or open a PR.`;
1070
1707
  const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this release. If there is no wiki, work from the code directly.`;
1071
1708
  const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
1072
1709
  const lines2 = [
1073
1710
  `You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
1074
- `The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
1711
+ `The repository ${ctx.repo.fullName} is checked out in your current working directory at the frozen release commit (branch "${ctx.repo.checkoutBranch}").`,
1075
1712
  task,
1076
1713
  orient,
1077
1714
  widgets,
1078
1715
  LANGUAGE_DIRECTIVE,
1079
1716
  "",
1080
- "These coding guidelines apply to all code produced in this run:",
1081
- "",
1082
- loadRule("coding-guideline"),
1083
- "",
1084
1717
  `# Release: ${ctx.request?.title ?? ""}`
1085
1718
  ];
1086
1719
  if (ctx.request?.body) {
1087
1720
  lines2.push("", ctx.request.body);
1088
1721
  }
1089
- if (baseChecks && !baseChecks.ok) {
1090
- lines2.push(
1091
- "",
1092
- "# Pre-release check status",
1093
- "",
1094
- "\u26A0\uFE0F The repository's pre-commit checks (lint / typecheck / tests) are currently FAILING on the base branch, independently of any version bump. A release must not ship a broken base:",
1095
- "",
1096
- "- **Phase 1 (propose):** tell the user, in your reply, that the base currently fails these checks and that the release will fix them as part of the bump.",
1097
- "- **Phase 2 (apply):** fix the failing code at its root so the checks pass, THEN apply the version bumps and CHANGELOG. Do NOT delete/skip tests or weaken assertions. The fixes ship in the same bump PR. Still do NOT commit or push \u2014 the runner does.",
1098
- "",
1099
- "Failing check output:",
1100
- "",
1101
- "```",
1102
- baseChecks.log,
1103
- "```"
1104
- );
1105
- }
1106
1722
  if (ctx.prerelease) {
1107
1723
  lines2.push(
1108
1724
  "",
1109
1725
  "# Pre-release",
1110
1726
  "",
1111
- "This is a PRE-RELEASE. When proposing and applying versions, use a semver pre-release version string (e.g. `0.9.0-beta.1`): take the next stable version you would otherwise pick and append `-beta.N`, where N is the next unused beta number for that version (check existing `v<version>-beta.*` tags). Offer these pre-release strings in the version-confirmation widgets, and write them to package.json, CHANGELOG.md, and the `flumecode:versions` comment as usual."
1727
+ "This is a PRE-RELEASE. When proposing versions, use a semver pre-release version string (e.g. `0.9.0-beta.1`): take the next stable version you would otherwise pick and append `-beta.N`, where N is the next unused beta number for that version (check existing `v<version>-beta.*` tags). Offer these pre-release strings in the version-confirmation widgets, and include them in the `flumecode:versions` comment as usual."
1112
1728
  );
1113
1729
  }
1114
1730
  appendThread(lines2, ctx);
1115
1731
  lines2.push(
1116
1732
  "",
1117
- "Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you applied the bumps (Phase 2), make it the report the skill produced. The runner appends the pull-request link."
1733
+ "Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you emitted the final report (Phase 2), make it the report the skill produced."
1118
1734
  );
1119
1735
  return lines2.join("\n");
1120
1736
  }
@@ -1124,6 +1740,36 @@ function buildCodexPrompt(ctx) {
1124
1740
  "\n"
1125
1741
  );
1126
1742
  }
1743
+ function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
1744
+ const lines2 = [
1745
+ `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 page that imports the changed components and fills them with realistic fake data.`,
1747
+ `Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
1748
+ `These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
1749
+ "",
1750
+ "These coding guidelines apply to all code produced in this run:",
1751
+ "",
1752
+ loadRule("coding-guideline"),
1753
+ "",
1754
+ `# Request: ${ctx.request?.title ?? ""}`
1755
+ ];
1756
+ if (ctx.request?.body) {
1757
+ lines2.push("", ctx.request.body);
1758
+ }
1759
+ if (committedFileNames.trim()) {
1760
+ lines2.push(
1761
+ "",
1762
+ "UI files changed by this implementation (showcase these components):",
1763
+ "",
1764
+ committedFileNames.trim()
1765
+ );
1766
+ }
1767
+ lines2.push(
1768
+ "",
1769
+ "When done, reply with one short sentence confirming what showcase file you created."
1770
+ );
1771
+ return lines2.join("\n");
1772
+ }
1127
1773
  function buildInitPrompt(ctx) {
1128
1774
  return [
1129
1775
  `You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
@@ -1311,27 +1957,6 @@ function commitFailureLog(err) {
1311
1957
  const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
1312
1958
  return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
1313
1959
  }
1314
- function isUnsupportedGitSubcommand(err) {
1315
- const e = err;
1316
- const text = `${typeof e.stderr === "string" ? e.stderr : ""}
1317
- ${e.message ?? ""}`;
1318
- return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
1319
- }
1320
- async function runRepoChecks(dir) {
1321
- try {
1322
- await git(["-C", dir, "hook", "run", "pre-commit"]);
1323
- logEvent("checks", "pre-commit hook passed");
1324
- return { ok: true, log: "", skipped: false };
1325
- } catch (err) {
1326
- if (isUnsupportedGitSubcommand(err)) {
1327
- logEvent("checks", "pre-commit hook skipped (git too old)");
1328
- return { ok: true, log: "", skipped: true };
1329
- }
1330
- const log = commitFailureLog(err);
1331
- logEvent("checks:err", log);
1332
- return { ok: false, log, skipped: false };
1333
- }
1334
- }
1335
1960
  async function commitChanges(ctx, dir) {
1336
1961
  if (!await hasChanges(dir)) return false;
1337
1962
  try {
@@ -1428,10 +2053,9 @@ async function openPullRequest(ctx) {
1428
2053
  return { number: data.number, url: data.html_url };
1429
2054
  }
1430
2055
  if (res.status === 422) {
1431
- const list = await fetch(
1432
- `${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
1433
- { headers }
1434
- );
2056
+ const list = await fetch(`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}`, {
2057
+ headers
2058
+ });
1435
2059
  if (list.ok) {
1436
2060
  const open = await list.json();
1437
2061
  if (open[0]) return { number: open[0].number, url: open[0].html_url };
@@ -1440,6 +2064,18 @@ async function openPullRequest(ctx) {
1440
2064
  }
1441
2065
  throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
1442
2066
  }
2067
+ async function gitCommittedFiles(dir) {
2068
+ const { stdout: stdout2 } = await git([
2069
+ "-C",
2070
+ dir,
2071
+ "--no-pager",
2072
+ "show",
2073
+ "--name-only",
2074
+ "--format=",
2075
+ "HEAD"
2076
+ ]);
2077
+ return stdout2;
2078
+ }
1443
2079
  async function cleanup(dir) {
1444
2080
  await rm(dir, { recursive: true, force: true });
1445
2081
  }
@@ -1499,6 +2135,250 @@ async function prNumbersForCommit(ctx, sha) {
1499
2135
  }
1500
2136
  }
1501
2137
 
2138
+ // src/preview.ts
2139
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
2140
+ import { createServer } from "node:net";
2141
+ import { join as join5, relative } from "node:path";
2142
+ import { spawn as spawn2 } from "node:child_process";
2143
+ var PREVIEW_MAX_TURNS = 60;
2144
+ var CAPTURE_TIMEOUT_MS = 3e4;
2145
+ var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
2146
+ var VIEWPORTS = [
2147
+ { name: "mobile", width: 375, height: 812 },
2148
+ { name: "tablet", width: 768, height: 1024 },
2149
+ { name: "desktop", width: 1280, height: 800 }
2150
+ ];
2151
+ var UI_EXTENSIONS = /* @__PURE__ */ new Set([
2152
+ ".tsx",
2153
+ ".jsx",
2154
+ ".vue",
2155
+ ".svelte",
2156
+ ".astro",
2157
+ ".css",
2158
+ ".scss",
2159
+ ".sass",
2160
+ ".less"
2161
+ ]);
2162
+ function changedFilesTouchUi(stat) {
2163
+ for (const line of stat.split("\n")) {
2164
+ const trimmed = line.trim();
2165
+ if (!trimmed) continue;
2166
+ const dot = trimmed.lastIndexOf(".");
2167
+ if (dot === -1) continue;
2168
+ const ext = trimmed.slice(dot);
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;
2178
+ try {
2179
+ pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
2180
+ } catch {
2181
+ return { pm, family: null, devCmd: null };
2182
+ }
2183
+ const deps = {
2184
+ ...pkg.dependencies,
2185
+ ...pkg.devDependencies
2186
+ };
2187
+ const family = pickFamily(deps);
2188
+ const port = 0;
2189
+ const devCmd = family ? pickDevCmd(family, port) : null;
2190
+ return { pm, family, devCmd };
2191
+ }
2192
+ async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
2193
+ const tmpRoute = mkTempRouteDir(dir);
2194
+ try {
2195
+ gitExclude(dir, tmpRoute);
2196
+ await runClaudeCode({
2197
+ cwd: dir,
2198
+ prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
2199
+ permissionMode: ctx.permissionMode,
2200
+ abortController: abort,
2201
+ maxTurns: PREVIEW_MAX_TURNS
2202
+ });
2203
+ const showcasePath = readShowcasePath(tmpRoute);
2204
+ if (!showcasePath) {
2205
+ throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
2206
+ }
2207
+ const framework = detectFramework(dir);
2208
+ if (!framework.family) {
2209
+ throw new Error("unsupported framework \u2014 skipping preview");
2210
+ }
2211
+ const port = await findFreePort();
2212
+ const devServer = await startDevServer(dir, framework, port);
2213
+ try {
2214
+ const url = `http://localhost:${port}${showcasePath}`;
2215
+ const shots = await captureWithChromium(url, VIEWPORTS);
2216
+ const { previewId, urls } = await mintUploadUrls(config, ctx.jobId, shots.keys);
2217
+ await uploadAll(urls, shots.keys, shots.bytes);
2218
+ return { previewId, entrypoint: shots.entrypointKey };
2219
+ } finally {
2220
+ await devServer.stop();
2221
+ }
2222
+ } finally {
2223
+ rmSync(tmpRoute, { recursive: true, force: true });
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
+ `);
2273
+ }
2274
+ function readShowcasePath(tmpRoute) {
2275
+ const sentinelPath = join5(tmpRoute, ".showcase-path");
2276
+ if (!existsSync4(sentinelPath)) return null;
2277
+ const content = readFileSync5(sentinelPath, "utf8").trim();
2278
+ return content.length > 0 ? content : null;
2279
+ }
2280
+ function findFreePort() {
2281
+ return new Promise((resolve, reject) => {
2282
+ const server = createServer();
2283
+ server.listen(0, "127.0.0.1", () => {
2284
+ const addr = server.address();
2285
+ if (!addr || typeof addr === "string") {
2286
+ server.close();
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
+ });
2298
+ }
2299
+ function startDevServer(dir, framework, port) {
2300
+ return new Promise((resolve, reject) => {
2301
+ const pm = framework.pm ?? "npm";
2302
+ const devCmd = pickDevCmd(framework.family, port);
2303
+ const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
2304
+ const cmd = pm === "npm" ? "npx" : pm;
2305
+ const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
2306
+ const proc = spawn2(cmd, args, {
2307
+ cwd: dir,
2308
+ env: { ...process.env, PORT: String(port) },
2309
+ stdio: "ignore"
2310
+ });
2311
+ proc.on("error", reject);
2312
+ const baseUrl = `http://localhost:${port}`;
2313
+ const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
2314
+ const poll = () => {
2315
+ fetch(baseUrl).then((res) => {
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());
2328
+ };
2329
+ const scheduleNext = () => {
2330
+ if (Date.now() > deadline) {
2331
+ proc.kill();
2332
+ reject(
2333
+ new Error(`Dev server did not become ready within ${DEV_SERVER_READY_TIMEOUT_MS}ms`)
2334
+ );
2335
+ return;
2336
+ }
2337
+ setTimeout(poll, 1e3);
2338
+ };
2339
+ setTimeout(poll, 2e3);
2340
+ });
2341
+ }
2342
+ async function captureWithChromium(url, viewports) {
2343
+ const { chromium } = await import("playwright");
2344
+ const browser = await chromium.launch({ headless: true });
2345
+ const keys = [];
2346
+ const bytes = [];
2347
+ try {
2348
+ for (const vp of viewports) {
2349
+ const context = await browser.newContext({
2350
+ viewport: { width: vp.width, height: vp.height }
2351
+ });
2352
+ const page = await context.newPage();
2353
+ await page.goto(url, { timeout: CAPTURE_TIMEOUT_MS, waitUntil: "networkidle" });
2354
+ const screenshot = await page.screenshot({ fullPage: true, type: "png" });
2355
+ keys.push(`${vp.name}.png`);
2356
+ bytes.push(Buffer.from(screenshot));
2357
+ await context.close();
2358
+ }
2359
+ } finally {
2360
+ await browser.close();
2361
+ }
2362
+ return {
2363
+ keys,
2364
+ bytes,
2365
+ entrypointKey: keys[keys.length - 1] ?? "desktop.png"
2366
+ };
2367
+ }
2368
+ async function uploadAll(urls, keys, bytes) {
2369
+ await Promise.all(
2370
+ keys.map((key, i) => {
2371
+ const url = urls[key];
2372
+ if (!url) return Promise.resolve();
2373
+ return fetch(url, {
2374
+ method: "PUT",
2375
+ headers: { "content-type": "image/png" },
2376
+ body: bytes[i]
2377
+ });
2378
+ })
2379
+ );
2380
+ }
2381
+
1502
2382
  // src/run.ts
1503
2383
  var IDLE_MS = 5e3;
1504
2384
  var CANCEL_POLL_MS = 2500;
@@ -1693,7 +2573,7 @@ async function processChatJob(ctx, dir, config, abort) {
1693
2573
  console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1694
2574
  return { text: reply, widgets: result.widgets };
1695
2575
  }
1696
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2576
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1697
2577
  let documented = false;
1698
2578
  if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
1699
2579
  try {
@@ -1782,7 +2662,7 @@ ${reply}`;
1782
2662
 
1783
2663
  > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1784
2664
  }
1785
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2665
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1786
2666
  let documented = false;
1787
2667
  if (wikiExists && await hasChanges(dir)) {
1788
2668
  try {
@@ -1807,11 +2687,24 @@ ${reply}`;
1807
2687
  });
1808
2688
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1809
2689
  const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2690
+ let preview;
2691
+ if (outcome.kind !== "none") {
2692
+ const committedFileNames = await gitCommittedFiles(dir);
2693
+ if (changedFilesTouchUi(committedFileNames)) {
2694
+ try {
2695
+ console.log(` \u2026generating UI preview for implement ${ctx.jobId}`);
2696
+ preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
2697
+ } catch (err) {
2698
+ console.warn(` preview skipped: ${errorMessage2(err)}`);
2699
+ }
2700
+ }
2701
+ }
1810
2702
  return {
1811
2703
  text: reply,
1812
2704
  widgets: [],
1813
2705
  ...finalReport ? { report: finalReport } : {},
1814
- ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
2706
+ ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
2707
+ ...preview ? { preview } : {}
1815
2708
  };
1816
2709
  }
1817
2710
  async function processReviseJob(ctx, dir, resumed, config, abort) {
@@ -1838,7 +2731,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1838
2731
  console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
1839
2732
  return { text: reply, widgets: result.widgets };
1840
2733
  }
1841
- const wikiExists = existsSync4(join5(dir, ".flumecode", "wiki"));
2734
+ const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
1842
2735
  let documented = false;
1843
2736
  if (wikiExists && await hasChanges(dir)) {
1844
2737
  try {
@@ -1865,12 +2758,25 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
1865
2758
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
1866
2759
  }
1867
2760
  const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
2761
+ let preview;
2762
+ if (outcome.kind !== "none") {
2763
+ const committedFileNames = await gitCommittedFiles(dir);
2764
+ if (changedFilesTouchUi(committedFileNames)) {
2765
+ try {
2766
+ console.log(` \u2026generating UI preview for revise ${ctx.jobId}`);
2767
+ preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
2768
+ } catch (err) {
2769
+ console.warn(` preview skipped: ${errorMessage2(err)}`);
2770
+ }
2771
+ }
2772
+ }
1868
2773
  return {
1869
2774
  text: reply,
1870
2775
  widgets: [],
1871
2776
  ...finalReport ? { report: finalReport } : {},
1872
2777
  ...outcome.kind === "pr" ? { pr: outcome.pr } : {},
1873
- ...result.plans?.length ? { plans: result.plans } : {}
2778
+ ...result.plans?.length ? { plans: result.plans } : {},
2779
+ ...preview ? { preview } : {}
1874
2780
  };
1875
2781
  }
1876
2782
  async function processResolveJob(ctx, dir, config, abort) {
@@ -1897,48 +2803,26 @@ async function processResolveJob(ctx, dir, config, abort) {
1897
2803
  reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
1898
2804
  return { text: reply, widgets: [], ...report ? { report } : {}, ...pr ? { pr } : {} };
1899
2805
  }
1900
- async function processReleaseJob(ctx, dir, resumed, config, abort) {
2806
+ async function processReleaseJob(ctx, dir, _resumed, _config, abort) {
1901
2807
  console.log(`
1902
2808
  \u25B6 Release ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
1903
- const installResult = await installDependencies(dir);
1904
- const checks = await runRepoChecks(dir);
1905
- if (checks.skipped) {
1906
- console.log(` \u2026release ${ctx.jobId}: pre-release checks skipped (git too old for 'hook run')`);
1907
- } else {
1908
- console.log(` \u2026release ${ctx.jobId}: pre-release checks ${checks.ok ? "passed" : "FAILED"}`);
1909
- }
1910
- const baseChecks = checks.ok ? void 0 : { ok: false, log: trimHookLog(checks.log) };
2809
+ await installDependencies(dir);
1911
2810
  const result = await runClaudeCode({
1912
2811
  cwd: dir,
1913
- prompt: buildReleasePrompt(ctx, baseChecks),
2812
+ prompt: buildReleasePrompt(ctx),
1914
2813
  permissionMode: ctx.permissionMode,
1915
2814
  model: orchestratorModel(ctx),
1916
2815
  maxTurns: ORCHESTRATOR_MAX_TURNS,
1917
2816
  abortController: abort
1918
2817
  });
1919
- let reply = result.text.trim() || "(the agent produced no reply)";
1920
- if (installResult.status === "failed") {
1921
- reply += `
1922
-
1923
- > \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
1924
- }
2818
+ const reply = result.text.trim() || "(the agent produced no reply)";
1925
2819
  if (result.widgets.length > 0) {
1926
2820
  console.log(
1927
2821
  ` \u2026release ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`
1928
2822
  );
1929
2823
  return { text: reply, widgets: result.widgets };
1930
2824
  }
1931
- const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
1932
- rebase: !resumed
1933
- });
1934
- if (outcome.kind !== "none") {
1935
- reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, autoMerged });
1936
- }
1937
- return {
1938
- text: reply,
1939
- widgets: [],
1940
- ...outcome.kind === "pr" ? { pr: outcome.pr } : {}
1941
- };
2825
+ return { text: reply, widgets: [] };
1942
2826
  }
1943
2827
  async function heartbeat(config) {
1944
2828
  const health = await checkClaudeCode();
@@ -2004,7 +2888,7 @@ async function pollLoop(config) {
2004
2888
  scheduleCancelPoll();
2005
2889
  try {
2006
2890
  resetUsage();
2007
- const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
2891
+ const { text, widgets, pr, plans, report, preview } = await processJob(ctx, config, abort);
2008
2892
  const usage = getUsage();
2009
2893
  await reportJob(config, ctx.jobId, {
2010
2894
  status: "done",
@@ -2013,6 +2897,7 @@ async function pollLoop(config) {
2013
2897
  pr,
2014
2898
  ...plans?.length ? { plans } : {},
2015
2899
  ...report ? { report } : {},
2900
+ ...preview ? { preview } : {},
2016
2901
  ...usage.totalTokens > 0 ? { usage } : {}
2017
2902
  });
2018
2903
  console.log(`\u2713 Job ${ctx.jobId} done`);
@@ -2075,7 +2960,7 @@ ${trimmed}` : trimmed;
2075
2960
  }
2076
2961
  function formatJobError(ctx, err) {
2077
2962
  if (!(err instanceof PreCommitError)) return errorMessage2(err);
2078
- const nextStep = ctx.kind === "release" ? `These checks are failing on \`${ctx.repo.mergeBranch}\` independently of the version bump, and the release couldn't fix them after ${MAX_COMMIT_REPAIRS} automatic attempts. Open a request on **${ctx.repo.fullName}** to fix the failing checks above, then start the release again once that fix has merged.` : `The agent couldn't get its change past these checks after ${MAX_COMMIT_REPAIRS} automatic repair attempts. Open a request on **${ctx.repo.fullName}** describing the failing checks above so the agent can fix them at their root, then try again.`;
2963
+ const nextStep = ctx.kind === "release" ? `These checks are failing on \`${ctx.repo.mergeBranch}\` independently of this release, and the release couldn't fix them after ${MAX_COMMIT_REPAIRS} automatic attempts. Open a request on **${ctx.repo.fullName}** to fix the failing checks above, then start the release again once that fix has merged.` : `The agent couldn't get its change past these checks after ${MAX_COMMIT_REPAIRS} automatic repair attempts. Open a request on **${ctx.repo.fullName}** describing the failing checks above so the agent can fix them at their root, then try again.`;
2079
2964
  return [
2080
2965
  "\u274C **Blocked by failing pre-commit checks.**",
2081
2966
  "",
@@ -2092,6 +2977,8 @@ function formatJobError(ctx, err) {
2092
2977
  }
2093
2978
 
2094
2979
  // src/cli.ts
2980
+ init_version();
2981
+ import React7 from "react";
2095
2982
  var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
2096
2983
  async function login(args) {
2097
2984
  const flags = parseFlags(args);
@@ -2126,6 +3013,21 @@ async function start() {
2126
3013
  }
2127
3014
  await pollLoop(config);
2128
3015
  }
3016
+ async function runTui(args) {
3017
+ const flags = parseFlags(args);
3018
+ let config = readConfig() ?? { serverUrl: flags.server ?? DEFAULT_SERVER, token: "" };
3019
+ if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
3020
+ if (flags.token) config.apiToken = flags.token;
3021
+ if (!config.apiToken) {
3022
+ const rl = createInterface({ input: stdin, output: stdout });
3023
+ config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
3024
+ rl.close();
3025
+ }
3026
+ writeConfig(config);
3027
+ const { render } = await import("ink");
3028
+ const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3029
+ render(React7.createElement(App2, { config }));
3030
+ }
2129
3031
  function parseFlags(args) {
2130
3032
  const out = {};
2131
3033
  for (let i = 0; i < args.length; i++) {
@@ -2143,11 +3045,14 @@ if (command === "--version" || command === "-v" || command === "version") {
2143
3045
  void login(rest);
2144
3046
  } else if (command === "start") {
2145
3047
  void start();
3048
+ } else if (command === "tui") {
3049
+ void runTui(rest);
2146
3050
  } else {
2147
3051
  console.log(`FlumeCode runner v${RUNNER_VERSION}`);
2148
3052
  console.log("Usage:");
2149
3053
  console.log(" flumecode login # save server URL + token");
2150
3054
  console.log(" flumecode start # poll for and run jobs");
3055
+ console.log(" flumecode tui # open the interactive TUI");
2151
3056
  console.log(" flumecode --version # print the runner version");
2152
3057
  process.exit(command ? 1 : 0);
2153
3058
  }