@higherdev/cli 0.18.0 → 0.22.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
@@ -58,6 +58,9 @@ export async function queueTicket(key, config = loadConfig()) {
58
58
  export async function cancelTicket(key, config = loadConfig()) {
59
59
  return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/cancel`);
60
60
  }
61
+ export async function mergeTicket(key, reason, config = loadConfig()) {
62
+ return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/merge`, { reason: reason ?? "", by: "owner" }, true);
63
+ }
61
64
  export async function listTicketRunEvents(key, afterAt, afterId, config = loadConfig()) {
62
65
  const query = new URLSearchParams();
63
66
  if (afterAt)
@@ -75,6 +78,9 @@ export async function listTicketStory(key, config = loadConfig()) {
75
78
  export async function postMessage(fields, config = loadConfig()) {
76
79
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
77
80
  }
81
+ export async function getRun(id, config = loadConfig()) {
82
+ return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
83
+ }
78
84
  export async function answerDecision(id, answer_md, config = loadConfig()) {
79
85
  return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
80
86
  }
@@ -128,6 +134,12 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
128
134
  export async function removeHostEnv(host, name, config = loadConfig()) {
129
135
  return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
130
136
  }
137
+ export async function getRoadmap(config = loadConfig()) {
138
+ return request(config, "GET", `/api/w/${config.slug}/roadmap`);
139
+ }
140
+ export async function updateRoadmap(vision_md, config = loadConfig()) {
141
+ return request(config, "PATCH", `/api/w/${config.slug}/roadmap`, { vision_md });
142
+ }
131
143
  export async function listEpics(config = loadConfig()) {
132
144
  return request(config, "GET", `/api/w/${config.slug}/epics`);
133
145
  }
@@ -137,6 +149,9 @@ export async function createEpic(fields, config = loadConfig()) {
137
149
  export async function approveEpic(id, config = loadConfig()) {
138
150
  return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
139
151
  }
152
+ export async function updateEpic(id, fields, config = loadConfig()) {
153
+ return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id, ...fields });
154
+ }
140
155
  export async function deleteEpic(id, config = loadConfig()) {
141
156
  return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
142
157
  }
@@ -152,6 +167,10 @@ export async function listMessages(options = {}, config = loadConfig()) {
152
167
  query.set("offset", String(options.offset));
153
168
  if (options.toRoles?.length)
154
169
  query.set("to_role", options.toRoles.join(","));
170
+ if (options.fromRoles?.length)
171
+ query.set("from_role", options.fromRoles.join(","));
172
+ if (options.unticketed)
173
+ query.set("unticketed", "true");
155
174
  if (options.undelivered)
156
175
  query.set("undelivered", "true");
157
176
  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, mergeTicket, 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";
@@ -138,7 +139,16 @@ async function cmdTicket(argv, deps = {}) {
138
139
  console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
139
140
  return;
140
141
  }
141
- fail("usage: hd ticket list | show KEY | new --title TITLE | queue KEY | cancel KEY");
142
+ if (action === "merge") {
143
+ const parsed = flags(rest);
144
+ const key = parsed.rest[0];
145
+ if (!key || parsed.rest.length !== 1)
146
+ fail("usage: hd ticket merge KEY [--reason TEXT]");
147
+ const { ticket } = await mergeTicket(key.toUpperCase(), parsed.opts.reason);
148
+ console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
149
+ return;
150
+ }
151
+ fail("usage: hd ticket list | show KEY | new --title TITLE | queue KEY | cancel KEY | merge KEY");
142
152
  }
143
153
  async function cmdEpic(argv) {
144
154
  const [action, ...rest] = argv;
@@ -178,7 +188,44 @@ async function cmdEpic(argv) {
178
188
  }
179
189
  return;
180
190
  }
181
- fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID");
191
+ if (action === "set") {
192
+ const parsed = flags(rest);
193
+ const id = parsed.rest[0];
194
+ const choices = ["after", "position", "depends-on"].filter((name) => parsed.opts[name] !== undefined);
195
+ if (!id || parsed.rest.length !== 1 || choices.length !== 1) {
196
+ fail("usage: hd epic set ID --after ID | --position N | --depends-on ID");
197
+ }
198
+ const position = parsed.opts.position ? Number(parsed.opts.position) : undefined;
199
+ if (position !== undefined && (!Number.isInteger(position) || position < 1)) {
200
+ fail("position must be a positive integer.");
201
+ }
202
+ const { epic } = await updateEpic(id, {
203
+ ...(parsed.opts.after ? { after: parsed.opts.after } : {}),
204
+ ...(position !== undefined ? { position } : {}),
205
+ ...(parsed.opts["depends-on"] ? { depends_on: [parsed.opts["depends-on"]] } : {}),
206
+ });
207
+ console.log(`${c.bold(epic.id)} position ${epic.position}`);
208
+ return;
209
+ }
210
+ fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
211
+ }
212
+ async function cmdRoadmap(argv) {
213
+ const parsed = flags(argv);
214
+ if (parsed.rest.length || [...parsed.bools].some((name) => name !== "json") || Object.keys(parsed.opts).length) {
215
+ fail("usage: hd roadmap [--json]");
216
+ }
217
+ const data = await getRoadmap();
218
+ if (parsed.bools.has("json"))
219
+ return console.log(JSON.stringify(data));
220
+ if (!data.roadmap)
221
+ return console.log(c.dim("No roadmap."));
222
+ console.log(c.bold("Vision"));
223
+ console.log(data.roadmap.vision_md);
224
+ if (!data.epics.length)
225
+ return console.log(`\n${c.dim("No epics.")}`);
226
+ console.log("");
227
+ for (const line of roadmapText(data.epics))
228
+ console.log(line);
182
229
  }
183
230
  async function cmdPlan(argv) {
184
231
  if (argv.length)
@@ -554,6 +601,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
554
601
  await cmdEpic(rest);
555
602
  return;
556
603
  }
604
+ if (cmd === "roadmap") {
605
+ await cmdRoadmap(rest);
606
+ return;
607
+ }
557
608
  if (cmd === "plan") {
558
609
  await cmdPlan(rest);
559
610
  return;
package/dist/out.js CHANGED
@@ -64,8 +64,9 @@ export function usage() {
64
64
  return [
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
- ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
67
+ ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel | merge")} 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, mergeTicket, 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);
@@ -616,6 +693,20 @@ export function App({ initial }) {
616
693
  setBusy(false);
617
694
  }
618
695
  return;
696
+ case "merge":
697
+ setBusy(true);
698
+ try {
699
+ const { ticket: merged } = await mergeTicket(config, action.key);
700
+ say("system", `${merged.key} approved over review.`);
701
+ await refresh();
702
+ }
703
+ catch (error) {
704
+ setNotice(error instanceof Error ? error.message : String(error));
705
+ }
706
+ finally {
707
+ setBusy(false);
708
+ }
709
+ return;
619
710
  case "agent-add":
620
711
  setBusy(true);
621
712
  try {
@@ -736,9 +827,9 @@ export function App({ initial }) {
736
827
  default:
737
828
  return;
738
829
  }
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]);
830
+ }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
831
+ changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
832
+ selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
742
833
  useInput((input, key) => {
743
834
  if (key.ctrl && input === "c")
744
835
  exit();
@@ -758,14 +849,21 @@ export function App({ initial }) {
758
849
  const settled = messages.filter((message) => message.done);
759
850
  const inFlight = messages.filter((message) => !message.done);
760
851
  const splash = !started;
852
+ const chatRole = view === "chat" && mode !== "browse" ? mode : null;
853
+ const activeThread = chatRole
854
+ ? threads[threadKey(workspace.id, chatRole)] ?? emptyThread(chatRole, workspace.id)
855
+ : null;
856
+ const chatLabel = chatRole ? agentName(chatRole) : "";
857
+ const chatLines = activeThread ? chatViewLines(activeThread, Math.max(20, width), chatLabel, now) : [];
761
858
  const scrollback = splash ? [] : [
762
859
  { key: "banner" },
763
860
  { key: "help", message: { id: "help", speaker: "system", body: "", panel: "help" } },
764
861
  ...settled.map((message) => ({ key: message.id, message })),
765
862
  ];
863
+ const chatting = view === "chat";
766
864
  const plan = planLayout({
767
- rows, columns, width, splash, ready, decision: decisionRows(decisions),
768
- inFlight: inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
865
+ rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
866
+ inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
769
867
  notice: Boolean(notice), home: view === "home",
770
868
  });
771
869
  const agentsView = splitPanels(plan.panels);
@@ -777,15 +875,15 @@ export function App({ initial }) {
777
875
  if (item.message.panel === "help")
778
876
  return _jsx(Help, { width: width }, item.key);
779
877
  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
878
+ } }), 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
879
  ? _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) => {
880
+ : _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
881
  setDraft(next);
784
882
  if (editingRef.current)
785
883
  setEditing({ key: editingRef.current.key, draft: next });
786
884
  }, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
787
885
  ? (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: () => {
886
+ : 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
887
  if (answering) {
790
888
  setAnswering(null);
791
889
  setDraft("");
@@ -798,6 +896,14 @@ export function App({ initial }) {
798
896
  setNotice(null);
799
897
  return;
800
898
  }
899
+ if (chatting || mode !== "browse") {
900
+ const left = leaveChat();
901
+ setMode(left.mode);
902
+ setView(left.view);
903
+ setDraft("");
904
+ setNotice(left.notice);
905
+ return;
906
+ }
801
907
  if (view !== "home") {
802
908
  setView("home");
803
909
  setTicketKey(null);
@@ -806,6 +912,16 @@ export function App({ initial }) {
806
912
  setCursor(null);
807
913
  selectedRef.current = null;
808
914
  }, onUp: () => {
915
+ if (chatting && !draft) {
916
+ const overflow = chatLines.length > plan.panels;
917
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
918
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - 1));
919
+ return;
920
+ }
921
+ if (view === "roadmap" && !draft) {
922
+ setRoadmapOffset((current) => Math.max(0, current - 1));
923
+ return;
924
+ }
809
925
  if (view === "ticket" && !draft) {
810
926
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
811
927
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
@@ -824,6 +940,17 @@ export function App({ initial }) {
824
940
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
825
941
  setDraft(history.current[historyAt.current] ?? "");
826
942
  }, onDown: () => {
943
+ if (chatting && !draft) {
944
+ const overflow = chatLines.length > plan.panels;
945
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
946
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + 1));
947
+ return;
948
+ }
949
+ if (view === "roadmap" && !draft) {
950
+ const max = Math.max(0, roadmapLines(board, width).length - Math.max(1, plan.panels - 1));
951
+ setRoadmapOffset((current) => Math.min(max, current + 1));
952
+ return;
953
+ }
827
954
  if (view === "ticket" && !draft) {
828
955
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
829
956
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
@@ -847,11 +974,33 @@ export function App({ initial }) {
847
974
  }
848
975
  setDraft(history.current[historyAt.current] ?? "");
849
976
  }, onPageUp: () => {
977
+ if (chatting && !draft) {
978
+ const overflow = chatLines.length > plan.panels;
979
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
980
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - Math.max(1, inner)));
981
+ return;
982
+ }
983
+ if (view === "roadmap" && !draft) {
984
+ setRoadmapOffset((current) => Math.max(0, current - Math.max(1, plan.panels - 1)));
985
+ return;
986
+ }
850
987
  if (view === "ticket" && !draft) {
851
988
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
852
989
  setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
853
990
  }
854
991
  }, onPageDown: () => {
992
+ if (chatting && !draft) {
993
+ const overflow = chatLines.length > plan.panels;
994
+ const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
995
+ setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + Math.max(1, inner)));
996
+ return;
997
+ }
998
+ if (view === "roadmap" && !draft) {
999
+ const page = Math.max(1, plan.panels - 1);
1000
+ const max = Math.max(0, roadmapLines(board, width).length - page);
1001
+ setRoadmapOffset((current) => Math.min(max, current + page));
1002
+ return;
1003
+ }
855
1004
  if (view === "ticket" && !draft) {
856
1005
  const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
857
1006
  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,15 +6,17 @@ 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" },
12
13
  { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
14
+ { name: "/merge", args: "HD-12", help: "approve a reviewed PR over the reviewer's objections" },
13
15
  { name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
14
16
  { name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
15
17
  { name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
16
18
  { name: "/epics", help: "list epics and ticket progress" },
17
- { name: "/architect", help: "talk to the agent that shapes draft epics" },
19
+ { name: "/architect", help: "open a chat with the architect" },
18
20
  { name: "/plan", help: "alias for /architect" },
19
21
  { name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
20
22
  { name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
@@ -24,7 +26,7 @@ export const COMMANDS = [
24
26
  { name: "/on", help: "turn on the current workspace" },
25
27
  { name: "/off", help: "turn off the current workspace" },
26
28
  { name: "/feed", help: "what just happened" },
27
- { name: "/orchestrator", help: "talk to the agent that gets work in flight finished" },
29
+ { name: "/orchestrator", help: "open a chat with the orchestrator" },
28
30
  { name: "/refresh", help: "reload the board now" },
29
31
  { name: "/help", help: "this list" },
30
32
  { 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,143 @@
1
+ import { narrateEvents } from "./narrate.js";
2
+ import { wrapLines } from "./ticket-view.js";
3
+ export function chatInstructionEffects(body) {
4
+ const parts = [];
5
+ const answered = body.match(/Answered decision[^\n.;]*/i);
6
+ const queued = body.match(/HD-\d+ is queued/i);
7
+ const cap = body.match(/attempt cap is \d+/i);
8
+ if (answered)
9
+ parts.push(answered[0].trim().replace(/[.;]+$/, ""));
10
+ if (queued)
11
+ parts.push(queued[0].trim().replace(/[.;]+$/, ""));
12
+ if (cap)
13
+ parts.push(cap[0].trim().replace(/[.;]+$/, ""));
14
+ return parts.length ? parts.join("; ") : null;
15
+ }
16
+ /** Last 50 exchanges (a human turn and an agent turn) when rehydrating. */
17
+ export const CHAT_EXCHANGE_LIMIT = 50;
18
+ export const CHAT_MESSAGE_LIMIT = CHAT_EXCHANGE_LIMIT * 2;
19
+ const HUMAN_ROLES = new Set(["human", "operator"]);
20
+ const HUMAN_TARGETS = new Set(["human", "operator", "all"]);
21
+ export function threadKey(workspaceId, role) {
22
+ return `${workspaceId}:${role}`;
23
+ }
24
+ export function emptyThread(role, workspaceId) {
25
+ return { role, workspaceId, turns: [], pending: null };
26
+ }
27
+ export function chatAgentLabel(role, displayName) {
28
+ const named = displayName?.trim();
29
+ if (named)
30
+ return named;
31
+ return role === "architect" ? "Architect" : "Orchestrator";
32
+ }
33
+ export function formatWorkingElapsed(ms) {
34
+ const seconds = Math.max(0, Math.floor(ms / 1000));
35
+ if (seconds < 60)
36
+ return `${seconds}s`;
37
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
38
+ }
39
+ export function workingStatus(label, startedAt, now = Date.now()) {
40
+ const start = startedAt ? Date.parse(startedAt) : Number.NaN;
41
+ const elapsed = Number.isFinite(start) ? Math.max(0, now - start) : 0;
42
+ return `${label} is working (${formatWorkingElapsed(elapsed)})`;
43
+ }
44
+ export function isChatConversation(message, role) {
45
+ if (message.ticket_id)
46
+ return false;
47
+ const fromAgent = message.from_role === role && HUMAN_TARGETS.has(message.to_role);
48
+ const toAgent = HUMAN_ROLES.has(message.from_role) && message.to_role === role;
49
+ return fromAgent || toAgent;
50
+ }
51
+ export function rehydrateThread(messages, role, workspaceId) {
52
+ const conversation = messages
53
+ .filter((message) => isChatConversation(message, role))
54
+ .sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))
55
+ .slice(-CHAT_MESSAGE_LIMIT);
56
+ return {
57
+ role,
58
+ workspaceId,
59
+ turns: conversation.map((message) => ({
60
+ id: message.id,
61
+ speaker: message.from_role === role ? role : "you",
62
+ body: message.body_md,
63
+ at: message.created_at,
64
+ })),
65
+ pending: null,
66
+ };
67
+ }
68
+ export function restoreThread(threads, workspaceId, role) {
69
+ return threads[threadKey(workspaceId, role)] ?? null;
70
+ }
71
+ export function leaveChat() {
72
+ return { view: "home", mode: "browse", notice: "Back to browse." };
73
+ }
74
+ export function openChat(role) {
75
+ return { view: "chat", mode: role };
76
+ }
77
+ export function chatScrollOffset(count, rows, offset) {
78
+ if (count <= rows)
79
+ return 0;
80
+ return Math.max(0, Math.min(offset, count - rows));
81
+ }
82
+ export function chatWindow(count, rows, offset) {
83
+ const start = chatScrollOffset(count, rows, offset);
84
+ return { start, end: Math.min(count, start + Math.max(0, rows)) };
85
+ }
86
+ export function pendingActivity(events, run, label, now = Date.now()) {
87
+ const live = !run || ["queued", "running", ""].includes(run.status ?? "running");
88
+ return {
89
+ status: live ? workingStatus(label, run?.started_at ?? null, now) : "",
90
+ lines: narrateEvents(events).map((line) => line.title),
91
+ };
92
+ }
93
+ export function followShouldStop(run, reply) {
94
+ if (reply)
95
+ return true;
96
+ if (run && !["queued", "running"].includes(run.status))
97
+ return true;
98
+ return false;
99
+ }
100
+ export function chatViewLines(thread, width, label, now = Date.now()) {
101
+ const lines = [];
102
+ const bodyWidth = Math.max(8, width);
103
+ for (const turn of thread.turns) {
104
+ const speaker = turn.speaker === "you" ? "you" : label;
105
+ lines.push({ key: `${turn.id}:who`, kind: "speaker", text: speaker });
106
+ const body = turn.body.trim() || (turn.pending ? "" : "");
107
+ if (body) {
108
+ wrapLines(body, bodyWidth).forEach((text, index) => {
109
+ lines.push({ key: `${turn.id}:body:${index}`, kind: "body", text });
110
+ });
111
+ }
112
+ if (turn.speaker !== "you") {
113
+ const effects = chatInstructionEffects(turn.body);
114
+ if (effects)
115
+ lines.push({ key: `${turn.id}:effects`, kind: "status", text: effects });
116
+ }
117
+ }
118
+ if (thread.pending) {
119
+ const live = pendingActivity([], { started_at: thread.pending.startedAt, status: thread.pending.status }, label, now);
120
+ const status = thread.pending.status && !["queued", "running", ""].includes(thread.pending.status)
121
+ ? ""
122
+ : live.status;
123
+ if (status)
124
+ lines.push({ key: "pending:status", kind: "status", text: status });
125
+ for (const [index, title] of thread.pending.activity.entries()) {
126
+ wrapLines(title, bodyWidth).forEach((text, line) => {
127
+ lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
128
+ });
129
+ }
130
+ }
131
+ return lines;
132
+ }
133
+ export function mergeHydratedThread(local, remote) {
134
+ if (!local)
135
+ return remote;
136
+ const known = new Set(remote.turns.map((turn) => turn.id));
137
+ const extras = local.turns.filter((turn) => !known.has(turn.id));
138
+ return {
139
+ ...remote,
140
+ turns: [...remote.turns, ...extras],
141
+ pending: local.pending,
142
+ };
143
+ }
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, mergeTicket as mergeTicketNow, 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);
@@ -157,6 +188,9 @@ export async function queueTicket(config, key) {
157
188
  export async function cancelTicket(config, key) {
158
189
  return cancelTicketNow(key, config);
159
190
  }
191
+ export async function mergeTicket(config, key, reason) {
192
+ return mergeTicketNow(key, reason, config);
193
+ }
160
194
  export async function setWorkspacePaused(config, paused) {
161
195
  return setPausedNow(paused, config);
162
196
  }
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() };
@@ -79,6 +80,9 @@ export function parseLine(raw) {
79
80
  case "cancel":
80
81
  return argument ? { kind: "cancel", key: argument.toUpperCase() }
81
82
  : { kind: "unknown", command: "cancel needs a key" };
83
+ case "merge":
84
+ return argument ? { kind: "merge", key: argument.toUpperCase() }
85
+ : { kind: "unknown", command: "merge needs a key" };
82
86
  case "decide": {
83
87
  const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
84
88
  const words = argument.replace(/(^|\s)--(skip|dismiss)(\s|$)/g, " ").trim().split(/\s+/).filter(Boolean);
@@ -130,7 +130,7 @@ export function ticketViewLines(ticket, width, collapsed = []) {
130
130
  pushWrapped(lines, "timeline", " ", event.headline, width);
131
131
  lines.push({
132
132
  key: `${event.id}:meta`, kind: "line", section: "timeline",
133
- text: ` ${event.outcome} · ${event.kind}/${event.provider} · ${elapsedLabel(event.elapsed_ms)}`,
133
+ text: ` ${event.outcome} · ${event.kind}/${event.provider ?? "owner"} · ${elapsedLabel(event.elapsed_ms)}`,
134
134
  });
135
135
  if (event.send_back_reason) {
136
136
  pushWrapped(lines, "timeline", " sent back because: ", event.send_back_reason, width);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.18.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"