@higherdev/cli 0.28.0 → 0.30.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
@@ -30,7 +30,7 @@ workspace-map config shapes are migrated automatically when they are read.
30
30
  | `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket non-interactively |
31
31
  | `hd ticket queue KEY` | Queue a complete ticket now |
32
32
  | `hd ticket cancel KEY` | Cancel a ticket |
33
- | `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
33
+ | `hd epic new PATH [--title TITLE] [--draft]` | Create an approved epic, or keep it as a draft |
34
34
  | `hd epic list` | List epics and ticket progress |
35
35
  | `hd epic approve ID` | Open a draft epic for orchestrator decomposition |
36
36
  | `hd epic rm ID` | Remove a draft epic |
package/dist/api.js CHANGED
@@ -81,6 +81,9 @@ export async function postMessage(fields, config = loadConfig()) {
81
81
  export async function getRun(id, config = loadConfig()) {
82
82
  return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
83
83
  }
84
+ export async function cancelRun(id, config = loadConfig()) {
85
+ return request(config, "POST", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}/cancel`);
86
+ }
84
87
  export async function answerDecision(id, answer_md, config = loadConfig()) {
85
88
  return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
86
89
  }
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, mergeTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, cancelRun, mergeTicket, createEpic, deleteAgent, deleteEpic, getStatus, getRoadmap, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
5
5
  import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
6
6
  import { login, parseLoginFlags } from "./login.js";
7
7
  import { loadConfig } from "./config.js";
@@ -89,6 +89,25 @@ async function cmdStatus() {
89
89
  console.log(`\n${c.bold("Chat replies")} median ${seconds}s (last day)`);
90
90
  }
91
91
  }
92
+ export function runAge(started, created, now = Date.now()) {
93
+ const seconds = Math.max(0, Math.floor((now - Date.parse(started ?? created)) / 1000));
94
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
95
+ }
96
+ async function cmdRun(argv) {
97
+ if (argv[0] !== "cancel" || !argv[1] || argv.length !== 2)
98
+ fail("usage: hd run cancel ID");
99
+ const { run } = await cancelRun(argv[1]);
100
+ console.log(`${run.id} cancellation requested`);
101
+ }
102
+ async function cmdRuns(argv) {
103
+ if (argv.length)
104
+ fail("usage: hd runs");
105
+ const [{ live_runs: runs }, { agents }] = await Promise.all([getStatus(), listAgents()]);
106
+ const names = new Map(agents.map((agent) => [agent.id, agent.display_name]));
107
+ console.log(table(["ID", "AGE", "KIND", "AGENT"], runs.map((run) => [
108
+ run.id, runAge(run.started_at, run.created_at), run.kind, names.get(run.agent_id ?? "") ?? run.provider,
109
+ ])));
110
+ }
92
111
  async function cmdTicket(argv, deps = {}) {
93
112
  const [action, ...rest] = argv;
94
113
  if (action === "list") {
@@ -167,18 +186,19 @@ async function cmdEpic(argv) {
167
186
  return;
168
187
  }
169
188
  console.log(table(["KEY", "STATUS", "PROGRESS", "TITLE"], rows.map((epic) => [
170
- epic.id, statusChip(epic.status), `${epic.merged}/${epic.total}`, epic.title,
189
+ epic.id, epic.status === "draft" ? c.yellow(`needs your approval; hd epic approve ${epic.id}`)
190
+ : statusChip(epic.status), `${epic.merged}/${epic.total}`, epic.title,
171
191
  ])));
172
192
  return;
173
193
  }
174
194
  if (action === "new") {
175
- const { rest: paths, opts } = flags(rest);
195
+ const { rest: paths, opts, bools } = flags(rest);
176
196
  const path = paths.join(" ");
177
- if (!path)
178
- fail("usage: hd epic new PATH [--title TITLE]");
197
+ if (!path || [...bools].some((name) => name !== "draft"))
198
+ fail("usage: hd epic new PATH [--title TITLE] [--draft]");
179
199
  const input = await readEpicSpec(path, opts.title);
180
- const { epic } = await createEpic(input);
181
- console.log(`${c.bold(epic.id)} ${statusChip(epic.status)} ${epic.title}`);
200
+ const { epic, message } = await createEpic({ ...input, draft: bools.has("draft") });
201
+ console.log(`${c.bold(epic.id)} ${message}`);
182
202
  return;
183
203
  }
184
204
  if (action === "approve" || action === "rm") {
@@ -214,7 +234,7 @@ async function cmdEpic(argv) {
214
234
  console.log(`${c.bold(epic.id)} position ${epic.position}`);
215
235
  return;
216
236
  }
217
- fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
237
+ fail("usage: hd epic new PATH [--title TITLE] [--draft] | list | approve ID | rm ID | set ID --position N");
218
238
  }
219
239
  async function cmdRoadmap(argv) {
220
240
  const parsed = flags(argv);
@@ -445,8 +465,10 @@ async function cmdAgents(argv, deps = {}) {
445
465
  };
446
466
  if (!Object.keys(fields).length)
447
467
  fail(agentUsage);
448
- const { agent } = await updateAgent(current.id, fields);
468
+ const { agent, cancelled_runs = 0 } = await updateAgent(current.id, fields);
449
469
  console.log(`${agent.display_name} ${agent.role} ${agent.provider} ${agent.model} ${agent.enabled ? "on" : "off"}`);
470
+ if (cancelled_runs)
471
+ console.log(`cancellation requested for ${cancelled_runs} live run${cancelled_runs === 1 ? "" : "s"}`);
450
472
  }
451
473
  export function mailerCapsRow(auth) {
452
474
  if (auth === "smtp")
@@ -622,6 +644,14 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
622
644
  await cmdTicket(rest, deps);
623
645
  return;
624
646
  }
647
+ if (cmd === "run") {
648
+ await cmdRun(rest);
649
+ return;
650
+ }
651
+ if (cmd === "runs") {
652
+ await cmdRuns(rest);
653
+ return;
654
+ }
625
655
  if (cmd === "epic") {
626
656
  await cmdEpic(rest);
627
657
  return;
@@ -658,7 +688,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
658
688
  await cmdWorkspace(rest, deps);
659
689
  return;
660
690
  }
661
- if (cmd === "agents") {
691
+ if (cmd === "agents" || cmd === "agent") {
662
692
  await cmdAgents(rest, deps);
663
693
  return;
664
694
  }
package/dist/out.js CHANGED
@@ -65,7 +65,8 @@ export function usage() {
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
67
  ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel | merge")} ticket operations`,
68
- ` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
68
+ ` ${c.blue("hd runs | hd run cancel ID")} inspect or cancel live runs`,
69
+ ` ${c.blue("hd epic new PATH [--draft] | list | approve | rm")} epic operations`,
69
70
  ` ${c.blue("hd roadmap [--json]")} ordered workspace roadmap`,
70
71
  ` ${c.blue("hd plan")} use /architect in the TUI`,
71
72
  ` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
package/dist/roadmap.js CHANGED
@@ -1,7 +1,12 @@
1
1
  export function currentRoadmapEpic(epics) {
2
2
  const statuses = new Map(epics.map((epic) => [epic.id, epic.status]));
3
- return [...epics].sort((a, b) => a.position - b.position).find((epic) => !["draft", "done"].includes(epic.status)
4
- && epic.depends_on.every((id) => statuses.get(id) === "done")) ?? null;
3
+ const next = [...epics].sort((a, b) => a.position - b.position).find((epic) => epic.status !== "done" && epic.depends_on.every((id) => statuses.get(id) === "done"));
4
+ return next?.status === "draft" ? null : next ?? null;
5
+ }
6
+ export function orchestratorIdleReason(epics) {
7
+ const statuses = new Map(epics.map((epic) => [epic.id, epic.status]));
8
+ const next = [...epics].sort((a, b) => a.position - b.position).find((epic) => epic.status !== "done" && epic.depends_on.every((id) => statuses.get(id) === "done"));
9
+ return next?.status === "draft" ? `idle because ${next.title} is a draft and needs your approval` : null;
5
10
  }
6
11
  export function progressBar(merged, total, width = 10) {
7
12
  const complete = total > 0 ? Math.round((Math.max(0, Math.min(merged, total)) / total) * width) : 0;
@@ -12,7 +17,7 @@ export function roadmapText(epics) {
12
17
  const current = currentRoadmapEpic(epics);
13
18
  return [...epics].sort((a, b) => a.position - b.position).flatMap((epic) => {
14
19
  const dependencies = epic.depends_on.map((id) => names.get(id) ?? id);
15
- const status = epic.status === "draft" ? "draft, awaiting approval" : epic.status;
20
+ const status = epic.status === "draft" ? `needs your approval · /epic approve ${epic.id}` : epic.status;
16
21
  return [
17
22
  `${epic.id === current?.id ? "▶" : " "} ${epic.position}. ${epic.title} [${status}]`,
18
23
  ` Outcome: ${epic.outcome_md || "Not described"}`,
package/dist/tui/App.js CHANGED
@@ -25,7 +25,7 @@ import { alertOnce } from "./alert.js";
25
25
  import { bubbleRows } from "./height.js";
26
26
  import { planLayout, splitPanels } from "./layout.js";
27
27
  import { parseLine } from "./parse.js";
28
- import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, mergeTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, followChat, loadChatMessages, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, POLL_MS, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
28
+ import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, cancelRun, mergeTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, followChat, loadChatMessages, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, POLL_MS, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
29
29
  import { inputActive, promptPlaceholder, settleChatReply } from "./chat-wait.js";
30
30
  import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
31
31
  import { EARLIER_PAGE } from "./inbox.js";
@@ -315,16 +315,20 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
315
315
  }
316
316
  setBusy(true);
317
317
  try {
318
+ let message = null;
318
319
  if (edit.value.target === "cap") {
319
320
  await updateProviderCap(config, edit.value.provider, edit.value.cap);
320
321
  }
321
322
  else if (edit.value.target === "agent") {
322
- await updateAgent(config, edit.value.id, edit.value.fields);
323
+ const result = await updateAgent(config, edit.value.id, edit.value.fields);
324
+ const count = result.cancelled_runs ?? 0;
325
+ if (count)
326
+ message = `Saved. Cancellation requested for ${count} live run${count === 1 ? "" : "s"}.`;
323
327
  }
324
328
  else {
325
329
  await updateWorkspace(config, edit.value.fields);
326
330
  }
327
- setNotice(null);
331
+ setNotice(message);
328
332
  await refresh();
329
333
  }
330
334
  catch (error) {
@@ -679,8 +683,8 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
679
683
  case "epic-new":
680
684
  setBusy(true);
681
685
  try {
682
- const { epic } = await createEpicFromFile(config, action.path);
683
- say("system", `Created epic ${epic.id}: ${epic.title}`);
686
+ const { epic, message } = await createEpicFromFile(config, action.path, action.draft);
687
+ say("system", `Created epic ${epic.id}: ${message}`);
684
688
  await refresh();
685
689
  }
686
690
  catch (error) {
@@ -721,7 +725,8 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
721
725
  case "epics": {
722
726
  const rows = epicProgressRows(board.epics, board.tickets);
723
727
  say("system", rows.length
724
- ? rows.map((epic) => `${epic.id} ${epic.status} ${epic.merged}/${epic.total} ${epic.title}`).join("\n")
728
+ ? rows.map((epic) => `${epic.id} ${epic.status === "draft"
729
+ ? `needs your approval · /epic approve ${epic.id}` : epic.status} ${epic.merged}/${epic.total} ${epic.title}`).join("\n")
725
730
  : "No epics.");
726
731
  return;
727
732
  }
@@ -767,6 +772,20 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
767
772
  setBusy(false);
768
773
  }
769
774
  return;
775
+ case "cancel-run":
776
+ setBusy(true);
777
+ try {
778
+ await cancelRun(config, action.id);
779
+ say("system", `Cancellation requested for run ${action.id}.`);
780
+ await refresh();
781
+ }
782
+ catch (error) {
783
+ setNotice(error instanceof Error ? error.message : String(error));
784
+ }
785
+ finally {
786
+ setBusy(false);
787
+ }
788
+ return;
770
789
  case "merge":
771
790
  setBusy(true);
772
791
  try {
@@ -122,7 +122,7 @@ export function AgentsColumn({ board, width, rows, }) {
122
122
  ? "warning" : "muted";
123
123
  return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, row.state === "draining" ? null : (_jsx(Text, { color: UI.dim, children: row.run
124
124
  ? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
125
- : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` }))] })] }, row.key));
125
+ : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.detail ?? row.state}` }))] })] }, row.key));
126
126
  }),
127
127
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
128
128
  ] }));
package/dist/tui/Help.js CHANGED
@@ -10,12 +10,12 @@ export const COMMANDS = [
10
10
  { name: "/inbox", args: "[more]", help: "unread, earlier, and numbered decisions; Enter answers" },
11
11
  { name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open a full ticket view or create one" },
12
12
  { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
13
- { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
13
+ { name: "/cancel", args: "HD-12 | RUN-ID", help: "cancel a ticket or run" },
14
14
  { name: "/merge", args: "HD-12", help: "approve a reviewed PR over the reviewer's objections" },
15
15
  { name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
16
16
  { name: "/attach", args: "PATH [HD-12]", help: "attach a file to the open or named ticket" },
17
17
  { name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
18
- { name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
18
+ { name: "/epic", args: "new PATH [--draft] | approve ID | rm ID", help: "create, approve, or remove an epic" },
19
19
  { name: "/epics", help: "list epics and ticket progress" },
20
20
  { name: "/architect", help: "open a chat with the architect" },
21
21
  { name: "/plan", help: "alias for /architect" },
@@ -93,14 +93,15 @@ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, selectedId
93
93
  const entries = inboxEntries(board, width, answeringId);
94
94
  const unread = board.messages ?? [];
95
95
  const earlier = board.earlier ?? [];
96
+ const drafts = board.epics.filter((epic) => epic.status === "draft").length;
96
97
  const selected = selectedId ?? decisionIdAt(entries, focus);
97
98
  const inner = Math.max(0, rows - 1);
98
99
  const window = scrollWindow(entries.length, inner, focus);
99
100
  const hiddenAbove = window.start;
100
101
  const hiddenBelow = entries.length - window.end;
101
- const note = `${board.decisions.length}d · ${unread.length} unread`
102
+ const note = `${drafts ? `${drafts} epic${drafts === 1 ? "" : "s"} · ` : ""}${board.decisions.length}d · ${unread.length} unread`
102
103
  + `${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
103
- const empty = board.decisions.length === 0 && unread.length === 0 && earlier.length === 0;
104
+ const empty = drafts === 0 && board.decisions.length === 0 && unread.length === 0 && earlier.length === 0;
104
105
  return (_jsx(Panel, { width: width, rows: rows, children: [
105
106
  _jsx(Heading, { text: "Inbox", note: note }, "h"),
106
107
  ...(empty
@@ -122,6 +123,15 @@ export function inboxEntries(board, width, answeringId) {
122
123
  const bodyWidth = Math.max(12, width - 2);
123
124
  const unread = board.messages ?? [];
124
125
  const earlier = board.earlier ?? [];
126
+ const drafts = board.epics.filter((epic) => epic.status === "draft");
127
+ if (drafts.length) {
128
+ entries.push({ key: "drafts:section", kind: "section", text: "Draft epics" });
129
+ for (const epic of drafts) {
130
+ entries.push({ key: `draft:${epic.id}`, kind: "header", text: epic.title });
131
+ entries.push({ key: `draft:${epic.id}:action`, kind: "body",
132
+ text: `Needs your approval · /epic approve ${epic.id}` });
133
+ }
134
+ }
125
135
  board.decisions.forEach((decision, index) => {
126
136
  const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
127
137
  entries.push({
@@ -30,7 +30,7 @@ export function RoadmapPanel({ board, width = 80, rows = 12, offset = 0 }) {
30
30
  _jsx(Heading, { text: "Roadmap", note: note }, "h"),
31
31
  ...shown.map((line, index) => {
32
32
  const current = line.startsWith("▶");
33
- const draft = line.includes("draft, awaiting approval");
33
+ const draft = line.includes("needs your approval");
34
34
  return _jsx(Text, { color: current ? UI.accent
35
35
  : draft ? inkColor("warning") : line === "Vision" ? UI.text : UI.dim, bold: current || line === "Vision", inverse: current, wrap: "truncate", children: line || " " }, `${start + index}:${line}`);
36
36
  }),
@@ -1,4 +1,5 @@
1
1
  import { formatDrainStatus } from "../host.js";
2
+ import { orchestratorIdleReason } from "../roadmap.js";
2
3
  const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
3
4
  export function limitedUntilByProvider(tickets) {
4
5
  const limited = new Map();
@@ -33,6 +34,7 @@ export function agentDisplayRows(board, now = Date.now()) {
33
34
  ticket: null,
34
35
  state: "draining",
35
36
  limitedUntil: null,
37
+ detail: null,
36
38
  }]
37
39
  : [];
38
40
  const activeAgents = new Set();
@@ -61,6 +63,7 @@ export function agentDisplayRows(board, now = Date.now()) {
61
63
  ticket,
62
64
  state: run.status,
63
65
  limitedUntil: null,
66
+ detail: null,
64
67
  };
65
68
  });
66
69
  const limited = limitedUntilByProvider(board.tickets);
@@ -81,6 +84,7 @@ export function agentDisplayRows(board, now = Date.now()) {
81
84
  ? "offline"
82
85
  : "idle",
83
86
  limitedUntil: until ?? null,
87
+ detail: agent.role === "orchestrator" ? orchestratorIdleReason(board.epics) : null,
84
88
  };
85
89
  });
86
90
  return [...drain, ...live, ...idle];
@@ -96,7 +96,7 @@ export function followShouldStop(run, reply, tagged = false) {
96
96
  return true;
97
97
  if (!run)
98
98
  return false;
99
- if (["failed", "killed"].includes(run.status))
99
+ if (["failed", "killed", "cancelled"].includes(run.status))
100
100
  return true;
101
101
  // The role's other runs may finish while ours is still queued; only the run
102
102
  // started for this message is definitive when it ends without a reply.
@@ -28,7 +28,7 @@ export function settleChatReply(reply, run) {
28
28
  if (reply) {
29
29
  return { body: reply, pending: false, steps: [], done: true, replyMs: run?.reply_ms ?? null };
30
30
  }
31
- if (run && ["failed", "killed"].includes(run.status ?? "")) {
31
+ if (run && ["failed", "killed", "cancelled"].includes(run.status ?? "")) {
32
32
  return { body: failedChatNote(run.summary), pending: false, steps: [], done: true, replyMs: run.reply_ms ?? null };
33
33
  }
34
34
  if (run && run.status && !["queued", "running"].includes(run.status)) {
package/dist/tui/data.js CHANGED
@@ -1,4 +1,4 @@
1
- import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getRoadmap, getRun, getWorkspace, listAgents, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, mergeTicket as mergeTicketNow, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
1
+ import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, cancelRun as cancelRunNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getRoadmap, getRun, getWorkspace, listAgents, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, mergeTicket as mergeTicketNow, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
2
2
  import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
3
  import { readEpicSpec } from "../epics.js";
4
4
  import { EARLIER_PAGE } from "./inbox.js";
@@ -70,6 +70,7 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
70
70
  chat: Number(workspaceData.workspace.settings.max_turns?.chat ?? 8),
71
71
  },
72
72
  max_attempts: Number(workspaceData.workspace.settings.max_attempts ?? 3),
73
+ chat_timeout_ms: Number(workspaceData.workspace.settings.chat_timeout_ms ?? 600_000),
73
74
  },
74
75
  board: {
75
76
  tickets,
@@ -129,6 +130,9 @@ export async function postAgentMessage(role, body, config, attachmentIds = []) {
129
130
  attachment_ids: attachmentIds }, config);
130
131
  return message;
131
132
  }
133
+ export async function cancelRun(config, id) {
134
+ await cancelRunNow(id, config);
135
+ }
132
136
  export async function loadChatMessages(config, role) {
133
137
  const { messages } = await listMessages({
134
138
  unticketed: true,
@@ -173,8 +177,8 @@ export async function loadTicketDetail(config, key) {
173
177
  },
174
178
  };
175
179
  }
176
- export async function createEpicFromFile(config, path) {
177
- return postEpic(await readEpicSpec(path), config);
180
+ export async function createEpicFromFile(config, path, draft = false) {
181
+ return postEpic({ ...await readEpicSpec(path), draft }, config);
178
182
  }
179
183
  export async function approveEpic(config, id) {
180
184
  return approveEpicNow(id, config);
package/dist/tui/parse.js CHANGED
@@ -61,7 +61,10 @@ export function parseLine(raw) {
61
61
  : { kind: "unknown", command: "ticket needs a key" };
62
62
  case "epic":
63
63
  if (rest[0]?.toLowerCase() === "new" && rest.length > 1) {
64
- return { kind: "epic-new", path: rest.slice(1).join(" ") };
64
+ const draft = rest.includes("--draft");
65
+ const path = rest.slice(1).filter((part) => part !== "--draft").join(" ");
66
+ return path ? { kind: "epic-new", path, draft }
67
+ : { kind: "unknown", command: "epic needs new PATH, approve ID, or rm ID" };
65
68
  }
66
69
  return rest[0]?.toLowerCase() === "approve" && rest.length === 2
67
70
  ? { kind: "epic-approve", id: rest[1] }
@@ -79,7 +82,9 @@ export function parseLine(raw) {
79
82
  ? { kind: "queue", key: argument.toUpperCase() }
80
83
  : { kind: "unknown", command: "queue needs a key" };
81
84
  case "cancel":
82
- return argument ? { kind: "cancel", key: argument.toUpperCase() }
85
+ return argument ? /^HD-\d+$/i.test(argument)
86
+ ? { kind: "cancel", key: argument.toUpperCase() }
87
+ : { kind: "cancel-run", id: argument }
83
88
  : { kind: "unknown", command: "cancel needs a key" };
84
89
  case "merge":
85
90
  return argument ? { kind: "merge", key: argument.toUpperCase() }
@@ -12,6 +12,7 @@ export function settingsRows(workspace, agents) {
12
12
  value: String(workspace.max_turns[kind]), hint: "integer >= 1",
13
13
  })),
14
14
  { key: "w:max_attempts", kind: "number", label: "max attempts", value: String(workspace.max_attempts) },
15
+ { key: "w:chat_timeout_ms", kind: "number", label: "chat timeout ms", value: String(workspace.chat_timeout_ms ?? 600_000) },
15
16
  ];
16
17
  for (const provider of providers) {
17
18
  rows.push({
@@ -81,6 +82,12 @@ export function editFor(row, raw) {
81
82
  return { ok: false, error: "max_attempts must be an integer >= 1." };
82
83
  return { ok: true, value: { target: "workspace", fields: { max_attempts: number } } };
83
84
  }
85
+ if (id === "chat_timeout_ms") {
86
+ const number = Number(value);
87
+ if (!Number.isInteger(number) || number < 1_000)
88
+ return { ok: false, error: "chat_timeout_ms must be an integer >= 1000." };
89
+ return { ok: true, value: { target: "workspace", fields: { chat_timeout_ms: number } } };
90
+ }
84
91
  if (id === "auto_merge")
85
92
  return { ok: true, value: { target: "workspace", fields: { auto_merge: value === "yes" } } };
86
93
  if (!value)
@@ -39,7 +39,7 @@ export function toStreamLines(events, runs, raw = false) {
39
39
  const target = run.ticket ? ` on ${run.ticket}` : "";
40
40
  lines.push({ id: `${run.runId}:end`, sourceIds: [`${run.runId}:end`], runId: run.runId,
41
41
  agent: run.agent, at: run.endedAt ?? "", seq: Number.MAX_SAFE_INTEGER,
42
- kind: run.status === "failed" || run.status === "killed" ? "error" : "status",
42
+ kind: ["failed", "killed", "cancelled"].includes(run.status) ? "error" : "status",
43
43
  title: `${run.agent} finished ${run.kind}${target}: ${run.summary}` });
44
44
  }
45
45
  return collapse(lines.sort((a, b) => a.at.localeCompare(b.at) || a.seq - b.seq), raw);
@@ -150,6 +150,12 @@ export async function workspaceSet(argv) {
150
150
  throw new Error("--max-attempts must be an integer >= 1");
151
151
  fields.max_attempts = value;
152
152
  }
153
+ if (opts["chat-timeout-ms"]) {
154
+ const value = Number(opts["chat-timeout-ms"]);
155
+ if (!Number.isInteger(value) || value < 1_000)
156
+ throw new Error("--chat-timeout-ms must be an integer >= 1000");
157
+ fields.chat_timeout_ms = value;
158
+ }
153
159
  if (!Object.keys(fields).length)
154
160
  throw new Error(WORKSPACE_USAGE);
155
161
  return (await updateWorkspace(fields)).workspace;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",