@higherdev/cli 0.18.0 → 0.21.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/api.js CHANGED
@@ -75,6 +75,9 @@ export async function listTicketStory(key, config = loadConfig()) {
75
75
  export async function postMessage(fields, config = loadConfig()) {
76
76
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
77
77
  }
78
+ export async function getRun(id, config = loadConfig()) {
79
+ return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
80
+ }
78
81
  export async function answerDecision(id, answer_md, config = loadConfig()) {
79
82
  return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
80
83
  }
@@ -128,6 +131,12 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
128
131
  export async function removeHostEnv(host, name, config = loadConfig()) {
129
132
  return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
130
133
  }
134
+ export async function getRoadmap(config = loadConfig()) {
135
+ return request(config, "GET", `/api/w/${config.slug}/roadmap`);
136
+ }
137
+ export async function updateRoadmap(vision_md, config = loadConfig()) {
138
+ return request(config, "PATCH", `/api/w/${config.slug}/roadmap`, { vision_md });
139
+ }
131
140
  export async function listEpics(config = loadConfig()) {
132
141
  return request(config, "GET", `/api/w/${config.slug}/epics`);
133
142
  }
@@ -137,6 +146,9 @@ export async function createEpic(fields, config = loadConfig()) {
137
146
  export async function approveEpic(id, config = loadConfig()) {
138
147
  return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
139
148
  }
149
+ export async function updateEpic(id, fields, config = loadConfig()) {
150
+ return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id, ...fields });
151
+ }
140
152
  export async function deleteEpic(id, config = loadConfig()) {
141
153
  return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
142
154
  }
@@ -152,6 +164,10 @@ export async function listMessages(options = {}, config = loadConfig()) {
152
164
  query.set("offset", String(options.offset));
153
165
  if (options.toRoles?.length)
154
166
  query.set("to_role", options.toRoles.join(","));
167
+ if (options.fromRoles?.length)
168
+ query.set("from_role", options.fromRoles.join(","));
169
+ if (options.unticketed)
170
+ query.set("unticketed", "true");
155
171
  if (options.undelivered)
156
172
  query.set("undelivered", "true");
157
173
  if (options.delivered)
package/dist/host.js CHANGED
@@ -95,6 +95,7 @@ ExecStart=${opts.nodePath} --experimental-strip-types ${opts.runnerPath}
95
95
  Restart=always
96
96
  RestartSec=5
97
97
  TimeoutStopSec=${systemdStopTimeoutSec(opts.drainTimeoutMs)}
98
+ KillMode=mixed
98
99
 
99
100
  [Install]
100
101
  WantedBy=default.target
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
5
5
  import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
6
6
  import { login, parseLoginFlags } from "./login.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { epicProgressRows, readEpicSpec } from "./epics.js";
9
+ import { roadmapText } from "./roadmap.js";
9
10
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
10
11
  import { ticketNew } from "./ticket-commands.js";
11
12
  import { ticketViewLines } from "./tui/ticket-view.js";
@@ -178,7 +179,44 @@ async function cmdEpic(argv) {
178
179
  }
179
180
  return;
180
181
  }
181
- fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID");
182
+ if (action === "set") {
183
+ const parsed = flags(rest);
184
+ const id = parsed.rest[0];
185
+ const choices = ["after", "position", "depends-on"].filter((name) => parsed.opts[name] !== undefined);
186
+ if (!id || parsed.rest.length !== 1 || choices.length !== 1) {
187
+ fail("usage: hd epic set ID --after ID | --position N | --depends-on ID");
188
+ }
189
+ const position = parsed.opts.position ? Number(parsed.opts.position) : undefined;
190
+ if (position !== undefined && (!Number.isInteger(position) || position < 1)) {
191
+ fail("position must be a positive integer.");
192
+ }
193
+ const { epic } = await updateEpic(id, {
194
+ ...(parsed.opts.after ? { after: parsed.opts.after } : {}),
195
+ ...(position !== undefined ? { position } : {}),
196
+ ...(parsed.opts["depends-on"] ? { depends_on: [parsed.opts["depends-on"]] } : {}),
197
+ });
198
+ console.log(`${c.bold(epic.id)} position ${epic.position}`);
199
+ return;
200
+ }
201
+ fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
202
+ }
203
+ async function cmdRoadmap(argv) {
204
+ const parsed = flags(argv);
205
+ if (parsed.rest.length || [...parsed.bools].some((name) => name !== "json") || Object.keys(parsed.opts).length) {
206
+ fail("usage: hd roadmap [--json]");
207
+ }
208
+ const data = await getRoadmap();
209
+ if (parsed.bools.has("json"))
210
+ return console.log(JSON.stringify(data));
211
+ if (!data.roadmap)
212
+ return console.log(c.dim("No roadmap."));
213
+ console.log(c.bold("Vision"));
214
+ console.log(data.roadmap.vision_md);
215
+ if (!data.epics.length)
216
+ return console.log(`\n${c.dim("No epics.")}`);
217
+ console.log("");
218
+ for (const line of roadmapText(data.epics))
219
+ console.log(line);
182
220
  }
183
221
  async function cmdPlan(argv) {
184
222
  if (argv.length)
@@ -554,6 +592,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
554
592
  await cmdEpic(rest);
555
593
  return;
556
594
  }
595
+ if (cmd === "roadmap") {
596
+ await cmdRoadmap(rest);
597
+ return;
598
+ }
557
599
  if (cmd === "plan") {
558
600
  await cmdPlan(rest);
559
601
  return;
package/dist/out.js CHANGED
@@ -66,6 +66,7 @@ export function usage() {
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
67
  ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
68
68
  ` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
69
+ ` ${c.blue("hd roadmap [--json]")} ordered workspace roadmap`,
69
70
  ` ${c.blue("hd plan")} use /architect in the TUI`,
70
71
  ` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
71
72
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
@@ -0,0 +1,23 @@
1
+ export function currentRoadmapEpic(epics) {
2
+ const statuses = new Map(epics.map((epic) => [epic.id, epic.status]));
3
+ return [...epics].sort((a, b) => a.position - b.position).find((epic) => !["draft", "done"].includes(epic.status)
4
+ && epic.depends_on.every((id) => statuses.get(id) === "done")) ?? null;
5
+ }
6
+ export function progressBar(merged, total, width = 10) {
7
+ const complete = total > 0 ? Math.round((Math.max(0, Math.min(merged, total)) / total) * width) : 0;
8
+ return `${"█".repeat(complete)}${"░".repeat(Math.max(0, width - complete))}`;
9
+ }
10
+ export function roadmapText(epics) {
11
+ const names = new Map(epics.map((epic) => [epic.id, epic.title]));
12
+ const current = currentRoadmapEpic(epics);
13
+ return [...epics].sort((a, b) => a.position - b.position).flatMap((epic) => {
14
+ const dependencies = epic.depends_on.map((id) => names.get(id) ?? id);
15
+ const status = epic.status === "draft" ? "draft, awaiting approval" : epic.status;
16
+ return [
17
+ `${epic.id === current?.id ? "▶" : " "} ${epic.position}. ${epic.title} [${status}]`,
18
+ ` Outcome: ${epic.outcome_md || "Not described"}`,
19
+ ` Depends on: ${dependencies.length ? dependencies.join(", ") : "none"}`,
20
+ ` ${progressBar(epic.merged, epic.total)} ${epic.merged}/${epic.total} merged`,
21
+ ];
22
+ });
23
+ }
package/dist/tui/App.js CHANGED
@@ -9,20 +9,23 @@ import { agentAdd } from "../agent-commands.js";
9
9
  import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
10
10
  import { Banner } from "./Banner.js";
11
11
  import { Bubble } from "./Bubble.js";
12
+ import { ChatPanel } from "./Chat.js";
13
+ import { chatAgentLabel, chatScrollOffset, chatViewLines, emptyThread, followShouldStop, leaveChat, mergeHydratedThread, pendingActivity, rehydrateThread, threadKey, } from "./chat-view.js";
12
14
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
13
15
  import { DecisionPanel, decisionRows } from "./Decision.js";
14
16
  import { COMMANDS, Help } from "./Help.js";
15
17
  import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
16
18
  import { sectionAt, ticketScrollOffset, ticketViewLines, toggleSection } from "./ticket-view.js";
17
19
  import { SettingsPanel } from "./Settings.js";
20
+ import { RoadmapPanel, roadmapLines } from "./Roadmap.js";
18
21
  import { Splash } from "./Splash.js";
19
22
  import TextInput from "./TextInput.js";
20
23
  import { alertOnce } from "./alert.js";
21
24
  import { bubbleRows } from "./height.js";
22
25
  import { planLayout, splitPanels } from "./layout.js";
23
26
  import { parseLine } from "./parse.js";
24
- import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
25
- import { inputActive, promptPlaceholder, QUEUED_STEP, REPLY_WAIT_MS, settleChatReply } from "./chat-wait.js";
27
+ import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, followChat, loadChatMessages, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, POLL_MS, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
28
+ import { inputActive, NO_REPLY_NOTE, promptPlaceholder, REPLY_WAIT_MS } from "./chat-wait.js";
26
29
  import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
27
30
  import { EARLIER_PAGE } from "./inbox.js";
28
31
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
@@ -46,11 +49,15 @@ export function App({ initial }) {
46
49
  const [view, setView] = useState("home");
47
50
  const [live, setLive] = useState("connecting");
48
51
  const [messages, setMessages] = useState([]);
52
+ const [threads, setThreads] = useState({});
53
+ const [chatOffset, setChatOffset] = useState(0);
54
+ const [now, setNow] = useState(Date.now());
49
55
  const [draft, setDraft] = useState("");
50
56
  const [busy, setBusy] = useState(false);
51
57
  const [notice, setNotice] = useState(null);
52
58
  const [ticketKey, setTicketKey] = useState(null);
53
59
  const [ticketOffset, setTicketOffset] = useState(0);
60
+ const [roadmapOffset, setRoadmapOffset] = useState(0);
54
61
  const [ticketCollapsed, setTicketCollapsed] = useState([]);
55
62
  const [ready, setReady] = useState(false);
56
63
  const [stream, setStream] = useState([]);
@@ -97,6 +104,12 @@ export function App({ initial }) {
97
104
  if (live.cockpit > 0)
98
105
  setStarted(true);
99
106
  }, [ready, rows, columns, width, board.decisions]);
107
+ useEffect(() => {
108
+ if (view !== "chat")
109
+ return;
110
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
111
+ return () => clearInterval(timer);
112
+ }, [view]);
100
113
  const applySnapshot = useCallback((snapshot) => {
101
114
  const token = loads.current.start(snapshot.workspace.id);
102
115
  if (!loads.current.isCurrent(token))
@@ -302,27 +315,95 @@ export function App({ initial }) {
302
315
  setBusy(false);
303
316
  }
304
317
  }, [config, say]);
318
+ const agentName = useCallback((role) => chatAgentLabel(role, board.agents.find((agent) => agent.role === role)?.display_name), [board.agents]);
319
+ const setThread = useCallback((key, update) => {
320
+ setThreads((prior) => {
321
+ const current = prior[key] ?? emptyThread(key.split(":")[1], key.split(":")[0] ?? workspace.id);
322
+ return { ...prior, [key]: update(current) };
323
+ });
324
+ }, [workspace.id]);
325
+ const hydrateChat = useCallback(async (role) => {
326
+ const key = threadKey(workspace.id, role);
327
+ try {
328
+ const remote = rehydrateThread(await loadChatMessages(config, role), role, workspace.id);
329
+ setThreads((prior) => ({
330
+ ...prior,
331
+ [key]: mergeHydratedThread(prior[key] ?? null, remote),
332
+ }));
333
+ }
334
+ catch (error) {
335
+ setNotice(error instanceof Error ? error.message : String(error));
336
+ }
337
+ }, [config, workspace.id]);
338
+ const openAgentChat = useCallback((role) => {
339
+ setMode(role);
340
+ setView("chat");
341
+ setCursor(null);
342
+ selectedRef.current = null;
343
+ setChatOffset(10_000);
344
+ void hydrateChat(role);
345
+ }, [hydrateChat]);
305
346
  const askAgent = useCallback((role, text) => {
306
- const id = nextId();
307
- setMessages((prior) => [...prior, { id, speaker: role, body: "", pending: true }]);
347
+ const key = threadKey(workspace.id, role);
308
348
  const since = new Date().toISOString();
349
+ const youTurn = { id: nextId(), speaker: "you", body: text, at: since };
350
+ setMode(role);
351
+ setView("chat");
352
+ setChatOffset(10_000);
353
+ setThread(key, (current) => ({
354
+ ...current,
355
+ role,
356
+ workspaceId: workspace.id,
357
+ turns: [...current.turns, youTurn],
358
+ pending: { messageId: null, runId: null, startedAt: since, status: "queued", activity: [] },
359
+ }));
309
360
  void (async () => {
310
361
  try {
311
- await postAgentMessage(role, text, config);
312
- setMessages((prior) => prior.map((message) => message.id === id
313
- ? { ...message, steps: [QUEUED_STEP] }
314
- : message));
315
- const reply = await waitForReply(config, role, since, REPLY_WAIT_MS);
316
- setMessages((prior) => prior.map((message) => message.id === id
317
- ? { ...message, ...settleChatReply(reply) }
318
- : message));
362
+ const posted = await postAgentMessage(role, text, config);
363
+ const deadline = Date.now() + REPLY_WAIT_MS;
364
+ while (Date.now() <= deadline) {
365
+ const follow = await followChat(config, role, posted.id, since);
366
+ const activity = pendingActivity(follow.events, follow.run, agentName(role));
367
+ setThread(key, (current) => ({
368
+ ...current,
369
+ pending: {
370
+ messageId: posted.id,
371
+ runId: follow.run?.id ?? null,
372
+ startedAt: follow.run?.started_at ?? current.pending?.startedAt ?? since,
373
+ status: follow.run?.status ?? "queued",
374
+ activity: activity.lines,
375
+ },
376
+ }));
377
+ if (followShouldStop(follow.run, follow.reply)) {
378
+ const body = follow.reply ?? follow.run?.summary ?? NO_REPLY_NOTE;
379
+ setThread(key, (current) => ({
380
+ ...current,
381
+ turns: [...current.turns, { id: `reply-${posted.id}`, speaker: role, body, at: new Date().toISOString() }],
382
+ pending: null,
383
+ }));
384
+ setChatOffset(10_000);
385
+ return;
386
+ }
387
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
388
+ }
389
+ setThread(key, (current) => ({
390
+ ...current,
391
+ turns: [...current.turns, {
392
+ id: `timeout-${posted.id}`, speaker: role, body: NO_REPLY_NOTE, at: new Date().toISOString(),
393
+ }],
394
+ pending: null,
395
+ }));
319
396
  }
320
397
  catch (error) {
321
398
  const body = error instanceof Error ? error.message : String(error);
322
- setMessages((prior) => prior.map((message) => message.id === id ? { ...message, body, pending: false, done: true } : message));
399
+ setThread(key, (current) => ({
400
+ ...current,
401
+ turns: [...current.turns, { id: nextId(), speaker: role, body, at: new Date().toISOString() }],
402
+ pending: null,
403
+ }));
323
404
  }
324
405
  })();
325
- }, [config]);
406
+ }, [agentName, config, setThread, workspace.id]);
326
407
  const openTicket = useCallback(async (key) => {
327
408
  setTicketKey(key);
328
409
  setView("ticket");
@@ -415,7 +496,6 @@ export function App({ initial }) {
415
496
  setNotice(null);
416
497
  const action = parseLine(text);
417
498
  if (action.kind === "say") {
418
- say("you", text);
419
499
  if (mode !== "browse")
420
500
  askAgent(mode, text);
421
501
  else
@@ -424,15 +504,12 @@ export function App({ initial }) {
424
504
  }
425
505
  switch (action.kind) {
426
506
  case "mode":
427
- setMode(action.mode);
428
- setCursor(null);
429
- selectedRef.current = null;
430
- say("system", action.mode === "architect"
431
- ? "Talking to the architect. Ask questions, refine the epic, then explicitly request a draft."
432
- : "Talking to the orchestrator. It moves work already in flight.");
507
+ openAgentChat(action.mode);
433
508
  return;
434
509
  case "view":
435
510
  setView(action.view);
511
+ if (action.view === "roadmap")
512
+ setRoadmapOffset(0);
436
513
  if (action.view === "inbox") {
437
514
  const header = selectedDecisionId ? decisionHeaderIndex(inbox, selectedDecisionId) : 0;
438
515
  setInboxFocus(header >= 0 ? header : 0);
@@ -736,9 +813,9 @@ export function App({ initial }) {
736
813
  default:
737
814
  return;
738
815
  }
739
- }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder, changeWorkspace,
740
- config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering, selectedDecisionId,
741
- submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
816
+ }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
817
+ changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
818
+ selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
742
819
  useInput((input, key) => {
743
820
  if (key.ctrl && input === "c")
744
821
  exit();
@@ -758,14 +835,21 @@ export function App({ initial }) {
758
835
  const settled = messages.filter((message) => message.done);
759
836
  const inFlight = messages.filter((message) => !message.done);
760
837
  const splash = !started;
838
+ const chatRole = view === "chat" && mode !== "browse" ? mode : null;
839
+ const activeThread = chatRole
840
+ ? threads[threadKey(workspace.id, chatRole)] ?? emptyThread(chatRole, workspace.id)
841
+ : null;
842
+ const chatLabel = chatRole ? agentName(chatRole) : "";
843
+ const chatLines = activeThread ? chatViewLines(activeThread, Math.max(20, width), chatLabel, now) : [];
761
844
  const scrollback = splash ? [] : [
762
845
  { key: "banner" },
763
846
  { key: "help", message: { id: "help", speaker: "system", body: "", panel: "help" } },
764
847
  ...settled.map((message) => ({ key: message.id, message })),
765
848
  ];
849
+ const chatting = view === "chat";
766
850
  const plan = planLayout({
767
- rows, columns, width, splash, ready, decision: decisionRows(decisions),
768
- inFlight: inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
851
+ rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
852
+ inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
769
853
  notice: Boolean(notice), home: view === "home",
770
854
  });
771
855
  const agentsView = splitPanels(plan.panels);
@@ -777,15 +861,15 @@ export function App({ initial }) {
777
861
  if (item.message.panel === "help")
778
862
  return _jsx(Help, { width: width }, item.key);
779
863
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
780
- } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
864
+ } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
781
865
  ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
782
- : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, inFlight.map((message) => _jsx(Bubble, { message: message, width: width }, message.id)), _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, answering ? " esc cancels" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
866
+ : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
783
867
  setDraft(next);
784
868
  if (editingRef.current)
785
869
  setEditing({ key: editingRef.current.key, draft: next });
786
870
  }, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
787
871
  ? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
788
- : promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
872
+ : promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || chatting || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : chatting ? `${chatLabel}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
789
873
  if (answering) {
790
874
  setAnswering(null);
791
875
  setDraft("");
@@ -798,6 +882,14 @@ export function App({ initial }) {
798
882
  setNotice(null);
799
883
  return;
800
884
  }
885
+ if (chatting || mode !== "browse") {
886
+ const left = leaveChat();
887
+ setMode(left.mode);
888
+ setView(left.view);
889
+ setDraft("");
890
+ setNotice(left.notice);
891
+ return;
892
+ }
801
893
  if (view !== "home") {
802
894
  setView("home");
803
895
  setTicketKey(null);
@@ -806,6 +898,16 @@ export function App({ initial }) {
806
898
  setCursor(null);
807
899
  selectedRef.current = null;
808
900
  }, onUp: () => {
901
+ if (chatting && !draft) {
902
+ const overflow = chatLines.length > plan.panels;
903
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
904
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - 1));
905
+ return;
906
+ }
907
+ if (view === "roadmap" && !draft) {
908
+ setRoadmapOffset((current) => Math.max(0, current - 1));
909
+ return;
910
+ }
809
911
  if (view === "ticket" && !draft) {
810
912
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
811
913
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
@@ -824,6 +926,17 @@ export function App({ initial }) {
824
926
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
825
927
  setDraft(history.current[historyAt.current] ?? "");
826
928
  }, onDown: () => {
929
+ if (chatting && !draft) {
930
+ const overflow = chatLines.length > plan.panels;
931
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
932
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + 1));
933
+ return;
934
+ }
935
+ if (view === "roadmap" && !draft) {
936
+ const max = Math.max(0, roadmapLines(board, width).length - Math.max(1, plan.panels - 1));
937
+ setRoadmapOffset((current) => Math.min(max, current + 1));
938
+ return;
939
+ }
827
940
  if (view === "ticket" && !draft) {
828
941
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
829
942
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
@@ -847,11 +960,33 @@ export function App({ initial }) {
847
960
  }
848
961
  setDraft(history.current[historyAt.current] ?? "");
849
962
  }, onPageUp: () => {
963
+ if (chatting && !draft) {
964
+ const overflow = chatLines.length > plan.panels;
965
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
966
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - Math.max(1, inner)));
967
+ return;
968
+ }
969
+ if (view === "roadmap" && !draft) {
970
+ setRoadmapOffset((current) => Math.max(0, current - Math.max(1, plan.panels - 1)));
971
+ return;
972
+ }
850
973
  if (view === "ticket" && !draft) {
851
974
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
852
975
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
853
976
  }
854
977
  }, onPageDown: () => {
978
+ if (chatting && !draft) {
979
+ const overflow = chatLines.length > plan.panels;
980
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
981
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + Math.max(1, inner)));
982
+ return;
983
+ }
984
+ if (view === "roadmap" && !draft) {
985
+ const page = Math.max(1, plan.panels - 1);
986
+ const max = Math.max(0, roadmapLines(board, width).length - page);
987
+ setRoadmapOffset((current) => Math.min(max, current + page));
988
+ return;
989
+ }
855
990
  if (view === "ticket" && !draft) {
856
991
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
857
992
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + Math.max(1, plan.panels)));
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { BoundedPanel as Panel } from "./bounded.js";
4
+ import { chatScrollOffset, chatViewLines, } from "./chat-view.js";
5
+ import { UI } from "./theme.js";
6
+ export function ChatPanel({ thread, width = 80, rows = 24, offset = 0, label, now = Date.now(), }) {
7
+ const entries = chatViewLines(thread, Math.max(20, width), label, now);
8
+ const drawable = Math.max(0, rows);
9
+ const overflow = entries.length > drawable;
10
+ const inner = overflow ? Math.max(0, drawable - 1) : drawable;
11
+ const start = chatScrollOffset(entries.length, inner, offset);
12
+ const shown = entries.slice(start, start + inner);
13
+ const hiddenAbove = start;
14
+ const hiddenBelow = Math.max(0, entries.length - start - shown.length);
15
+ return (_jsx(Panel, { width: width, rows: drawable, children: [
16
+ ...(shown.length === 0
17
+ ? [
18
+ _jsx(Text, { color: UI.dim, children: `No messages yet. Say something to ${label}.` }, "empty"),
19
+ ]
20
+ : shown.map((entry) => (_jsx(Text, { color: entry.kind === "status" ? UI.accent : entry.kind === "activity" ? UI.dim
21
+ : entry.kind === "speaker" ? UI.warn : UI.text, wrap: "truncate", children: entry.text }, entry.key)))),
22
+ overflow ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [hiddenAbove ? `${hiddenAbove}↑ ` : "", hiddenBelow ? `${hiddenBelow}↓` : ""] }, "more")) : null,
23
+ ] }));
24
+ }
25
+ /** Thread stacked above a prompt, for layout tests. */
26
+ export function ChatFrame({ thread, width = 80, rows = 12, offset = 0, label, prompt, now = Date.now(), }) {
27
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(ChatPanel, { thread: thread, width: width, rows: rows, offset: offset, label: label, now: now }), _jsx(Text, { children: prompt })] }));
28
+ }
@@ -6,6 +6,7 @@ import { inkColor } from "../out/theme.js";
6
6
  import { UI } from "./theme.js";
7
7
  import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js";
8
8
  import { agentDisplayRows } from "./agent-rows.js";
9
+ import { currentRoadmapEpic } from "../roadmap.js";
9
10
  const DOT = "●";
10
11
  /**
11
12
  * The width at which the board and the agents stop competing for the same
@@ -157,8 +158,12 @@ export function epicsRows(board, cap = 3) {
157
158
  return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
158
159
  }
159
160
  export function nowEntries(board) {
160
- return board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
161
- .map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` }));
161
+ const current = currentRoadmapEpic(board.epics ?? []);
162
+ return [
163
+ ...(current ? [{ key: `epic:${current.id}`, headline: `Current epic: ${current.title}` }] : []),
164
+ ...board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
165
+ .map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` })),
166
+ ];
162
167
  }
163
168
  function NowStrip({ board, width, rows }) {
164
169
  const entries = nowEntries(board);
package/dist/tui/Help.js CHANGED
@@ -6,6 +6,7 @@ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are
6
6
  /** Everything you can type. The app is driven from here, not from flags. */
7
7
  export const COMMANDS = [
8
8
  { name: "/board", help: "the kanban board" },
9
+ { name: "/roadmap", help: "the vision and ordered epic progress" },
9
10
  { name: "/inbox", args: "[more]", help: "unread, earlier, and numbered decisions; Enter answers" },
10
11
  { name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open a full ticket view or create one" },
11
12
  { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
@@ -14,7 +15,7 @@ export const COMMANDS = [
14
15
  { name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
15
16
  { name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
16
17
  { name: "/epics", help: "list epics and ticket progress" },
17
- { name: "/architect", help: "talk to the agent that shapes draft epics" },
18
+ { name: "/architect", help: "open a chat with the architect" },
18
19
  { name: "/plan", help: "alias for /architect" },
19
20
  { name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
20
21
  { name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
@@ -24,7 +25,7 @@ export const COMMANDS = [
24
25
  { name: "/on", help: "turn on the current workspace" },
25
26
  { name: "/off", help: "turn off the current workspace" },
26
27
  { name: "/feed", help: "what just happened" },
27
- { name: "/orchestrator", help: "talk to the agent that gets work in flight finished" },
28
+ { name: "/orchestrator", help: "open a chat with the orchestrator" },
28
29
  { name: "/refresh", help: "reload the board now" },
29
30
  { name: "/help", help: "this list" },
30
31
  { name: "/exit", help: "leave" },
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { roadmapText } from "../roadmap.js";
4
+ import { inkColor } from "../out/theme.js";
5
+ import { epicProgress } from "./data.js";
6
+ import { BoundedPanel as Panel, Heading } from "./bounded.js";
7
+ import { wrapLines } from "./ticket-view.js";
8
+ import { UI } from "./theme.js";
9
+ export function roadmapLines(board, width) {
10
+ if (!board.roadmap)
11
+ return ["No roadmap yet. Talk to /architect to create one."];
12
+ const lines = ["Vision"];
13
+ lines.push(...wrapLines(board.roadmap.vision_md, Math.max(12, width - 2)));
14
+ lines.push("");
15
+ const progress = new Map(epicProgress(board).map((row) => [row.epic.id, row]));
16
+ lines.push(...roadmapText(board.epics.map((epic) => ({ ...epic,
17
+ merged: progress.get(epic.id)?.merged ?? 0,
18
+ total: progress.get(epic.id)?.total ?? 0,
19
+ }))));
20
+ return lines;
21
+ }
22
+ export function RoadmapPanel({ board, width = 80, rows = 12, offset = 0 }) {
23
+ const lines = roadmapLines(board, width);
24
+ const budget = Math.max(0, rows - 1);
25
+ const start = Math.max(0, Math.min(offset, Math.max(0, lines.length - budget)));
26
+ const shown = lines.slice(start, start + budget);
27
+ const note = `${board.epics.length} epics${start ? ` · ${start}↑` : ""}`
28
+ + `${start + budget < lines.length ? ` · ${lines.length - start - budget}↓` : ""}`;
29
+ return _jsx(Panel, { width: width, rows: rows, children: [
30
+ _jsx(Heading, { text: "Roadmap", note: note }, "h"),
31
+ ...shown.map((line, index) => {
32
+ const current = line.startsWith("▶");
33
+ const draft = line.includes("draft, awaiting approval");
34
+ return _jsx(Text, { color: current ? UI.accent
35
+ : draft ? inkColor("warning") : line === "Vision" ? UI.text : UI.dim, bold: current || line === "Vision", inverse: current, wrap: "truncate", children: line || " " }, `${start + index}:${line}`);
36
+ }),
37
+ ] });
38
+ }
@@ -0,0 +1,125 @@
1
+ import { narrateEvents } from "./narrate.js";
2
+ import { wrapLines } from "./ticket-view.js";
3
+ /** Last 50 exchanges (a human turn and an agent turn) when rehydrating. */
4
+ export const CHAT_EXCHANGE_LIMIT = 50;
5
+ export const CHAT_MESSAGE_LIMIT = CHAT_EXCHANGE_LIMIT * 2;
6
+ const HUMAN_ROLES = new Set(["human", "operator"]);
7
+ const HUMAN_TARGETS = new Set(["human", "operator", "all"]);
8
+ export function threadKey(workspaceId, role) {
9
+ return `${workspaceId}:${role}`;
10
+ }
11
+ export function emptyThread(role, workspaceId) {
12
+ return { role, workspaceId, turns: [], pending: null };
13
+ }
14
+ export function chatAgentLabel(role, displayName) {
15
+ const named = displayName?.trim();
16
+ if (named)
17
+ return named;
18
+ return role === "architect" ? "Architect" : "Orchestrator";
19
+ }
20
+ export function formatWorkingElapsed(ms) {
21
+ const seconds = Math.max(0, Math.floor(ms / 1000));
22
+ if (seconds < 60)
23
+ return `${seconds}s`;
24
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
25
+ }
26
+ export function workingStatus(label, startedAt, now = Date.now()) {
27
+ const start = startedAt ? Date.parse(startedAt) : Number.NaN;
28
+ const elapsed = Number.isFinite(start) ? Math.max(0, now - start) : 0;
29
+ return `${label} is working (${formatWorkingElapsed(elapsed)})`;
30
+ }
31
+ export function isChatConversation(message, role) {
32
+ if (message.ticket_id)
33
+ return false;
34
+ const fromAgent = message.from_role === role && HUMAN_TARGETS.has(message.to_role);
35
+ const toAgent = HUMAN_ROLES.has(message.from_role) && message.to_role === role;
36
+ return fromAgent || toAgent;
37
+ }
38
+ export function rehydrateThread(messages, role, workspaceId) {
39
+ const conversation = messages
40
+ .filter((message) => isChatConversation(message, role))
41
+ .sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))
42
+ .slice(-CHAT_MESSAGE_LIMIT);
43
+ return {
44
+ role,
45
+ workspaceId,
46
+ turns: conversation.map((message) => ({
47
+ id: message.id,
48
+ speaker: message.from_role === role ? role : "you",
49
+ body: message.body_md,
50
+ at: message.created_at,
51
+ })),
52
+ pending: null,
53
+ };
54
+ }
55
+ export function restoreThread(threads, workspaceId, role) {
56
+ return threads[threadKey(workspaceId, role)] ?? null;
57
+ }
58
+ export function leaveChat() {
59
+ return { view: "home", mode: "browse", notice: "Back to browse." };
60
+ }
61
+ export function openChat(role) {
62
+ return { view: "chat", mode: role };
63
+ }
64
+ export function chatScrollOffset(count, rows, offset) {
65
+ if (count <= rows)
66
+ return 0;
67
+ return Math.max(0, Math.min(offset, count - rows));
68
+ }
69
+ export function chatWindow(count, rows, offset) {
70
+ const start = chatScrollOffset(count, rows, offset);
71
+ return { start, end: Math.min(count, start + Math.max(0, rows)) };
72
+ }
73
+ export function pendingActivity(events, run, label, now = Date.now()) {
74
+ const live = !run || ["queued", "running", ""].includes(run.status ?? "running");
75
+ return {
76
+ status: live ? workingStatus(label, run?.started_at ?? null, now) : "",
77
+ lines: narrateEvents(events).map((line) => line.title),
78
+ };
79
+ }
80
+ export function followShouldStop(run, reply) {
81
+ if (reply)
82
+ return true;
83
+ if (run && !["queued", "running"].includes(run.status))
84
+ return true;
85
+ return false;
86
+ }
87
+ export function chatViewLines(thread, width, label, now = Date.now()) {
88
+ const lines = [];
89
+ const bodyWidth = Math.max(8, width);
90
+ for (const turn of thread.turns) {
91
+ const speaker = turn.speaker === "you" ? "you" : label;
92
+ lines.push({ key: `${turn.id}:who`, kind: "speaker", text: speaker });
93
+ const body = turn.body.trim() || (turn.pending ? "" : "");
94
+ if (body) {
95
+ wrapLines(body, bodyWidth).forEach((text, index) => {
96
+ lines.push({ key: `${turn.id}:body:${index}`, kind: "body", text });
97
+ });
98
+ }
99
+ }
100
+ if (thread.pending) {
101
+ const live = pendingActivity([], { started_at: thread.pending.startedAt, status: thread.pending.status }, label, now);
102
+ const status = thread.pending.status && !["queued", "running", ""].includes(thread.pending.status)
103
+ ? ""
104
+ : live.status;
105
+ if (status)
106
+ lines.push({ key: "pending:status", kind: "status", text: status });
107
+ for (const [index, title] of thread.pending.activity.entries()) {
108
+ wrapLines(title, bodyWidth).forEach((text, line) => {
109
+ lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
110
+ });
111
+ }
112
+ }
113
+ return lines;
114
+ }
115
+ export function mergeHydratedThread(local, remote) {
116
+ if (!local)
117
+ return remote;
118
+ const known = new Set(remote.turns.map((turn) => turn.id));
119
+ const extras = local.turns.filter((turn) => !known.has(turn.id));
120
+ return {
121
+ ...remote,
122
+ turns: [...remote.turns, ...extras],
123
+ pending: local.pending,
124
+ };
125
+ }
package/dist/tui/data.js CHANGED
@@ -1,4 +1,4 @@
1
- import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
1
+ import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getRoadmap, getRun, getWorkspace, listAgents, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
2
2
  import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
3
  import { readEpicSpec } from "../epics.js";
4
4
  import { EARLIER_PAGE } from "./inbox.js";
@@ -31,12 +31,12 @@ function withTicketKeys(messages, tickets) {
31
31
  }
32
32
  export async function loadSnapshot(config = loadConfig(), options = {}) {
33
33
  const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
34
- const [status, workspaceData, ticketData, agentData, epicData, feedData, unreadData, earlierData] = await Promise.all([
34
+ const [status, workspaceData, ticketData, agentData, roadmapData, feedData, unreadData, earlierData] = await Promise.all([
35
35
  getStatus(config),
36
36
  getWorkspace(config),
37
37
  listTickets(config),
38
38
  listAgents(config),
39
- listEpics(config),
39
+ getRoadmap(config),
40
40
  listFeed(config),
41
41
  listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
42
42
  listMessages({
@@ -73,7 +73,8 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
73
73
  board: {
74
74
  tickets,
75
75
  agents: agentData.agents,
76
- epics: epicData.epics,
76
+ epics: roadmapData.epics,
77
+ roadmap: roadmapData.roadmap,
77
78
  decisions: status.decisions,
78
79
  messages: withTicketKeys(unreadData.messages, tickets),
79
80
  earlier: earlier.slice(0, earlierLimit),
@@ -123,7 +124,37 @@ export async function switchWorkspace(slug, config = loadConfig()) {
123
124
  return snapshot;
124
125
  }
125
126
  export async function postAgentMessage(role, body, config) {
126
- await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
127
+ const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
128
+ return message;
129
+ }
130
+ export async function loadChatMessages(config, role) {
131
+ const { messages } = await listMessages({
132
+ unticketed: true,
133
+ fromRoles: ["human", "operator", role],
134
+ limit: 200,
135
+ order: "desc",
136
+ }, config);
137
+ return messages.slice().reverse();
138
+ }
139
+ export async function followChat(config, role, messageId, since) {
140
+ const kind = role === "architect" ? "architect" : "orchestrate";
141
+ const status = await getStatus(config);
142
+ const runs = [...(status.live_runs ?? []), ...(status.recent_runs ?? [])];
143
+ const tagged = runs.find((run) => run.message_id === messageId);
144
+ const live = runs.find((run) => run.kind === kind && ["queued", "running"].includes(run.status));
145
+ const match = tagged ?? live ?? null;
146
+ let run = null;
147
+ let events = [];
148
+ if (match) {
149
+ const detail = await getRun(match.id, config);
150
+ run = detail.run;
151
+ events = detail.events;
152
+ }
153
+ const { messages } = await listMessages({ since, limit: 50 }, config);
154
+ const reply = messages.find((message) => message.from_role === role
155
+ && ["human", "operator", "all"].includes(message.to_role)
156
+ && (!run || !message.run_id || message.run_id === run.id));
157
+ return { run, events, reply: reply?.body_md ?? null };
127
158
  }
128
159
  export async function loadTicketDetail(config, key) {
129
160
  const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
package/dist/tui/parse.js CHANGED
@@ -18,6 +18,7 @@ export function parseLine(raw) {
18
18
  return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
19
19
  : { kind: "mode", mode: "architect" };
20
20
  case "board":
21
+ case "roadmap":
21
22
  case "feed":
22
23
  case "settings":
23
24
  return { kind: "view", view: word.toLowerCase() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.18.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"