@higherdev/cli 0.28.0 → 0.29.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/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") {
@@ -445,8 +464,10 @@ async function cmdAgents(argv, deps = {}) {
445
464
  };
446
465
  if (!Object.keys(fields).length)
447
466
  fail(agentUsage);
448
- const { agent } = await updateAgent(current.id, fields);
467
+ const { agent, cancelled_runs = 0 } = await updateAgent(current.id, fields);
449
468
  console.log(`${agent.display_name} ${agent.role} ${agent.provider} ${agent.model} ${agent.enabled ? "on" : "off"}`);
469
+ if (cancelled_runs)
470
+ console.log(`cancellation requested for ${cancelled_runs} live run${cancelled_runs === 1 ? "" : "s"}`);
450
471
  }
451
472
  export function mailerCapsRow(auth) {
452
473
  if (auth === "smtp")
@@ -622,6 +643,14 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
622
643
  await cmdTicket(rest, deps);
623
644
  return;
624
645
  }
646
+ if (cmd === "run") {
647
+ await cmdRun(rest);
648
+ return;
649
+ }
650
+ if (cmd === "runs") {
651
+ await cmdRuns(rest);
652
+ return;
653
+ }
625
654
  if (cmd === "epic") {
626
655
  await cmdEpic(rest);
627
656
  return;
@@ -658,7 +687,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
658
687
  await cmdWorkspace(rest, deps);
659
688
  return;
660
689
  }
661
- if (cmd === "agents") {
690
+ if (cmd === "agents" || cmd === "agent") {
662
691
  await cmdAgents(rest, deps);
663
692
  return;
664
693
  }
package/dist/out.js CHANGED
@@ -65,6 +65,7 @@ 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 runs | hd run cancel ID")} inspect or cancel live runs`,
68
69
  ` ${c.blue("hd epic new PATH | 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`,
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) {
@@ -767,6 +771,20 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
767
771
  setBusy(false);
768
772
  }
769
773
  return;
774
+ case "cancel-run":
775
+ setBusy(true);
776
+ try {
777
+ await cancelRun(config, action.id);
778
+ say("system", `Cancellation requested for run ${action.id}.`);
779
+ await refresh();
780
+ }
781
+ catch (error) {
782
+ setNotice(error instanceof Error ? error.message : String(error));
783
+ }
784
+ finally {
785
+ setBusy(false);
786
+ }
787
+ return;
770
788
  case "merge":
771
789
  setBusy(true);
772
790
  try {
package/dist/tui/Help.js CHANGED
@@ -10,7 +10,7 @@ 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" },
@@ -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,
package/dist/tui/parse.js CHANGED
@@ -79,7 +79,9 @@ export function parseLine(raw) {
79
79
  ? { kind: "queue", key: argument.toUpperCase() }
80
80
  : { kind: "unknown", command: "queue needs a key" };
81
81
  case "cancel":
82
- return argument ? { kind: "cancel", key: argument.toUpperCase() }
82
+ return argument ? /^HD-\d+$/i.test(argument)
83
+ ? { kind: "cancel", key: argument.toUpperCase() }
84
+ : { kind: "cancel-run", id: argument }
83
85
  : { kind: "unknown", command: "cancel needs a key" };
84
86
  case "merge":
85
87
  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.29.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",