@higherdev/cli 0.12.0 → 0.13.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
@@ -8,15 +8,10 @@ Operator controls for HDX workspaces, tickets, agents, and runner hosts.
8
8
  npm install --global @higherdev/cli
9
9
  ```
10
10
 
11
- Create `~/.config/hd/config.json` with an existing workspace API key:
11
+ Configure this client with an existing workspace API key:
12
12
 
13
- ```json
14
- {
15
- "url": "https://hdx-higher-ops.vercel.app",
16
- "api_key": "hdx_...",
17
- "current": "workspace",
18
- "repos": {}
19
- }
13
+ ```sh
14
+ hd login --url https://hdx-higher-ops.vercel.app --api-key hdx_... --slug workspace
20
15
  ```
21
16
 
22
17
  One workspace key reaches every workspace. Previous single-workspace and
@@ -54,8 +49,13 @@ workspace-map config shapes are migrated automatically when they are read.
54
49
  | `hd decide ID --answer TEXT` | Answer a decision |
55
50
  | `hd on` / `hd off` | Turn the workspace on or off |
56
51
  | `hd pause` / `hd resume` | Pause or resume the workspace |
57
- | `hd init [options]` | Configure this host and runner service |
58
- | `hd upgrade [options]` | Refresh this host configuration |
52
+ | `hd login --url URL --api-key KEY [--slug S]` | Configure only this operator client |
53
+ | `hd init --host HOST [--checkout DIR] [options]` | Configure this host and runner service |
54
+ | `hd upgrade --host HOST [--checkout DIR] [options]` | Refresh this host configuration |
55
+
56
+ `hd init` uses `~/hdx` as its checkout by default. Pass `--checkout DIR` when the runner source is
57
+ elsewhere. If the runner environment or service unit already exists, inspect the paths it prints and
58
+ pass `--force` only when replacing that host configuration is intentional.
59
59
 
60
60
  `hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
61
61
  branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` unless
package/dist/host.js CHANGED
@@ -1,16 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { homedir, hostname } from "node:os";
2
+ import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
5
  import { promisify } from "node:util";
7
- import { writeHdConfig } from "./config.js";
8
6
  const execFileAsync = promisify(execFile);
9
7
  export const LAUNCHD_LABEL = "io.higherops.hdx-runner";
10
8
  export const SYSTEMD_UNIT = "hdx-runner.service";
11
- export function repoRootFrom(fileUrl = import.meta.url) {
12
- return path.resolve(path.dirname(fileURLToPath(fileUrl)), "../../..");
13
- }
14
9
  export function parseEnvFile(text) {
15
10
  const out = {};
16
11
  for (const line of text.split(/\r?\n/)) {
@@ -88,6 +83,10 @@ export function parseHostFlags(argv) {
88
83
  };
89
84
  if (arg === "--host")
90
85
  flags.host = next();
86
+ else if (arg === "--checkout")
87
+ flags.checkout = next();
88
+ else if (arg === "--force")
89
+ flags.force = true;
91
90
  else if (arg === "--env-out")
92
91
  flags.envFile = next();
93
92
  else if (arg === "--worktree-root")
@@ -96,12 +95,6 @@ export function parseHostFlags(argv) {
96
95
  flags.installLaunchd = true;
97
96
  else if (arg === "--install-systemd")
98
97
  flags.installSystemd = true;
99
- else if (arg === "--url")
100
- flags.url = next();
101
- else if (arg === "--api-key")
102
- flags.apiKey = next();
103
- else if (arg === "--slug")
104
- flags.slug = next();
105
98
  else if (arg === "--plist-out")
106
99
  flags.plistOut = next();
107
100
  else if (arg === "--unit-out")
@@ -111,14 +104,47 @@ export function parseHostFlags(argv) {
111
104
  }
112
105
  return flags;
113
106
  }
107
+ async function exists(file) {
108
+ try {
109
+ await access(file);
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ function expandHome(value, home) {
117
+ if (value === "~")
118
+ return home;
119
+ if (value.startsWith("~/"))
120
+ return path.join(home, value.slice(2));
121
+ return value;
122
+ }
114
123
  export async function initHost(flags, env = process.env) {
115
124
  const home = homedir();
116
- const host = flags.host || env.RUNNER_HOST || hostname().replace(/\..*$/, "").toLowerCase();
117
125
  const envFile = flags.envFile || path.join(home, ".config/hdx/runner.env");
118
126
  const worktreeRoot = flags.worktreeRoot || env.WORKTREE_ROOT || path.join(home, "hdx-worktrees");
119
- const repo = repoRootFrom();
127
+ const repo = path.resolve(expandHome(flags.checkout ?? path.join(home, "hdx"), home));
120
128
  const runnerPath = path.join(repo, "apps/runner/src/index.ts");
121
129
  const nodePath = process.execPath;
130
+ const plistPath = flags.plistOut || path.join(home, "Library/LaunchAgents", `${LAUNCHD_LABEL}.plist`);
131
+ const unitPath = flags.unitOut || path.join(home, ".config/systemd/user", SYSTEMD_UNIT);
132
+ const targets = [envFile];
133
+ if (process.platform === "darwin" || flags.plistOut || flags.installLaunchd)
134
+ targets.push(plistPath);
135
+ if (process.platform === "linux" || flags.unitOut || flags.installSystemd)
136
+ targets.push(unitPath);
137
+ const existingTargets = (await Promise.all(targets.map(async (target) => ({ target, exists: await exists(target) })))).filter((entry) => entry.exists).map((entry) => entry.target);
138
+ if (existingTargets.length && !flags.force) {
139
+ throw new Error([
140
+ "hd init refused to overwrite existing runner files:",
141
+ ...existingTargets.map((target) => ` ${target}`),
142
+ "Re-run with --force to overwrite them.",
143
+ ].join("\n"));
144
+ }
145
+ if (!flags.host)
146
+ throw new Error("hd init requires --host HOST.");
147
+ const host = flags.host;
122
148
  let existing = {};
123
149
  try {
124
150
  existing = parseEnvFile(await readFile(envFile, "utf8"));
@@ -137,13 +163,9 @@ export async function initHost(flags, env = process.env) {
137
163
  await mkdir(path.dirname(envFile), { recursive: true });
138
164
  await writeFile(envFile, formatEnvFile(merged), { encoding: "utf8", mode: 0o600 });
139
165
  await chmod(envFile, 0o600);
140
- if (flags.url && flags.apiKey && flags.slug) {
141
- writeHdConfig({ url: flags.url.replace(/\/$/, ""), api_key: flags.apiKey, slug: flags.slug });
142
- }
143
166
  const lines = [`hd init host=${host}`, `env: ${envFile}`];
144
167
  const workDir = repo;
145
168
  if (process.platform === "darwin" || flags.plistOut || flags.installLaunchd) {
146
- const plistPath = flags.plistOut || path.join(home, "Library/LaunchAgents", `${LAUNCHD_LABEL}.plist`);
147
169
  const logPath = path.join(home, "Library/Logs/hdx-runner.log");
148
170
  await mkdir(path.dirname(plistPath), { recursive: true });
149
171
  await writeFile(plistPath, launchdPlist({ nodePath, runnerPath, envFile, logPath, workDir }), "utf8");
@@ -163,7 +185,6 @@ export async function initHost(flags, env = process.env) {
163
185
  lines.push(`launchd plist: ${plistPath}${loaded ? " (loaded)" : ""}`);
164
186
  }
165
187
  if (process.platform === "linux" || flags.unitOut || flags.installSystemd) {
166
- const unitPath = flags.unitOut || path.join(home, ".config/systemd/user", SYSTEMD_UNIT);
167
188
  await mkdir(path.dirname(unitPath), { recursive: true });
168
189
  await writeFile(unitPath, systemdUnit({ nodePath, runnerPath, envFile, workDir }), "utf8");
169
190
  let loaded = false;
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { approveEpic, answerDecision, cancelTicket, createAgent, createEpic, createTicket, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, updateCaps, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
+ import { login, parseLoginFlags } from "./login.js";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { epicProgressRows, readEpicSpec } from "./epics.js";
8
9
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
@@ -256,7 +257,7 @@ async function cmdWorkspace(argv, deps = {}) {
256
257
  fail(WORKSPACE_USAGE);
257
258
  const result = await workspaceRotateKey();
258
259
  console.log(`api key: ${result.apiKey}`);
259
- console.log("saved locally; update Mel's hd init configuration on the box.");
260
+ console.log("saved locally; run hd login with the new key on other machines.");
260
261
  return;
261
262
  }
262
263
  if (action === "set") {
@@ -271,7 +272,7 @@ async function cmdWorkspace(argv, deps = {}) {
271
272
  console.log(`invitation pending for ${result.runnerUser}`);
272
273
  console.log(`workspace: ${result.slug}`);
273
274
  console.log("saved and switched; configure another machine with:");
274
- console.log(result.initCommand);
275
+ console.log(result.loginCommand);
275
276
  }
276
277
  function selectAgent(agents, target, provider) {
277
278
  const exact = agents.find((agent) => agent.id === target);
@@ -414,6 +415,13 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
414
415
  console.log(cmd === "on" ? "on" : "resumed");
415
416
  return;
416
417
  }
418
+ if (cmd === "login") {
419
+ const config = login(parseLoginFlags(rest));
420
+ console.log(`logged in to ${config.slug}`);
421
+ const { launchApp } = await import("./tui/launch.js");
422
+ await launchApp(config.slug);
423
+ return;
424
+ }
417
425
  if (cmd === "init" || cmd === "upgrade") {
418
426
  const report = await initHost(parseHostFlags(rest));
419
427
  console.log(report);
package/dist/login.js ADDED
@@ -0,0 +1,41 @@
1
+ import { loadStoredConfig, writeHdConfig } from "./config.js";
2
+ export function parseLoginFlags(argv) {
3
+ const flags = {};
4
+ for (let i = 0; i < argv.length; i += 1) {
5
+ const arg = argv[i];
6
+ const next = () => {
7
+ const value = argv[i + 1];
8
+ if (!value || value.startsWith("--"))
9
+ throw new Error(`Missing value for ${arg}`);
10
+ i += 1;
11
+ return value;
12
+ };
13
+ if (arg === "--url")
14
+ flags.url = next();
15
+ else if (arg === "--api-key")
16
+ flags.apiKey = next();
17
+ else if (arg === "--slug")
18
+ flags.slug = next();
19
+ else
20
+ throw new Error(`Unknown option: ${arg}`);
21
+ }
22
+ return flags;
23
+ }
24
+ /** Configure the operator client without touching runner host files. */
25
+ export function login(flags, path) {
26
+ if (!flags.url || !flags.apiKey) {
27
+ throw new Error("usage: hd login --url URL --api-key KEY [--slug S]");
28
+ }
29
+ let current = "workspace";
30
+ try {
31
+ current = loadStoredConfig(path).current;
32
+ }
33
+ catch { }
34
+ const config = {
35
+ url: flags.url,
36
+ api_key: flags.apiKey,
37
+ slug: flags.slug ?? current,
38
+ };
39
+ writeHdConfig(config, path);
40
+ return config;
41
+ }
package/dist/out.js CHANGED
@@ -74,6 +74,8 @@ export function usage() {
74
74
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
75
75
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
76
76
  ` ${c.blue("hd on | hd off")} workspace switch`,
77
- ` ${c.blue("hd init | hd upgrade")} host setup`,
77
+ ` ${c.blue("hd login --url URL --api-key KEY")} client setup`,
78
+ ` ${c.blue("hd init --host HOST [options]")} host setup`,
79
+ ` ${c.blue("hd upgrade --host HOST [options]")} refresh host setup`,
78
80
  ].join("\n");
79
81
  }
package/dist/tui/App.js CHANGED
@@ -10,7 +10,7 @@ import { Bubble } from "./Bubble.js";
10
10
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
11
11
  import { DecisionPanel, decisionRows } from "./Decision.js";
12
12
  import { COMMANDS, Help } from "./Help.js";
13
- import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel } from "./Panels.js";
13
+ import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
14
14
  import { SettingsPanel } from "./Settings.js";
15
15
  import { Splash } from "./Splash.js";
16
16
  import TextInput from "./TextInput.js";
@@ -50,6 +50,7 @@ export function App({ initial }) {
50
50
  const [started, setStarted] = useState(false);
51
51
  const [field, setField] = useState(null);
52
52
  const [editing, setEditing] = useState(null);
53
+ const [inboxFocus, setInboxFocus] = useState(0);
53
54
  const selectedRef = useRef(null);
54
55
  const fieldRef = useRef(null);
55
56
  const editingRef = useRef(null);
@@ -117,6 +118,7 @@ export function App({ initial }) {
117
118
  const settingsOrder = useMemo(() => editableKeys(settings), [settings]);
118
119
  const browsing = mode === "browse" && draft.length === 0 && cursor !== null;
119
120
  const configuring = view === "settings" && !editing;
121
+ const inbox = useMemo(() => inboxEntries(board, width), [board, width]);
120
122
  const moveCursor = useCallback((delta) => {
121
123
  if (!order.length)
122
124
  return false;
@@ -135,6 +137,12 @@ export function App({ initial }) {
135
137
  setField(next);
136
138
  return true;
137
139
  }, [settingsOrder]);
140
+ const moveInbox = useCallback((delta) => {
141
+ setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current + delta)));
142
+ }, [inbox.length]);
143
+ useEffect(() => {
144
+ setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current)));
145
+ }, [inbox.length]);
138
146
  const refresh = useCallback(async () => {
139
147
  await refreshRef.current?.();
140
148
  }, []);
@@ -274,6 +282,8 @@ export function App({ initial }) {
274
282
  return;
275
283
  case "view":
276
284
  setView(action.view);
285
+ if (action.view === "inbox")
286
+ setInboxFocus(0);
277
287
  if (action.view === "board" && order.length) {
278
288
  const next = selectedRef.current && order.includes(selectedRef.current) ? selectedRef.current : order[0];
279
289
  selectedRef.current = next;
@@ -312,7 +322,7 @@ export function App({ initial }) {
312
322
  if (!created)
313
323
  throw new Error("Workspace wizard did not finish.");
314
324
  await changeWorkspace(created.slug, loadConfig());
315
- say("system", `Created ${created.slug}. Configure the box with: ${created.initCommand}`);
325
+ say("system", `Created ${created.slug}. Configure another machine with: ${created.loginCommand}`);
316
326
  }
317
327
  else if (action.command === "set") {
318
328
  const updated = await workspaceSet(action.args);
@@ -322,7 +332,7 @@ export function App({ initial }) {
322
332
  else {
323
333
  const rotated = await workspaceRotateKey();
324
334
  setConfig(loadConfig());
325
- say("system", `API key: ${rotated.apiKey}\nSaved locally. Update Mel's hd init configuration on the box.`);
335
+ say("system", `API key: ${rotated.apiKey}\nSaved locally. Run hd login with the new key on other machines.`);
326
336
  }
327
337
  }
328
338
  catch (error) {
@@ -545,9 +555,9 @@ export function App({ initial }) {
545
555
  if (item.message.panel === "help")
546
556
  return _jsx(Help, { width: width }, item.key);
547
557
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
548
- } }), 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 }) : null, view === "ticket" && plan.panels > 0 ? ticket
558
+ } }), 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 }) : null, view === "ticket" && plan.panels > 0 ? ticket
549
559
  ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels })
550
- : _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 }), 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, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
560
+ : _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 }), 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, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, view === "inbox" ? " ↑↓ scroll" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
551
561
  setDraft(next);
552
562
  if (editingRef.current)
553
563
  setEditing({ key: editingRef.current.key, draft: next });
@@ -566,6 +576,10 @@ export function App({ initial }) {
566
576
  setCursor(null);
567
577
  selectedRef.current = null;
568
578
  }, onUp: () => {
579
+ if (view === "inbox" && !draft) {
580
+ moveInbox(-1);
581
+ return;
582
+ }
569
583
  if (configuring && moveField(-1))
570
584
  return;
571
585
  if (browsing && moveCursor(-1))
@@ -575,6 +589,10 @@ export function App({ initial }) {
575
589
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
576
590
  setDraft(history.current[historyAt.current] ?? "");
577
591
  }, onDown: () => {
592
+ if (view === "inbox" && !draft) {
593
+ moveInbox(1);
594
+ return;
595
+ }
578
596
  if (configuring && moveField(1))
579
597
  return;
580
598
  if (browsing && moveCursor(1))
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { BOARD_COLUMNS, availabilityLabel, epicProgressCaption, epicProgress, statusLabel, statusTone, } from "./data.js";
3
+ import { BOARD_COLUMNS, epicProgressCaption, epicProgress, statusLabel, statusTone, } from "./data.js";
4
4
  import { elapsed, truncate } from "../out/format.js";
5
5
  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
+ import { agentDisplayRows } from "./agent-rows.js";
8
9
  const DOT = "●";
9
10
  /**
10
11
  * The width at which the board and the agents stop competing for the same
@@ -106,33 +107,22 @@ export function BoardColumn({ board, width, rows, cursor, }) {
106
107
  ] }));
107
108
  }
108
109
  export function AgentsColumn({ board, width, rows, }) {
109
- // Whoever is working comes first. An idle registry is what you scroll past.
110
- const ordered = [...board.agents].sort((left, right) => {
111
- const busy = (id) => board.runs.some((run) => run.agent_id === id && run.status === "running") ? 0 : 1;
112
- return busy(left.id) - busy(right.id) || left.display_name.localeCompare(right.display_name);
113
- });
114
- const shown = ordered.slice(0, contentRows(rows, ordered.length));
110
+ const displayRows = agentDisplayRows(board);
111
+ const shown = displayRows.slice(0, contentRows(rows, displayRows.length));
115
112
  return (_jsx(Panel, { width: width, rows: rows, children: [
116
- _jsx(Heading, { text: "Agents", note: `${board.agents.filter((a) => a.enabled).length} on` }, "h"),
117
- ...(board.agents.length === 0
113
+ _jsx(Heading, { text: "Agents", note: `${displayRows.length}` }, "h"),
114
+ ...(displayRows.length === 0
118
115
  ? [
119
116
  _jsx(Text, { color: UI.dim, children: "No agents yet." }, "empty"),
120
117
  ]
121
118
  : []),
122
- ...shown.map((agent) => {
123
- const run = board.runs.find((row) => row.agent_id === agent.id && row.status === "running");
124
- const ticket = run?.ticket_id
125
- ? board.tickets.find((item) => item.id === run.ticket_id)
126
- : undefined;
127
- const availability = board.availability.find((row) => row.provider === agent.provider);
128
- const blocked = availability && !availability.available ? availabilityLabel(availability) : "";
129
- const tone = !agent.enabled ? "muted" : run ? "blue" : blocked ? "warning" : "muted";
130
- const name = Math.max(8, Math.min(22, width - 14));
131
- return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(agent.display_name, name - 1) }) }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: run
132
- ? `${ticket ? `${ticket.key} ` : ""}${elapsed(run.started_at ?? run.created_at)}`
133
- : blocked || (agent.enabled ? "idle" : "offline") })] }, agent.id));
119
+ ...shown.map((row) => {
120
+ const tone = row.run ? "blue" : row.state === "offline" ? "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
122
+ ? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
123
+ : ` · ${row.state}` })] })] }, row.key));
134
124
  }),
135
- _jsx(More, { count: ordered.length - shown.length }, "more"),
125
+ _jsx(More, { count: displayRows.length - shown.length }, "more"),
136
126
  ] }));
137
127
  }
138
128
  const KIND_COLOR = {
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { BOARD_COLUMNS, availabilityLabel, statusLabel, statusTone, } from "./data.js";
3
+ import { BOARD_COLUMNS, statusLabel, statusTone, } from "./data.js";
4
4
  import { elapsed, pad, relativeTime, truncate } from "../out/format.js";
5
5
  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
+ import { scrollWindow } from "./Dashboard.js";
9
+ import { agentDisplayRows } from "./agent-rows.js";
8
10
  const DOT = "●";
9
11
  /**
10
12
  * The single-purpose views behind /board, /agents, /feed and /inbox. Each is
@@ -13,25 +15,20 @@ const DOT = "●";
13
15
  * `bounded.tsx` for why the bound is structural rather than arithmetic.
14
16
  */
15
17
  export function AgentsPanel({ board, width = 80, rows = 12, }) {
16
- const shown = board.agents.slice(0, contentRows(rows, board.agents.length));
18
+ const displayRows = agentDisplayRows(board);
19
+ const shown = displayRows.slice(0, contentRows(rows, displayRows.length));
17
20
  return (_jsx(Panel, { width: width, rows: rows, children: [
18
- _jsx(Heading, { text: "Agents", note: `${board.agents.length}` }, "h"),
19
- ...(board.agents.length === 0
21
+ _jsx(Heading, { text: "Agents", note: `${displayRows.length}` }, "h"),
22
+ ...(displayRows.length === 0
20
23
  ? [
21
24
  _jsx(Text, { color: UI.dim, children: "No agents yet." }, "empty"),
22
25
  ]
23
26
  : []),
24
- ...shown.map((agent) => {
25
- const run = board.runs.find((row) => row.agent_id === agent.id && row.status === "running");
26
- const ticket = run?.ticket_id
27
- ? board.tickets.find((item) => item.id === run.ticket_id)
28
- : undefined;
29
- const availability = board.availability.find((row) => row.provider === agent.provider);
30
- const blocked = availability && !availability.available ? availabilityLabel(availability) : "";
31
- const tone = !agent.enabled ? "muted" : run ? "blue" : blocked ? "warning" : "muted";
32
- return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(agent.display_name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(agent.role, 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(agent.model, 23), 24) }), _jsx(Text, { color: UI.text, children: pad(run ? "running" : blocked ? "blocked" : "idle", 9) }), _jsxs(Text, { color: UI.dim, children: [ticket ? `${ticket.key} ` : "", run ? elapsed(run.started_at ?? run.created_at) : blocked] })] }, agent.id));
27
+ ...shown.map((row) => {
28
+ const tone = row.run ? "blue" : row.state === "offline" ? "warning" : "muted";
29
+ 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(row.state, 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));
33
30
  }),
34
- _jsx(More, { count: board.agents.length - shown.length }, "more"),
31
+ _jsx(More, { count: displayRows.length - shown.length }, "more"),
35
32
  ] }));
36
33
  }
37
34
  export function BoardPanel({ board, width = 80, rows = 12, cursor, }) {
@@ -86,22 +83,70 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
86
83
  * The whole queue, one row each, numbered the way `/decide` reaches them: the
87
84
  * one on screen is 1.
88
85
  */
89
- export function InboxPanel({ board, width = 80, rows = 12, }) {
90
- const shown = board.decisions.slice(0, contentRows(rows, board.decisions.length));
86
+ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
87
+ const entries = inboxEntries(board, width);
88
+ const inner = Math.max(0, rows - 1);
89
+ const window = scrollWindow(entries.length, inner, focus);
90
+ const hiddenAbove = window.start;
91
+ const hiddenBelow = entries.length - window.end;
92
+ const note = `${board.decisions.length}${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
91
93
  return (_jsx(Panel, { width: width, rows: rows, children: [
92
- _jsx(Heading, { text: "Decisions", note: `${board.decisions.length}` }, "h"),
94
+ _jsx(Heading, { text: "Decisions", note: note }, "h"),
93
95
  ...(board.decisions.length === 0
94
96
  ? [
95
97
  _jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
96
98
  ]
97
99
  : []),
98
- ...shown.map((decision, index) => {
99
- const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
100
- return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: index === 0 ? UI.warn : UI.dim, children: pad(`${index + 1})`, 3) }), _jsx(Text, { color: UI.dim, children: pad(ticket?.key ?? decision.id.slice(0, 8), 9) }), _jsx(Text, { color: UI.text, children: truncate(decision.question_md, Math.max(12, width - 14)) })] }, decision.id));
101
- }),
102
- _jsx(More, { count: board.decisions.length - shown.length }, "more"),
100
+ ...entries.slice(window.start, window.end).map((entry, index) => (_jsx(Text, { color: entry.kind === "header" ? UI.warn : UI.text, wrap: "truncate", inverse: window.start + index === focus, children: entry.kind === "body" ? ` ${entry.text}` : entry.text }, entry.key))),
103
101
  ] }));
104
102
  }
103
+ /** Every physical line in /inbox, with decision bodies wrapped but never clipped. */
104
+ export function inboxEntries(board, width) {
105
+ const entries = [];
106
+ const bodyWidth = Math.max(12, width - 2);
107
+ board.decisions.forEach((decision, index) => {
108
+ const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
109
+ entries.push({
110
+ key: `${decision.id}:header`,
111
+ kind: "header",
112
+ text: `${index + 1}) ${ticket?.key ?? decision.id.slice(0, 8)}`,
113
+ });
114
+ wrapLines(decision.question_md.trim(), bodyWidth).forEach((text, line) => {
115
+ entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
116
+ });
117
+ });
118
+ return entries;
119
+ }
120
+ function wrapLines(text, width) {
121
+ if (!text)
122
+ return [""];
123
+ const lines = [];
124
+ for (const paragraph of text.split("\n")) {
125
+ if (!paragraph) {
126
+ lines.push("");
127
+ continue;
128
+ }
129
+ let line = "";
130
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
131
+ const candidate = line ? `${line} ${word}` : word;
132
+ if (candidate.length <= width) {
133
+ line = candidate;
134
+ continue;
135
+ }
136
+ if (line)
137
+ lines.push(line);
138
+ let rest = word;
139
+ while (rest.length > width) {
140
+ lines.push(rest.slice(0, width));
141
+ rest = rest.slice(width);
142
+ }
143
+ line = rest;
144
+ }
145
+ if (line)
146
+ lines.push(line);
147
+ }
148
+ return lines;
149
+ }
105
150
  /**
106
151
  * One ticket in full, inside the rows it was given.
107
152
  *
@@ -0,0 +1,57 @@
1
+ const roleForKind = {
2
+ architect: "architect",
3
+ build: "builder",
4
+ followup: "builder",
5
+ orchestrate: "orchestrator",
6
+ review: "reviewer",
7
+ };
8
+ function agentForRun(board, run) {
9
+ if (run.agent_id)
10
+ return board.agents.find((agent) => agent.id === run.agent_id) ?? null;
11
+ const role = roleForKind[run.kind];
12
+ return board.agents.find((agent) => agent.enabled && agent.role === role && agent.provider === run.provider) ?? null;
13
+ }
14
+ /** Expand live invocations into rows, then append enabled agents with no live run. */
15
+ export function agentDisplayRows(board) {
16
+ const activeAgents = new Set();
17
+ const live = board.runs
18
+ .filter((run) => run.status === "running" || run.status === "queued")
19
+ .slice()
20
+ .sort((left, right) => {
21
+ if (left.status !== right.status)
22
+ return left.status === "running" ? -1 : 1;
23
+ const leftAt = left.started_at ?? left.created_at;
24
+ const rightAt = right.started_at ?? right.created_at;
25
+ return leftAt.localeCompare(rightAt) || left.id.localeCompare(right.id);
26
+ })
27
+ .map((run) => {
28
+ const agent = agentForRun(board, run);
29
+ if (agent)
30
+ activeAgents.add(agent.id);
31
+ const ticket = run.ticket_id
32
+ ? board.tickets.find((row) => row.id === run.ticket_id) ?? null
33
+ : null;
34
+ return {
35
+ key: run.id,
36
+ name: agent?.display_name ?? run.kind,
37
+ agent,
38
+ run,
39
+ ticket,
40
+ state: run.status,
41
+ };
42
+ });
43
+ const idle = board.agents
44
+ .filter((agent) => agent.enabled && !activeAgents.has(agent.id))
45
+ .sort((left, right) => left.display_name.localeCompare(right.display_name))
46
+ .map((agent) => ({
47
+ key: `idle:${agent.id}`,
48
+ name: agent.display_name,
49
+ agent,
50
+ run: null,
51
+ ticket: null,
52
+ state: board.availability.some((row) => row.provider === agent.provider && !row.available)
53
+ ? "offline"
54
+ : "idle",
55
+ }));
56
+ return [...live, ...idle];
57
+ }
@@ -115,7 +115,7 @@ export async function workspaceNew(argv, deps = {}) {
115
115
  return { slug: result.workspace.slug, repo: result.workspace.repo,
116
116
  invitationPending: preflight.invitationPending,
117
117
  runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
118
- initCommand: `hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}` };
118
+ loginCommand: `hd login --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}` };
119
119
  }
120
120
  }
121
121
  export async function workspaceSet(argv) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"