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