@flumecode/runner 0.23.0 → 0.24.0

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