@higherdev/cli 0.17.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/README.md CHANGED
@@ -49,6 +49,7 @@ workspace-map config shapes are migrated automatically when they are read.
49
49
  | `hd env ls` | List workspace environment variable names |
50
50
  | `hd env set NAME=VALUE [NAME=VALUE...]` | Set workspace environment variables |
51
51
  | `hd env rm NAME` | Remove a workspace environment variable |
52
+ | `hd host roll [--host HOST] [--address ADDR] [--checkout DIR]` | Pull, install, and restart a runner over SSH, then watch drain |
52
53
  | `hd host env ls` | List environment variable names on the workspace's default host |
53
54
  | `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
54
55
  | `hd host env rm NAME` | Remove a host-only environment variable |
@@ -64,9 +65,15 @@ workspace-map config shapes are migrated automatically when they are read.
64
65
  | `hd upgrade --host HOST [--checkout DIR] [options]` | Refresh this host configuration |
65
66
 
66
67
  `hd init` uses `~/hdx` as its checkout by default. Pass `--checkout DIR` when the runner source is
67
- elsewhere. If the runner environment or service unit already exists, inspect the paths it prints and
68
+ elsewhere. Pass `--address HOST` to store `RUNNER_SSH_ADDRESS` so `hd host roll` can SSH to that
69
+ host. If the runner environment or service unit already exists, inspect the paths it prints and
68
70
  pass `--force` only when replacing that host configuration is intentional.
69
71
 
72
+ `hd host roll` SSHes to the host address (`--address`, or `RUNNER_SSH_ADDRESS` from the host
73
+ heartbeat), pulls the checkout, installs, and restarts the systemd unit. The runner drains live
74
+ runs for up to 15 minutes (`RUNNER_DRAIN_TIMEOUT_MS`) before force-closing leftovers. The systemd
75
+ unit `TimeoutStopSec` is one minute above that drain timeout.
76
+
70
77
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
71
78
  branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` with push
72
79
  access for user-owned repositories or admin for organization-owned repositories unless `--runner-user USER`
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
@@ -53,6 +53,35 @@ export function launchdPlist(opts) {
53
53
  </plist>
54
54
  `;
55
55
  }
56
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 15 * 60_000;
57
+ export const HOST_ROLL_USAGE = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR]";
58
+ export function systemdStopTimeoutSec(drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS) {
59
+ return Math.ceil(drainTimeoutMs / 1_000) + 60;
60
+ }
61
+ export function formatDrainStatus(live, until, now = Date.now()) {
62
+ const remaining = Date.parse(until) - now;
63
+ const minutes = Number.isFinite(remaining) ? Math.max(0, Math.ceil(remaining / 60_000)) : 0;
64
+ const runs = live === 1 ? "1 run" : `${live} runs`;
65
+ return `draining: ${runs}, up to ${minutes}m`;
66
+ }
67
+ export function hostDrainFromStats(stats) {
68
+ if (!stats || typeof stats !== "object" || Array.isArray(stats))
69
+ return null;
70
+ const draining = stats.draining;
71
+ if (!draining || typeof draining !== "object" || Array.isArray(draining))
72
+ return null;
73
+ const live = Number(draining.live);
74
+ const until = draining.until;
75
+ if (!Number.isFinite(live) || live < 0 || typeof until !== "string")
76
+ return null;
77
+ return { live, until };
78
+ }
79
+ export function sshAddressFromStats(stats) {
80
+ if (!stats || typeof stats !== "object" || Array.isArray(stats))
81
+ return null;
82
+ const value = stats.ssh_address;
83
+ return typeof value === "string" && value.trim() ? value.trim() : null;
84
+ }
56
85
  export function systemdUnit(opts) {
57
86
  return `[Unit]
58
87
  Description=HDX runner
@@ -65,6 +94,8 @@ WorkingDirectory=${opts.workDir}
65
94
  ExecStart=${opts.nodePath} --experimental-strip-types ${opts.runnerPath}
66
95
  Restart=always
67
96
  RestartSec=5
97
+ TimeoutStopSec=${systemdStopTimeoutSec(opts.drainTimeoutMs)}
98
+ KillMode=mixed
68
99
 
69
100
  [Install]
70
101
  WantedBy=default.target
@@ -85,6 +116,8 @@ export function parseHostFlags(argv) {
85
116
  flags.host = next();
86
117
  else if (arg === "--checkout")
87
118
  flags.checkout = next();
119
+ else if (arg === "--address")
120
+ flags.address = next();
88
121
  else if (arg === "--force")
89
122
  flags.force = true;
90
123
  else if (arg === "--env-out")
@@ -104,6 +137,45 @@ export function parseHostFlags(argv) {
104
137
  }
105
138
  return flags;
106
139
  }
140
+ export function parseHostRollFlags(argv) {
141
+ const flags = {};
142
+ for (let i = 0; i < argv.length; i += 1) {
143
+ const arg = argv[i];
144
+ const next = () => {
145
+ const value = argv[i + 1];
146
+ if (!value || value.startsWith("--"))
147
+ throw new Error(`Missing value for ${arg}`);
148
+ i += 1;
149
+ return value;
150
+ };
151
+ if (arg === "--host")
152
+ flags.host = next();
153
+ else if (arg === "--address")
154
+ flags.address = next();
155
+ else if (arg === "--checkout")
156
+ flags.checkout = next();
157
+ else if (!arg.startsWith("-") && !flags.host)
158
+ flags.host = arg;
159
+ else
160
+ throw new Error(arg.startsWith("-") ? `Unknown option: ${arg}` : HOST_ROLL_USAGE);
161
+ }
162
+ return flags;
163
+ }
164
+ function shellQuote(value) {
165
+ return `'${value.replace(/'/g, `'\\''`)}'`;
166
+ }
167
+ export function hostRollCommand(checkout) {
168
+ return [
169
+ "set -e",
170
+ `cd ${shellQuote(checkout)}`,
171
+ "git pull --ff-only",
172
+ "pnpm install",
173
+ "systemctl --user restart hdx-runner.service",
174
+ ].join(" && ");
175
+ }
176
+ async function defaultSsh(address, command) {
177
+ return execFileAsync("ssh", ["-o", "BatchMode=yes", address, "bash", "-lc", command], { encoding: "utf8" });
178
+ }
107
179
  async function exists(file) {
108
180
  try {
109
181
  await access(file);
@@ -159,6 +231,9 @@ export async function initHost(flags, env = process.env) {
159
231
  RUNNER_HOST: host,
160
232
  WORKTREE_ROOT: worktreeRoot,
161
233
  GH_TOKEN: env.GH_TOKEN || existing.GH_TOKEN || "",
234
+ ...(flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS
235
+ ? { RUNNER_SSH_ADDRESS: flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS }
236
+ : {}),
162
237
  };
163
238
  await mkdir(path.dirname(envFile), { recursive: true });
164
239
  await writeFile(envFile, formatEnvFile(merged), { encoding: "utf8", mode: 0o600 });
@@ -198,3 +273,48 @@ export async function initHost(flags, env = process.env) {
198
273
  lines.push("Start the runner service to register this host.");
199
274
  return lines.join("\n");
200
275
  }
276
+ const ROLL_WAIT_MS = DEFAULT_DRAIN_TIMEOUT_MS + 2 * 60_000;
277
+ export async function hostRoll(flags, deps = {}) {
278
+ const log = deps.log ?? ((line) => console.log(line));
279
+ const now = deps.now ?? Date.now;
280
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
281
+ const ssh = deps.ssh ?? defaultSsh;
282
+ const loadStatus = deps.getStatus ?? (await import("./api.js")).getStatus;
283
+ const snapshot = await loadStatus();
284
+ const host = flags.host ?? snapshot.workspace.default_host;
285
+ const address = flags.address ?? snapshot.host?.ssh_address ?? null;
286
+ if (!address) {
287
+ throw new Error(`hd host roll needs an SSH address for ${host}. Pass --address or set RUNNER_SSH_ADDRESS on the host.`);
288
+ }
289
+ const checkout = expandHome(flags.checkout ?? "~/hdx", homedir());
290
+ const command = hostRollCommand(checkout);
291
+ const lines = [`rolling ${host} at ${address}`];
292
+ log(lines[0]);
293
+ await ssh(address, command);
294
+ const restarted = `restarted ${host}; waiting for drain`;
295
+ lines.push(restarted);
296
+ log(restarted);
297
+ const before = snapshot.host?.last_seen_at ?? null;
298
+ const deadline = now() + (deps.waitMs ?? ROLL_WAIT_MS);
299
+ let lastDrain = "";
300
+ while (now() < deadline) {
301
+ const status = await loadStatus();
302
+ const drain = status.host?.draining ?? null;
303
+ if (drain) {
304
+ const line = formatDrainStatus(drain.live, drain.until, now());
305
+ if (line !== lastDrain) {
306
+ lastDrain = line;
307
+ lines.push(line);
308
+ log(line);
309
+ }
310
+ }
311
+ else if (status.host?.last_seen_at && status.host.last_seen_at !== before && status.host.runner_version) {
312
+ const line = `${host} runner ${status.host.runner_version}`;
313
+ lines.push(line);
314
+ log(line);
315
+ return lines;
316
+ }
317
+ await sleep(2_000);
318
+ }
319
+ throw new Error(`timed out waiting for ${host} to finish draining`);
320
+ }
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";
5
- import { initHost, parseHostFlags } from "./host.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
+ 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";
@@ -63,6 +64,9 @@ async function cmdStatus() {
63
64
  const data = await getStatus();
64
65
  const state = data.workspace.paused ? c.yellow("off") : c.green("on");
65
66
  console.log(`${c.bold(data.workspace.name)} ${c.dim(data.workspace.repo)} ${state}\n`);
67
+ if (data.host?.draining) {
68
+ console.log(`${c.yellow(formatDrainStatus(data.host.draining.live, data.host.draining.until))}\n`);
69
+ }
66
70
  const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
67
71
  ?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
68
72
  .map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
@@ -175,7 +179,44 @@ async function cmdEpic(argv) {
175
179
  }
176
180
  return;
177
181
  }
178
- 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);
179
220
  }
180
221
  async function cmdPlan(argv) {
181
222
  if (argv.length)
@@ -423,12 +464,23 @@ function githubSecretMissing(error) {
423
464
  return /404|not found|does not exist/i.test(message);
424
465
  }
425
466
  export async function cmdEnv(argv, deps = {}) {
426
- const [action, ...args] = argv;
427
- if (action === "ls") {
428
- const { env } = await listWorkspaceEnv();
467
+ const parsed = flags(argv);
468
+ const [rawAction, ...args] = parsed.rest;
469
+ const action = !rawAction && parsed.bools.has("vercel") ? "ls" : rawAction;
470
+ if (action === "ls" && !args.length && !Object.keys(parsed.opts).length
471
+ && [...parsed.bools].every((name) => name === "vercel")) {
472
+ const showVercel = parsed.bools.has("vercel");
473
+ const [{ env }, detail] = await Promise.all([listWorkspaceEnv(), showVercel ? getWorkspace() : null]);
429
474
  if (!env.length)
430
475
  return console.log(c.dim("No workspace environment variables."));
431
- console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
476
+ if (!showVercel)
477
+ console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
478
+ else {
479
+ const production = new Set(detail?.workspace.settings?.vercel?.production_env ?? []);
480
+ console.log(table(["NAME", "UPDATED", "VERCEL"], env.map((row) => [
481
+ row.name, row.updated_at, production.has(row.name) ? "yes" : "no",
482
+ ])));
483
+ }
432
484
  return;
433
485
  }
434
486
  if (action === "set" && args.length) {
@@ -471,11 +523,15 @@ export async function cmdEnv(argv, deps = {}) {
471
523
  console.log(`synced removal of ${name} to ${repo}`);
472
524
  return;
473
525
  }
474
- fail("usage: hd env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME");
526
+ fail("usage: hd env ls [--vercel] | set NAME=VALUE [NAME=VALUE...] | rm NAME");
475
527
  }
476
- async function cmdHost(argv) {
528
+ async function cmdHost(argv, deps = {}) {
477
529
  const [scope, action, ...args] = argv;
478
- const usage = "usage: hd host env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
530
+ if (scope === "roll") {
531
+ await hostRoll(parseHostRollFlags([action, ...args].filter((value) => Boolean(value))), deps);
532
+ return;
533
+ }
534
+ const usage = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
479
535
  if (scope !== "env")
480
536
  fail(usage);
481
537
  const host = (await getStatus()).workspace.default_host;
@@ -536,6 +592,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
536
592
  await cmdEpic(rest);
537
593
  return;
538
594
  }
595
+ if (cmd === "roadmap") {
596
+ await cmdRoadmap(rest);
597
+ return;
598
+ }
539
599
  if (cmd === "plan") {
540
600
  await cmdPlan(rest);
541
601
  return;
@@ -573,7 +633,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
573
633
  return;
574
634
  }
575
635
  if (cmd === "host") {
576
- await cmdHost(rest);
636
+ await cmdHost(rest, deps);
577
637
  return;
578
638
  }
579
639
  if (cmd === "pause" || cmd === "off") {
package/dist/out.js CHANGED
@@ -66,12 +66,13 @@ 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`,
72
73
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
73
- ` ${c.blue("hd env ls | set | rm")} workspace environment`,
74
- ` ${c.blue("hd host env ls | set | rm")} host environment`,
74
+ ` ${c.blue("hd env ls [--vercel] | set | rm")} workspace environment`,
75
+ ` ${c.blue("hd host roll | env ls | set | rm")} roll a host or edit host env`,
75
76
  ` ${c.blue("hd logs KEY [-f]")} run events`,
76
77
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
77
78
  ` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
@@ -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
@@ -117,10 +118,11 @@ export function AgentsColumn({ board, width, rows, }) {
117
118
  ]
118
119
  : []),
119
120
  ...shown.map((row) => {
120
- const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" ? "warning" : "muted";
121
- return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, _jsx(Text, { color: UI.dim, children: row.run
121
+ const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
122
+ ? "warning" : "muted";
123
+ return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, row.state === "draining" ? null : (_jsx(Text, { color: UI.dim, children: row.run
122
124
  ? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
123
- : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` })] })] }, row.key));
125
+ : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` }))] })] }, row.key));
124
126
  }),
125
127
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
126
128
  ] }));
@@ -156,8 +158,12 @@ export function epicsRows(board, cap = 3) {
156
158
  return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
157
159
  }
158
160
  export function nowEntries(board) {
159
- return board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
160
- .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
+ ];
161
167
  }
162
168
  function NowStrip({ board, width, rows }) {
163
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" },
@@ -28,8 +28,10 @@ export function AgentsPanel({ board, width = 80, rows = 12, }) {
28
28
  ]
29
29
  : []),
30
30
  ...shown.map((row) => {
31
- const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" ? "warning" : "muted";
32
- const state = row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
31
+ const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
32
+ ? "warning" : "muted";
33
+ const state = row.state === "draining" ? ""
34
+ : row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
33
35
  return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(row.name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(row.agent?.role ?? row.run?.kind ?? "", 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(row.agent?.model ?? "", 23), 24) }), _jsx(Text, { color: UI.text, children: pad(state, row.limitedUntil ? 22 : 9) }), _jsxs(Text, { color: UI.dim, children: [row.ticket ? `${row.ticket.key} ` : "", row.run ? elapsed(row.run.started_at ?? row.run.created_at) : ""] })] }, row.key));
34
36
  }),
35
37
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { formatDrainStatus } from "../host.js";
1
2
  const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
2
3
  export function limitedUntilByProvider(tickets) {
3
4
  const limited = new Map();
@@ -22,7 +23,18 @@ function agentForRun(board, run) {
22
23
  return board.agents.find((agent) => agent.enabled && agent.role === role && agent.provider === run.provider) ?? null;
23
24
  }
24
25
  /** Expand live invocations into rows, then append enabled agents with no live run. */
25
- export function agentDisplayRows(board) {
26
+ export function agentDisplayRows(board, now = Date.now()) {
27
+ const drain = board.hostDrain
28
+ ? [{
29
+ key: "drain",
30
+ name: formatDrainStatus(board.hostDrain.live, board.hostDrain.until, now),
31
+ agent: null,
32
+ run: null,
33
+ ticket: null,
34
+ state: "draining",
35
+ limitedUntil: null,
36
+ }]
37
+ : [];
26
38
  const activeAgents = new Set();
27
39
  const live = board.runs
28
40
  .filter((run) => run.status === "running" || run.status === "queued")
@@ -71,5 +83,5 @@ export function agentDisplayRows(board) {
71
83
  limitedUntil: until ?? null,
72
84
  };
73
85
  });
74
- return [...live, ...idle];
86
+ return [...drain, ...live, ...idle];
75
87
  }
@@ -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,13 +73,15 @@ 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),
80
81
  earlierHasMore: earlier.length > earlierLimit,
81
82
  availability,
82
83
  runs: status.recent_runs ?? status.live_runs,
84
+ hostDrain: status.host?.draining ?? null,
83
85
  },
84
86
  feed: feedData.entries,
85
87
  };
@@ -122,7 +124,37 @@ export async function switchWorkspace(slug, config = loadConfig()) {
122
124
  return snapshot;
123
125
  }
124
126
  export async function postAgentMessage(role, body, config) {
125
- 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 };
126
158
  }
127
159
  export async function loadTicketDetail(config, key) {
128
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.17.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"