@higherdev/cli 0.15.4 → 0.16.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
@@ -54,6 +54,7 @@ workspace-map config shapes are migrated automatically when they are read.
54
54
  | `hd host env rm NAME` | Remove a host-only environment variable |
55
55
  | `hd logs KEY [-f]` | Show or follow run events |
56
56
  | `hd msg KEY "message" [--interrupt]` | Message a builder |
57
+ | `hd inbox [--all] [--limit N] [--json]` | List unread inbox messages, or earlier history with `--all` |
57
58
  | `hd decide` | List open decisions |
58
59
  | `hd decide ID --answer TEXT` | Answer a decision |
59
60
  | `hd on` / `hd off` | Turn the workspace on or off |
@@ -95,7 +96,7 @@ Use `hd host env set VERCEL_TOKEN=... VERCEL_TEAM_ID=...` to let the runner adop
95
96
  Vercel project. It syncs `NEXT_PUBLIC_*`, names listed in `settings.vercel.env`, and existing Vercel-held names
96
97
  to production and preview. Later `hd env set` changes wake the orchestrator to update matching Vercel variables.
97
98
 
98
- Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
99
+ Inside the TUI, use `/board`, `/inbox`, `/inbox more`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
99
100
  `/epic approve`, `/epic rm`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
100
101
  `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/workspace grant-runner-access`, `/feed`,
101
102
  `/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
@@ -0,0 +1,80 @@
1
+ import { createAgent, getStatus, listAgents } from "./api.js";
2
+ import { promptOnStdin } from "./prompt.js";
3
+ export const AGENT_ADD_USAGE = "usage: hd agents add [ROLE] --name NAME --provider P --model M --effort E";
4
+ const EFFORTS = ["low", "medium", "high"];
5
+ const ROLES = ["architect", "orchestrator", "reviewer", "builder"];
6
+ function parse(argv) {
7
+ const opts = {};
8
+ const rest = [];
9
+ const missingValues = [];
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ const arg = argv[index];
12
+ if (!arg.startsWith("--")) {
13
+ rest.push(arg);
14
+ continue;
15
+ }
16
+ const name = arg.slice(2);
17
+ const value = argv[index + 1];
18
+ if (!value || value.startsWith("--")) {
19
+ missingValues.push(name);
20
+ continue;
21
+ }
22
+ opts[name] = value;
23
+ index += 1;
24
+ }
25
+ return { opts, rest, missingValues };
26
+ }
27
+ export function nextBuilderName(agents) {
28
+ return `Builder ${agents.filter((agent) => agent.role === "builder").length + 1}`;
29
+ }
30
+ export function availableProvider(capabilities, requested) {
31
+ const capability = capabilities.find((item) => item.provider === requested);
32
+ if (!capability?.available)
33
+ throw new Error(capability?.reason ?? `Unsupported provider ${requested}.`);
34
+ return capability;
35
+ }
36
+ function choose(answer, choices) {
37
+ if (!answer)
38
+ return choices[0];
39
+ return /^\d+$/.test(answer) ? choices[Number(answer) - 1] : answer;
40
+ }
41
+ export async function agentAdd(argv, deps = {}) {
42
+ const { opts, rest, missingValues } = parse(argv);
43
+ if (missingValues.length || rest.length > 1 || Object.keys(opts).some((key) => !["name", "provider", "model", "effort"].includes(key)))
44
+ throw new Error(AGENT_ADD_USAGE);
45
+ const role = rest[0] ?? "builder";
46
+ if (!ROLES.includes(role))
47
+ throw new Error(AGENT_ADD_USAGE);
48
+ const interactive = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
49
+ const needsForm = ["name", "provider", "model", "effort"].some((field) => !opts[field]);
50
+ if (needsForm && !interactive)
51
+ throw new Error(AGENT_ADD_USAGE);
52
+ const [{ agents }, status] = await Promise.all([
53
+ (deps.listAgents ?? listAgents)(deps.config), (deps.getStatus ?? getStatus)(deps.config),
54
+ ]);
55
+ const prompt = deps.prompt ?? promptOnStdin;
56
+ const defaultName = nextBuilderName(agents);
57
+ const name = opts.name?.trim() || (await prompt(`Name [${defaultName}]: `)).trim() || defaultName;
58
+ const capabilities = status.workspace.provider_capabilities ?? [];
59
+ if (opts.provider?.trim())
60
+ availableProvider(capabilities, opts.provider.trim());
61
+ const providers = capabilities.filter((item) => item.available).map((item) => item.provider);
62
+ if (!providers.length && !opts.provider) {
63
+ throw new Error(`No providers are available on runner host ${status.workspace.default_host}.`);
64
+ }
65
+ const providerAnswer = opts.provider?.trim() || (await prompt(`Provider (${providers.map((item, index) => `${index + 1}=${item}`).join(", ")}) [${providers[0]}]: `)).trim();
66
+ const provider = choose(providerAnswer, providers);
67
+ if (!provider)
68
+ throw new Error("Provider is required.");
69
+ availableProvider(capabilities, provider);
70
+ const model = opts.model?.trim() || (await prompt("Model: ")).trim();
71
+ if (!model)
72
+ throw new Error("Model is required.");
73
+ const effortAnswer = opts.effort?.trim() || (await prompt("Effort (1=low, 2=medium, 3=high) [high]: ")).trim();
74
+ const effort = choose(effortAnswer || "high", [...EFFORTS]);
75
+ if (!effort || !EFFORTS.includes(effort)) {
76
+ throw new Error(`Choose effort from: ${EFFORTS.join(", ")}.`);
77
+ }
78
+ return (deps.createAgent ?? createAgent)({ role, provider, model, effort,
79
+ display_name: name }, deps.config);
80
+ }
package/dist/api.js CHANGED
@@ -143,10 +143,16 @@ export async function listMessages(options = {}, config = loadConfig()) {
143
143
  query.set("since", options.since);
144
144
  if (options.limit)
145
145
  query.set("limit", String(options.limit));
146
+ if (options.offset)
147
+ query.set("offset", String(options.offset));
146
148
  if (options.toRoles?.length)
147
149
  query.set("to_role", options.toRoles.join(","));
148
150
  if (options.undelivered)
149
151
  query.set("undelivered", "true");
152
+ if (options.delivered)
153
+ query.set("delivered", "true");
154
+ if (options.order)
155
+ query.set("order", options.order);
150
156
  const suffix = query.size ? `?${query.toString()}` : "";
151
157
  return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
152
158
  }
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
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, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listHostEnv, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, 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";
8
8
  import { epicProgressRows, readEpicSpec } from "./epics.js";
9
9
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
10
10
  import { ticketNew } from "./ticket-commands.js";
11
+ import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
11
12
  import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
12
13
  import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
13
14
  function fail(message) {
@@ -60,6 +61,11 @@ async function cmdStatus() {
60
61
  const data = await getStatus();
61
62
  const state = data.workspace.paused ? c.yellow("off") : c.green("on");
62
63
  console.log(`${c.bold(data.workspace.name)} ${c.dim(data.workspace.repo)} ${state}\n`);
64
+ const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
65
+ ?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
66
+ .map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
67
+ if (waiting.length)
68
+ console.log(`${c.bold("Providers")} ${waiting.map((line) => c.yellow(line)).join(", ")}\n`);
63
69
  printTickets(data.tickets);
64
70
  if (data.live_runs.length > 0) {
65
71
  console.log(`\n${c.bold("Live runs")}`);
@@ -209,6 +215,36 @@ async function cmdMsg(argv) {
209
215
  });
210
216
  console.log(`sent ${message.id}`);
211
217
  }
218
+ async function cmdInbox(argv) {
219
+ const { rest, opts, bools } = flags(argv);
220
+ if (rest.length || (bools.has("limit") && !opts.limit))
221
+ fail("usage: hd inbox [--all] [--limit N] [--json]");
222
+ const all = bools.has("all");
223
+ const json = bools.has("json");
224
+ const limit = opts.limit ? Number(opts.limit) : 50;
225
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500)
226
+ fail("usage: hd inbox [--all] [--limit N] [--json]");
227
+ const { messages } = await listMessages({
228
+ toRoles: ["human", "all"],
229
+ limit,
230
+ ...(all ? { order: "desc" } : { undelivered: true }),
231
+ });
232
+ if (json) {
233
+ console.log(JSON.stringify({ messages }));
234
+ return;
235
+ }
236
+ if (!messages.length) {
237
+ console.log(all ? "No messages." : "No unread messages.");
238
+ return;
239
+ }
240
+ for (const message of messages) {
241
+ const sender = message.from_name?.trim() || message.from_role;
242
+ const ticket = message.ticket_key?.trim();
243
+ console.log([message.created_at, sender, ticket].filter(Boolean).join(" "));
244
+ console.log(message.body_md);
245
+ console.log("");
246
+ }
247
+ }
212
248
  async function cmdDecide(argv) {
213
249
  const { rest, opts } = flags(argv);
214
250
  if (rest.length === 0) {
@@ -293,7 +329,7 @@ function selectAgent(agents, target, provider) {
293
329
  fail(`No unambiguous agent ${target}${provider ? ` for provider ${provider}` : ""}.`);
294
330
  return selected;
295
331
  }
296
- async function cmdAgents(argv) {
332
+ async function cmdAgents(argv, deps = {}) {
297
333
  const [action, ...rest] = argv;
298
334
  if (!action) {
299
335
  const { agents } = await listAgents();
@@ -307,14 +343,11 @@ async function cmdAgents(argv) {
307
343
  ])));
308
344
  return;
309
345
  }
310
- const agentUsage = "usage: hd agents add ROLE --provider P --model M [--effort E] [--name N] | rm ROLE|ID | set ROLE|ID [flags]";
346
+ const agentUsage = `${AGENT_ADD_USAGE} | hd agents rm ROLE|ID | hd agents set ROLE|ID [flags]`;
311
347
  const { rest: args, opts, bools } = flags(rest);
312
348
  const target = args[0];
313
349
  if (action === "add") {
314
- if (!target || !opts.provider || !opts.model)
315
- fail(agentUsage);
316
- const { agent } = await createAgent({ role: target, provider: opts.provider, model: opts.model,
317
- effort: opts.effort, display_name: opts.name ?? opts.provider });
350
+ const { agent } = await agentAdd(rest, deps);
318
351
  console.log(`${agent.id} ${agent.display_name} ${agent.role} ${agent.provider}`);
319
352
  return;
320
353
  }
@@ -339,11 +372,26 @@ async function cmdAgents(argv) {
339
372
  const { agent } = await updateAgent(current.id, fields);
340
373
  console.log(`${agent.display_name} ${agent.role} ${agent.provider} ${agent.model} ${agent.enabled ? "on" : "off"}`);
341
374
  }
375
+ export function mailerCapsRow(auth) {
376
+ if (auth === "smtp")
377
+ return "mailer: custom SMTP";
378
+ if (auth === "built-in" || auth === "configured")
379
+ return "mailer: built-in (2/h)";
380
+ return null;
381
+ }
382
+ export function previewCapsRow(preview) {
383
+ return `preview: ${preview === "bypass" ? "bypass" : "protected"}`;
384
+ }
342
385
  async function cmdCaps(argv) {
343
386
  const [action, provider, raw, ...extra] = argv;
344
387
  if (!action) {
345
- const caps = (await getStatus()).workspace.provider_caps;
388
+ const [status, detail] = await Promise.all([getStatus(), getWorkspace()]);
389
+ const caps = status.workspace.provider_caps ?? {};
346
390
  console.log(table(["PROVIDER", "CAP"], Object.entries(caps).sort().map(([name, cap]) => [name, String(cap)])));
391
+ const mailer = mailerCapsRow(detail.workspace.settings?.infra?.auth);
392
+ if (mailer)
393
+ console.log(mailer);
394
+ console.log(previewCapsRow(detail.workspace.settings?.infra?.vercel_preview));
347
395
  return;
348
396
  }
349
397
  const cap = Number(raw);
@@ -490,6 +538,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
490
538
  await cmdMsg(rest);
491
539
  return;
492
540
  }
541
+ if (cmd === "inbox") {
542
+ await cmdInbox(rest);
543
+ return;
544
+ }
493
545
  if (cmd === "decide") {
494
546
  await cmdDecide(rest);
495
547
  return;
@@ -499,7 +551,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
499
551
  return;
500
552
  }
501
553
  if (cmd === "agents") {
502
- await cmdAgents(rest);
554
+ await cmdAgents(rest, deps);
503
555
  return;
504
556
  }
505
557
  if (cmd === "caps") {
package/dist/out.js CHANGED
@@ -74,6 +74,7 @@ export function usage() {
74
74
  ` ${c.blue("hd host env ls | set | rm")} host environment`,
75
75
  ` ${c.blue("hd logs KEY [-f]")} run events`,
76
76
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
77
+ ` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
77
78
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
78
79
  ` ${c.blue("hd on | hd off")} workspace switch`,
79
80
  ` ${c.blue("hd login --url URL --api-key KEY")} client setup`,
package/dist/tui/App.js CHANGED
@@ -5,6 +5,7 @@ import { loadConfig } from "../config.js";
5
5
  import { epicProgressRows } from "../epics.js";
6
6
  import { promptOnStdin } from "../prompt.js";
7
7
  import { ticketNew } from "../ticket-commands.js";
8
+ import { agentAdd } from "../agent-commands.js";
8
9
  import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
9
10
  import { Banner } from "./Banner.js";
10
11
  import { Bubble } from "./Bubble.js";
@@ -19,8 +20,9 @@ import { alertOnce } from "./alert.js";
19
20
  import { bubbleRows } from "./height.js";
20
21
  import { planLayout, splitPanels } from "./layout.js";
21
22
  import { parseLine } from "./parse.js";
22
- import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
23
+ 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";
23
24
  import { inputActive, promptPlaceholder, QUEUED_STEP, REPLY_WAIT_MS, settleChatReply } from "./chat-wait.js";
25
+ import { EARLIER_PAGE } from "./inbox.js";
24
26
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
25
27
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
26
28
  import { UI } from "./theme.js";
@@ -54,6 +56,9 @@ export function App({ initial }) {
54
56
  const [field, setField] = useState(null);
55
57
  const [editing, setEditing] = useState(null);
56
58
  const [inboxFocus, setInboxFocus] = useState(0);
59
+ const [earlierLimit, setEarlierLimit] = useState(EARLIER_PAGE);
60
+ const earlierLimitRef = useRef(EARLIER_PAGE);
61
+ earlierLimitRef.current = earlierLimit;
57
62
  const selectedRef = useRef(null);
58
63
  const fieldRef = useRef(null);
59
64
  const editingRef = useRef(null);
@@ -94,7 +99,7 @@ export function App({ initial }) {
94
99
  }, []);
95
100
  useEffect(() => {
96
101
  setLive("connecting");
97
- const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)));
102
+ const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)), () => earlierLimitRef.current);
98
103
  refreshRef.current = polling.refresh;
99
104
  return polling.close;
100
105
  }, [config, applySnapshot]);
@@ -212,6 +217,8 @@ export function App({ initial }) {
212
217
  setStream([]);
213
218
  setLogsFilter(null);
214
219
  acknowledgedMessages.current.clear();
220
+ setEarlierLimit(EARLIER_PAGE);
221
+ earlierLimitRef.current = EARLIER_PAGE;
215
222
  setCursor(null);
216
223
  selectedRef.current = null;
217
224
  setView("home");
@@ -502,8 +509,13 @@ export function App({ initial }) {
502
509
  case "agent-add":
503
510
  setBusy(true);
504
511
  try {
505
- const { agent } = await createAgent(config, { role: action.role, provider: action.provider,
506
- model: action.model, effort: action.effort, display_name: action.name ?? action.provider });
512
+ let created;
513
+ await suspendTerminal(async () => {
514
+ created = await agentAdd(action.args, { config, isTTY: true, prompt: tuiPrompt });
515
+ });
516
+ if (!created)
517
+ throw new Error("Agent wizard did not finish.");
518
+ const { agent } = created;
507
519
  say("system", `Added ${agent.display_name} (${agent.id}).`);
508
520
  await refresh();
509
521
  }
@@ -599,6 +611,17 @@ export function App({ initial }) {
599
611
  case "help":
600
612
  setMessages((prior) => [...prior, { id: nextId(), speaker: "system", body: "", panel: "help", done: true }]);
601
613
  return;
614
+ case "inbox-more":
615
+ if (view !== "inbox")
616
+ setView("inbox");
617
+ if (!board.earlierHasMore) {
618
+ say("system", "No more earlier messages.");
619
+ return;
620
+ }
621
+ setEarlierLimit((current) => current + EARLIER_PAGE);
622
+ earlierLimitRef.current += EARLIER_PAGE;
623
+ await refresh();
624
+ return;
602
625
  case "refresh":
603
626
  await refresh();
604
627
  return;
package/dist/tui/Help.js CHANGED
@@ -6,7 +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: "/inbox", help: "decisions and messages waiting on you" },
9
+ { name: "/inbox", args: "[more]", help: "unread and earlier messages; more pages earlier" },
10
10
  { name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open or create a ticket" },
11
11
  { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
12
12
  { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
@@ -17,7 +17,7 @@ export const COMMANDS = [
17
17
  { name: "/architect", help: "talk to the agent that shapes draft epics" },
18
18
  { name: "/plan", help: "alias for /architect" },
19
19
  { name: "/decide", args: "[N|ID] answer", help: "answer a targeted or focused decision" },
20
- { name: "/agents", args: "[add ... | rm ROLE|ID]", help: "view or manage agents" },
20
+ { name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
21
21
  { name: "/env", help: "list workspace environment variable names" },
22
22
  { name: "/settings", help: "change provider caps and agent settings" },
23
23
  { name: "/workspace", args: "[slug | new | set | rotate-key | grant-runner-access]", help: "list, switch, create, or configure" },
@@ -7,6 +7,7 @@ import { UI } from "./theme.js";
7
7
  import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js";
8
8
  import { scrollWindow } from "./Dashboard.js";
9
9
  import { agentDisplayRows } from "./agent-rows.js";
10
+ import { inboxHeader } from "./inbox.js";
10
11
  const DOT = "●";
11
12
  /**
12
13
  * The single-purpose views behind /board, /agents, /feed and /inbox. Each is
@@ -86,27 +87,31 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
86
87
  */
87
88
  export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
88
89
  const entries = inboxEntries(board, width);
89
- const messages = board.messages ?? [];
90
+ const unread = board.messages ?? [];
91
+ const earlier = board.earlier ?? [];
90
92
  const inner = Math.max(0, rows - 1);
91
93
  const window = scrollWindow(entries.length, inner, focus);
92
94
  const hiddenAbove = window.start;
93
95
  const hiddenBelow = entries.length - window.end;
94
- const note = `${board.decisions.length}d · ${messages.length}m`
96
+ const note = `${board.decisions.length}d · ${unread.length} unread`
95
97
  + `${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
98
+ const empty = board.decisions.length === 0 && unread.length === 0 && earlier.length === 0;
96
99
  return (_jsx(Panel, { width: width, rows: rows, children: [
97
100
  _jsx(Heading, { text: "Inbox", note: note }, "h"),
98
- ...(board.decisions.length === 0 && messages.length === 0
101
+ ...(empty
99
102
  ? [
100
103
  _jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
101
104
  ]
102
105
  : []),
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))),
106
+ ...entries.slice(window.start, window.end).map((entry, index) => (_jsx(Text, { color: entry.kind === "body" ? UI.text : UI.warn, wrap: "truncate", inverse: window.start + index === focus, children: entry.kind === "body" ? ` ${entry.text}` : entry.text }, entry.key))),
104
107
  ] }));
105
108
  }
106
- /** Every physical line in /inbox, with decision bodies wrapped but never clipped. */
109
+ /** Every physical line in /inbox, with decision and message bodies wrapped but never clipped. */
107
110
  export function inboxEntries(board, width) {
108
111
  const entries = [];
109
112
  const bodyWidth = Math.max(12, width - 2);
113
+ const unread = board.messages ?? [];
114
+ const earlier = board.earlier ?? [];
110
115
  board.decisions.forEach((decision, index) => {
111
116
  const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
112
117
  entries.push({
@@ -118,18 +123,35 @@ export function inboxEntries(board, width) {
118
123
  entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
119
124
  });
120
125
  });
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
- });
126
+ entries.push({ key: "unread:section", kind: "section", text: "Unread" });
127
+ if (unread.length === 0) {
128
+ entries.push({ key: "unread:empty", kind: "body", text: "No unread messages." });
129
+ }
130
+ else {
131
+ unread.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
132
+ }
133
+ entries.push({ key: "earlier:section", kind: "section", text: "Earlier" });
134
+ if (earlier.length === 0) {
135
+ entries.push({ key: "earlier:empty", kind: "body", text: "No earlier messages." });
136
+ }
137
+ else {
138
+ earlier.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
139
+ if (board.earlierHasMore) {
140
+ entries.push({ key: "earlier:more", kind: "body", text: "Type /inbox more for older messages." });
141
+ }
142
+ }
131
143
  return entries;
132
144
  }
145
+ function pushMessageEntries(entries, message, bodyWidth) {
146
+ entries.push({
147
+ key: `${message.id}:header`,
148
+ kind: "header",
149
+ text: inboxHeader(message),
150
+ });
151
+ wrapLines(message.body_md.trim(), bodyWidth).forEach((text, line) => {
152
+ entries.push({ key: `${message.id}:body:${line}`, kind: "body", text });
153
+ });
154
+ }
133
155
  function wrapLines(text, width) {
134
156
  if (!text)
135
157
  return [""];
@@ -1,4 +1,4 @@
1
- const LIMITED_UNTIL = /^(\w+) limited until (\d{1,2}:\d{2})$/;
1
+ const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
2
2
  export function limitedUntilByProvider(tickets) {
3
3
  const limited = new Map();
4
4
  for (const ticket of tickets) {
package/dist/tui/data.js CHANGED
@@ -1,6 +1,7 @@
1
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, 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
+ import { EARLIER_PAGE } from "./inbox.js";
4
5
  export const POLL_MS = 5_000;
5
6
  export const providers = ["claude", "codex", "gemini", "grok"];
6
7
  export const efforts = ["low", "medium", "high"];
@@ -21,8 +22,16 @@ export const BOARD_COLUMNS = [
21
22
  export async function configuredSlugs(config = loadConfig()) {
22
23
  return (await listWorkspaces(config)).map((workspace) => workspace.slug).sort();
23
24
  }
24
- export async function loadSnapshot(config = loadConfig()) {
25
- const [status, workspaceData, ticketData, agentData, epicData, feedData, messageData] = await Promise.all([
25
+ function withTicketKeys(messages, tickets) {
26
+ const keys = new Map(tickets.map((ticket) => [ticket.id, ticket.key]));
27
+ return messages.map((message) => ({
28
+ ...message,
29
+ ticket_key: message.ticket_key ?? (message.ticket_id ? keys.get(message.ticket_id) ?? null : null),
30
+ }));
31
+ }
32
+ export async function loadSnapshot(config = loadConfig(), options = {}) {
33
+ const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
34
+ const [status, workspaceData, ticketData, agentData, epicData, feedData, unreadData, earlierData] = await Promise.all([
26
35
  getStatus(config),
27
36
  getWorkspace(config),
28
37
  listTickets(config),
@@ -30,6 +39,9 @@ export async function loadSnapshot(config = loadConfig()) {
30
39
  listEpics(config),
31
40
  listFeed(config),
32
41
  listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
42
+ listMessages({
43
+ toRoles: ["human", "all"], delivered: true, order: "desc", limit: earlierLimit + 1,
44
+ }, config),
33
45
  ]);
34
46
  const byId = new Map(ticketData.tickets.map((ticket) => [ticket.id, ticket]));
35
47
  const agents = new Map(agentData.agents.map((agent) => [agent.id, agent]));
@@ -39,11 +51,10 @@ export async function loadSnapshot(config = loadConfig()) {
39
51
  blocker_keys: ticket.blocked_by.map((id) => byId.get(id)?.key).filter((key) => Boolean(key)),
40
52
  stuck: ticket.stuck_reason,
41
53
  }));
42
- const caps = status.workspace.provider_caps ?? {};
43
- const availability = providers.map((provider) => {
44
- const cap = Number(caps[provider] ?? 0);
45
- return { provider, available: cap > 0, reason: cap > 0 ? "available" : "cap 0" };
46
- });
54
+ const availability = status.workspace.provider_capabilities?.length
55
+ ? status.workspace.provider_capabilities
56
+ : providers.map((provider) => ({ provider, available: false, reason: "runner capability unknown" }));
57
+ const earlier = withTicketKeys(earlierData.messages, tickets);
47
58
  return {
48
59
  config,
49
60
  workspace: {
@@ -64,14 +75,16 @@ export async function loadSnapshot(config = loadConfig()) {
64
75
  agents: agentData.agents,
65
76
  epics: epicData.epics,
66
77
  decisions: status.decisions,
67
- messages: messageData.messages,
78
+ messages: withTicketKeys(unreadData.messages, tickets),
79
+ earlier: earlier.slice(0, earlierLimit),
80
+ earlierHasMore: earlier.length > earlierLimit,
68
81
  availability,
69
82
  runs: status.live_runs,
70
83
  },
71
84
  feed: feedData.entries,
72
85
  };
73
86
  }
74
- export function pollSnapshot(config, onSnapshot, onState, onError) {
87
+ export function pollSnapshot(config, onSnapshot, onState, onError, earlierLimit) {
75
88
  let closed = false;
76
89
  let active = false;
77
90
  const refresh = async () => {
@@ -79,7 +92,7 @@ export function pollSnapshot(config, onSnapshot, onState, onError) {
79
92
  return;
80
93
  active = true;
81
94
  try {
82
- const snapshot = await loadSnapshot(config);
95
+ const snapshot = await loadSnapshot(config, { earlierLimit: earlierLimit?.() ?? EARLIER_PAGE });
83
96
  if (closed)
84
97
  return;
85
98
  onSnapshot(snapshot);
@@ -0,0 +1,29 @@
1
+ export const EARLIER_PAGE = 50;
2
+ /** Split a mixed inbox fixture into Unread and Earlier, newest-first for delivered. */
3
+ export function inboxHistory(messages, earlierLimit = EARLIER_PAGE) {
4
+ const unread = messages
5
+ .filter((message) => !message.delivered_at)
6
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
7
+ const earlier = messages
8
+ .filter((message) => Boolean(message.delivered_at))
9
+ .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
10
+ return {
11
+ unread,
12
+ earlier: earlier.slice(0, earlierLimit),
13
+ earlierHasMore: earlier.length > earlierLimit,
14
+ };
15
+ }
16
+ export function inboxTime(iso) {
17
+ const at = Date.parse(iso);
18
+ if (Number.isNaN(at))
19
+ return iso;
20
+ return new Date(at).toISOString().slice(0, 16).replace("T", " ");
21
+ }
22
+ export function inboxSender(message) {
23
+ return message.from_name?.trim() || message.from_role;
24
+ }
25
+ export function inboxHeader(message) {
26
+ const key = message.ticket_key?.trim();
27
+ const prefix = `${inboxTime(message.created_at)} ${inboxSender(message)}`;
28
+ return key ? `${prefix} ${key}` : prefix;
29
+ }
package/dist/tui/parse.js CHANGED
@@ -19,22 +19,22 @@ export function parseLine(raw) {
19
19
  : { kind: "mode", mode: "architect" };
20
20
  case "board":
21
21
  case "feed":
22
- case "inbox":
23
22
  case "settings":
24
23
  return { kind: "view", view: word.toLowerCase() };
24
+ case "inbox":
25
+ if (!argument)
26
+ return { kind: "view", view: "inbox" };
27
+ return rest.length === 1 && rest[0].toLowerCase() === "more"
28
+ ? { kind: "inbox-more" }
29
+ : { kind: "unknown", command: "inbox takes no arguments, or more" };
25
30
  case "agents": {
26
31
  if (!argument)
27
32
  return { kind: "view", view: "agents" };
28
33
  const [verb, target, ...tail] = rest;
29
34
  if (verb === "rm" && target && !tail.length)
30
35
  return { kind: "agent-rm", target };
31
- const opts = {};
32
- for (let i = 0; i < tail.length; i += 2)
33
- if (tail[i]?.startsWith("--") && tail[i + 1])
34
- opts[tail[i].slice(2)] = tail[i + 1];
35
- return verb === "add" && target && opts.provider && opts.model
36
- ? { kind: "agent-add", role: target, provider: opts.provider, model: opts.model,
37
- ...(opts.effort ? { effort: opts.effort } : {}), ...(opts.name ? { name: opts.name } : {}) }
36
+ return verb === "add"
37
+ ? { kind: "agent-add", args: rest.slice(1) }
38
38
  : { kind: "unknown", command: "agents needs add ROLE --provider P --model M or rm ROLE|ID" };
39
39
  }
40
40
  case "env":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"