@higherdev/cli 0.12.1 → 0.14.2

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
@@ -43,6 +43,9 @@ workspace-map config shapes are migrated automatically when they are read.
43
43
  | `hd agents rm ROLE\|ID` | Remove an unambiguous agent |
44
44
  | `hd agents set ROLE\|ID [options]` | Rename, configure, enable, or disable an agent |
45
45
  | `hd caps [set PROVIDER N]` | Show or update provider concurrency caps |
46
+ | `hd env ls` | List workspace environment variable names |
47
+ | `hd env set NAME=VALUE [NAME=VALUE...]` | Set workspace environment variables |
48
+ | `hd env rm NAME` | Remove a workspace environment variable |
46
49
  | `hd logs KEY [-f]` | Show or follow run events |
47
50
  | `hd msg KEY "message" [--interrupt]` | Message a builder |
48
51
  | `hd decide` | List open decisions |
@@ -64,7 +67,7 @@ the repository is missing, approve private creation interactively, use `--create
64
67
  `--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
65
68
 
66
69
  Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
67
- `/epic approve`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`,
70
+ `/epic approve`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
68
71
  `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
69
72
  `/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
70
73
  the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -105,6 +105,15 @@ export async function updateAgent(id, fields, config = loadConfig()) {
105
105
  export async function deleteAgent(id, config = loadConfig()) {
106
106
  return request(config, "DELETE", `/api/w/${config.slug}/agents`, { id });
107
107
  }
108
+ export async function listWorkspaceEnv(config = loadConfig()) {
109
+ return request(config, "GET", `/api/w/${config.slug}/env`);
110
+ }
111
+ export async function setWorkspaceEnv(name, value, config = loadConfig()) {
112
+ return request(config, "PUT", `/api/w/${config.slug}/env`, { name, value });
113
+ }
114
+ export async function removeWorkspaceEnv(name, config = loadConfig()) {
115
+ return request(config, "DELETE", `/api/w/${config.slug}/env`, { name });
116
+ }
108
117
  export async function listEpics(config = loadConfig()) {
109
118
  return request(config, "GET", `/api/w/${config.slug}/epics`);
110
119
  }
@@ -125,9 +134,16 @@ export async function listMessages(options = {}, config = loadConfig()) {
125
134
  query.set("since", options.since);
126
135
  if (options.limit)
127
136
  query.set("limit", String(options.limit));
137
+ if (options.toRoles?.length)
138
+ query.set("to_role", options.toRoles.join(","));
139
+ if (options.undelivered)
140
+ query.set("undelivered", "true");
128
141
  const suffix = query.size ? `?${query.toString()}` : "";
129
142
  return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
130
143
  }
144
+ export async function markMessagesDelivered(ids, config = loadConfig()) {
145
+ return request(config, "PATCH", `/api/w/${config.slug}/messages`, { ids });
146
+ }
131
147
  export async function updateCaps(provider_caps, config = loadConfig()) {
132
148
  return request(config, "PATCH", `/api/w/${config.slug}/caps`, { provider_caps });
133
149
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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, createAgent, createEpic, createTicket, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createAgent, createEpic, createTicket, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, setPaused, setWorkspaceEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
6
  import { login, parseLoginFlags } from "./login.js";
7
7
  import { loadConfig } from "./config.js";
@@ -344,6 +344,32 @@ async function cmdCaps(argv) {
344
344
  const result = await updateCaps({ [provider]: cap });
345
345
  console.log(`${provider} ${result.provider_caps[provider]}`);
346
346
  }
347
+ async function cmdEnv(argv) {
348
+ const [action, ...args] = argv;
349
+ if (action === "ls") {
350
+ const { env } = await listWorkspaceEnv();
351
+ if (!env.length)
352
+ return console.log(c.dim("No workspace environment variables."));
353
+ console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
354
+ return;
355
+ }
356
+ if (action === "set" && args.length) {
357
+ for (const assignment of args) {
358
+ const at = assignment.indexOf("=");
359
+ if (at < 1)
360
+ fail("usage: hd env set NAME=VALUE [NAME=VALUE...]");
361
+ await setWorkspaceEnv(assignment.slice(0, at), assignment.slice(at + 1));
362
+ }
363
+ console.log(`set ${args.length} variable${args.length === 1 ? "" : "s"}`);
364
+ return;
365
+ }
366
+ if (action === "rm" && args.length === 1) {
367
+ await removeWorkspaceEnv(args[0]);
368
+ console.log(`removed ${args[0]}`);
369
+ return;
370
+ }
371
+ fail("usage: hd env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME");
372
+ }
347
373
  export async function main(argv = process.argv.slice(2), deps = {}) {
348
374
  const [cmd, ...rest] = argv;
349
375
  try {
@@ -405,6 +431,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
405
431
  await cmdCaps(rest);
406
432
  return;
407
433
  }
434
+ if (cmd === "env") {
435
+ await cmdEnv(rest);
436
+ return;
437
+ }
408
438
  if (cmd === "pause" || cmd === "off") {
409
439
  await setPaused(true);
410
440
  console.log(cmd === "off" ? "off" : "paused");
package/dist/out.js CHANGED
@@ -70,6 +70,7 @@ export function usage() {
70
70
  ` ${c.blue("hd workspace ls | new | set | rotate-key")} workspace operations`,
71
71
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
72
72
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
73
+ ` ${c.blue("hd env ls | set | rm")} workspace environment`,
73
74
  ` ${c.blue("hd logs KEY [-f]")} run events`,
74
75
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
75
76
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
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";
@@ -18,7 +18,7 @@ import { alertOnce } from "./alert.js";
18
18
  import { bubbleRows } from "./height.js";
19
19
  import { planLayout, splitPanels } from "./layout.js";
20
20
  import { parseLine } from "./parse.js";
21
- import { configuredSlugs, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, loadLiveEvents, loadTicketDetail, pollSnapshot, postAgentMessage, queueTicket, resolveDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
21
+ import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, queueTicket, resolveDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
22
22
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
23
23
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
24
24
  import { UI } from "./theme.js";
@@ -50,12 +50,14 @@ 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);
56
57
  const history = useRef([]);
57
58
  const historyAt = useRef(-1);
58
59
  const refreshRef = useRef(null);
60
+ const acknowledgedMessages = useRef(new Set());
59
61
  const loads = useRef(new WorkspaceLoads(initial.workspace.id));
60
62
  editingRef.current = editing;
61
63
  useEffect(() => {
@@ -117,6 +119,7 @@ export function App({ initial }) {
117
119
  const settingsOrder = useMemo(() => editableKeys(settings), [settings]);
118
120
  const browsing = mode === "browse" && draft.length === 0 && cursor !== null;
119
121
  const configuring = view === "settings" && !editing;
122
+ const inbox = useMemo(() => inboxEntries(board, width), [board, width]);
120
123
  const moveCursor = useCallback((delta) => {
121
124
  if (!order.length)
122
125
  return false;
@@ -135,6 +138,25 @@ export function App({ initial }) {
135
138
  setField(next);
136
139
  return true;
137
140
  }, [settingsOrder]);
141
+ const moveInbox = useCallback((delta) => {
142
+ setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current + delta)));
143
+ }, [inbox.length]);
144
+ useEffect(() => {
145
+ setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current)));
146
+ }, [inbox.length]);
147
+ useEffect(() => {
148
+ if (view !== "inbox")
149
+ return;
150
+ const ids = (board.messages ?? []).map((message) => message.id)
151
+ .filter((id) => !acknowledgedMessages.current.has(id));
152
+ if (!ids.length)
153
+ return;
154
+ ids.forEach((id) => acknowledgedMessages.current.add(id));
155
+ void acknowledgeInbox(config, ids).catch((error) => {
156
+ ids.forEach((id) => acknowledgedMessages.current.delete(id));
157
+ setNotice(error instanceof Error ? error.message : String(error));
158
+ });
159
+ }, [view, board.messages, config]);
138
160
  const refresh = useCallback(async () => {
139
161
  await refreshRef.current?.();
140
162
  }, []);
@@ -178,6 +200,7 @@ export function App({ initial }) {
178
200
  setBoard(snapshot.board);
179
201
  setFeed(snapshot.feed);
180
202
  setStream([]);
203
+ acknowledgedMessages.current.clear();
181
204
  setCursor(null);
182
205
  selectedRef.current = null;
183
206
  setView("home");
@@ -274,6 +297,8 @@ export function App({ initial }) {
274
297
  return;
275
298
  case "view":
276
299
  setView(action.view);
300
+ if (action.view === "inbox")
301
+ setInboxFocus(0);
277
302
  if (action.view === "board" && order.length) {
278
303
  const next = selectedRef.current && order.includes(selectedRef.current) ? selectedRef.current : order[0];
279
304
  selectedRef.current = next;
@@ -462,6 +487,19 @@ export function App({ initial }) {
462
487
  }
463
488
  return;
464
489
  }
490
+ case "env":
491
+ setBusy(true);
492
+ try {
493
+ const { env } = await listWorkspaceEnv(config);
494
+ say("system", env.length ? env.map((row) => row.name).join("\n") : "No workspace environment variables.");
495
+ }
496
+ catch (error) {
497
+ setNotice(error instanceof Error ? error.message : String(error));
498
+ }
499
+ finally {
500
+ setBusy(false);
501
+ }
502
+ return;
465
503
  case "decide": {
466
504
  const decision = board.decisions[0];
467
505
  if (!decision) {
@@ -545,9 +583,9 @@ export function App({ initial }) {
545
583
  if (item.message.panel === "help")
546
584
  return _jsx(Help, { width: width }, item.key);
547
585
  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
586
+ } }), 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
587
  ? _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) => {
588
+ : _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
589
  setDraft(next);
552
590
  if (editingRef.current)
553
591
  setEditing({ key: editingRef.current.key, draft: next });
@@ -566,6 +604,10 @@ export function App({ initial }) {
566
604
  setCursor(null);
567
605
  selectedRef.current = null;
568
606
  }, onUp: () => {
607
+ if (view === "inbox" && !draft) {
608
+ moveInbox(-1);
609
+ return;
610
+ }
569
611
  if (configuring && moveField(-1))
570
612
  return;
571
613
  if (browsing && moveCursor(-1))
@@ -575,6 +617,10 @@ export function App({ initial }) {
575
617
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
576
618
  setDraft(history.current[historyAt.current] ?? "");
577
619
  }, onDown: () => {
620
+ if (view === "inbox" && !draft) {
621
+ moveInbox(1);
622
+ return;
623
+ }
578
624
  if (configuring && moveField(1))
579
625
  return;
580
626
  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" || 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
122
+ ? ` · ${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));
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 = {
package/dist/tui/Help.js CHANGED
@@ -16,6 +16,7 @@ export const COMMANDS = [
16
16
  { name: "/plan", help: "alias for /architect" },
17
17
  { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
18
18
  { name: "/agents", args: "[add ... | rm ROLE|ID]", help: "view or manage agents" },
19
+ { name: "/env", help: "list workspace environment variable names" },
19
20
  { name: "/settings", help: "change provider caps and agent settings" },
20
21
  { name: "/workspace", args: "[slug | new | set | rotate-key]", help: "list, switch, create, or configure" },
21
22
  { name: "/on", help: "turn on the current workspace" },
@@ -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,21 @@ 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" || row.state === "limited" ? "warning" : "muted";
29
+ const state = row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
30
+ 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));
33
31
  }),
34
- _jsx(More, { count: board.agents.length - shown.length }, "more"),
32
+ _jsx(More, { count: displayRows.length - shown.length }, "more"),
35
33
  ] }));
36
34
  }
37
35
  export function BoardPanel({ board, width = 80, rows = 12, cursor, }) {
@@ -86,22 +84,82 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
86
84
  * The whole queue, one row each, numbered the way `/decide` reaches them: the
87
85
  * one on screen is 1.
88
86
  */
89
- export function InboxPanel({ board, width = 80, rows = 12, }) {
90
- const shown = board.decisions.slice(0, contentRows(rows, board.decisions.length));
87
+ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
88
+ const entries = inboxEntries(board, width);
89
+ const messages = board.messages ?? [];
90
+ const inner = Math.max(0, rows - 1);
91
+ const window = scrollWindow(entries.length, inner, focus);
92
+ const hiddenAbove = window.start;
93
+ const hiddenBelow = entries.length - window.end;
94
+ const note = `${board.decisions.length}d · ${messages.length}m`
95
+ + `${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
91
96
  return (_jsx(Panel, { width: width, rows: rows, children: [
92
- _jsx(Heading, { text: "Decisions", note: `${board.decisions.length}` }, "h"),
93
- ...(board.decisions.length === 0
97
+ _jsx(Heading, { text: "Inbox", note: note }, "h"),
98
+ ...(board.decisions.length === 0 && messages.length === 0
94
99
  ? [
95
100
  _jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
96
101
  ]
97
102
  : []),
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"),
103
+ ...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
104
  ] }));
104
105
  }
106
+ /** Every physical line in /inbox, with decision bodies wrapped but never clipped. */
107
+ export function inboxEntries(board, width) {
108
+ const entries = [];
109
+ const bodyWidth = Math.max(12, width - 2);
110
+ board.decisions.forEach((decision, index) => {
111
+ const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
112
+ entries.push({
113
+ key: `${decision.id}:header`,
114
+ kind: "header",
115
+ text: `${index + 1}) ${ticket?.key ?? decision.id.slice(0, 8)}`,
116
+ });
117
+ wrapLines(decision.question_md.trim(), bodyWidth).forEach((text, line) => {
118
+ entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
119
+ });
120
+ });
121
+ (board.messages ?? []).forEach((message) => {
122
+ entries.push({
123
+ key: `${message.id}:header`,
124
+ kind: "header",
125
+ text: `Message from ${message.from_name ?? message.from_role}`,
126
+ });
127
+ wrapLines(message.body_md.trim(), bodyWidth).forEach((text, line) => {
128
+ entries.push({ key: `${message.id}:body:${line}`, kind: "body", text });
129
+ });
130
+ });
131
+ return entries;
132
+ }
133
+ function wrapLines(text, width) {
134
+ if (!text)
135
+ return [""];
136
+ const lines = [];
137
+ for (const paragraph of text.split("\n")) {
138
+ if (!paragraph) {
139
+ lines.push("");
140
+ continue;
141
+ }
142
+ let line = "";
143
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
144
+ const candidate = line ? `${line} ${word}` : word;
145
+ if (candidate.length <= width) {
146
+ line = candidate;
147
+ continue;
148
+ }
149
+ if (line)
150
+ lines.push(line);
151
+ let rest = word;
152
+ while (rest.length > width) {
153
+ lines.push(rest.slice(0, width));
154
+ rest = rest.slice(width);
155
+ }
156
+ line = rest;
157
+ }
158
+ if (line)
159
+ lines.push(line);
160
+ }
161
+ return lines;
162
+ }
105
163
  /**
106
164
  * One ticket in full, inside the rows it was given.
107
165
  *
@@ -0,0 +1,75 @@
1
+ const LIMITED_UNTIL = /^(\w+) limited until (\d{1,2}:\d{2})$/;
2
+ export function limitedUntilByProvider(tickets) {
3
+ const limited = new Map();
4
+ for (const ticket of tickets) {
5
+ const match = ticket.stuck_reason?.match(LIMITED_UNTIL);
6
+ if (match)
7
+ limited.set(match[1], match[2]);
8
+ }
9
+ return limited;
10
+ }
11
+ const roleForKind = {
12
+ architect: "architect",
13
+ build: "builder",
14
+ followup: "builder",
15
+ orchestrate: "orchestrator",
16
+ review: "reviewer",
17
+ };
18
+ function agentForRun(board, run) {
19
+ if (run.agent_id)
20
+ return board.agents.find((agent) => agent.id === run.agent_id) ?? null;
21
+ const role = roleForKind[run.kind];
22
+ return board.agents.find((agent) => agent.enabled && agent.role === role && agent.provider === run.provider) ?? null;
23
+ }
24
+ /** Expand live invocations into rows, then append enabled agents with no live run. */
25
+ export function agentDisplayRows(board) {
26
+ const activeAgents = new Set();
27
+ const live = board.runs
28
+ .filter((run) => run.status === "running" || run.status === "queued")
29
+ .slice()
30
+ .sort((left, right) => {
31
+ if (left.status !== right.status)
32
+ return left.status === "running" ? -1 : 1;
33
+ const leftAt = left.started_at ?? left.created_at;
34
+ const rightAt = right.started_at ?? right.created_at;
35
+ return leftAt.localeCompare(rightAt) || left.id.localeCompare(right.id);
36
+ })
37
+ .map((run) => {
38
+ const agent = agentForRun(board, run);
39
+ if (agent)
40
+ activeAgents.add(agent.id);
41
+ const ticket = run.ticket_id
42
+ ? board.tickets.find((row) => row.id === run.ticket_id) ?? null
43
+ : null;
44
+ return {
45
+ key: run.id,
46
+ name: agent?.display_name ?? run.kind,
47
+ agent,
48
+ run,
49
+ ticket,
50
+ state: run.status,
51
+ limitedUntil: null,
52
+ };
53
+ });
54
+ const limited = limitedUntilByProvider(board.tickets);
55
+ const idle = board.agents
56
+ .filter((agent) => agent.enabled && !activeAgents.has(agent.id))
57
+ .sort((left, right) => left.display_name.localeCompare(right.display_name))
58
+ .map((agent) => {
59
+ const until = limited.get(agent.provider);
60
+ return {
61
+ key: `idle:${agent.id}`,
62
+ name: agent.display_name,
63
+ agent,
64
+ run: null,
65
+ ticket: null,
66
+ state: until
67
+ ? "limited"
68
+ : board.availability.some((row) => row.provider === agent.provider && !row.available)
69
+ ? "offline"
70
+ : "idle",
71
+ limitedUntil: until ?? null,
72
+ };
73
+ });
74
+ return [...live, ...idle];
75
+ }
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, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, listTicketEvents, 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, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketEvents, 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
  export const POLL_MS = 5_000;
@@ -22,13 +22,14 @@ export async function configuredSlugs(config = loadConfig()) {
22
22
  return (await listWorkspaces(config)).map((workspace) => workspace.slug).sort();
23
23
  }
24
24
  export async function loadSnapshot(config = loadConfig()) {
25
- const [status, workspaceData, ticketData, agentData, epicData, feedData] = await Promise.all([
25
+ const [status, workspaceData, ticketData, agentData, epicData, feedData, messageData] = await Promise.all([
26
26
  getStatus(config),
27
27
  getWorkspace(config),
28
28
  listTickets(config),
29
29
  listAgents(config),
30
30
  listEpics(config),
31
31
  listFeed(config),
32
+ listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
32
33
  ]);
33
34
  const byId = new Map(ticketData.tickets.map((ticket) => [ticket.id, ticket]));
34
35
  const agents = new Map(agentData.agents.map((agent) => [agent.id, agent]));
@@ -63,6 +64,7 @@ export async function loadSnapshot(config = loadConfig()) {
63
64
  agents: agentData.agents,
64
65
  epics: epicData.epics,
65
66
  decisions: status.decisions,
67
+ messages: messageData.messages,
66
68
  availability,
67
69
  runs: status.live_runs,
68
70
  },
@@ -127,6 +129,9 @@ export async function cancelTicket(config, key) {
127
129
  export async function setWorkspacePaused(config, paused) {
128
130
  return setPausedNow(paused, config);
129
131
  }
132
+ export async function listWorkspaceEnv(config) {
133
+ return getWorkspaceEnv(config);
134
+ }
130
135
  export async function createAgent(config, fields) {
131
136
  return postAgent(fields, config);
132
137
  }
@@ -148,6 +153,10 @@ export async function waitForReply(config, role, since, timeoutMs) {
148
153
  export async function resolveDecision(config, id, answer) {
149
154
  await answerDecision(id, answer, config);
150
155
  }
156
+ export async function acknowledgeInbox(config, ids) {
157
+ if (ids.length)
158
+ await markMessagesDelivered(ids, config);
159
+ }
151
160
  export async function updateAgent(config, id, fields) {
152
161
  return patchAgent(id, fields, config);
153
162
  }
package/dist/tui/parse.js CHANGED
@@ -37,6 +37,8 @@ export function parseLine(raw) {
37
37
  ...(opts.effort ? { effort: opts.effort } : {}), ...(opts.name ? { name: opts.name } : {}) }
38
38
  : { kind: "unknown", command: "agents needs add ROLE --provider P --model M or rm ROLE|ID" };
39
39
  }
40
+ case "env":
41
+ return rest.length ? { kind: "unknown", command: "env takes no arguments" } : { kind: "env" };
40
42
  case "help":
41
43
  return { kind: "help" };
42
44
  case "workspace":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.12.1",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"