@higherdev/cli 0.33.0 → 0.35.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 +1 -0
- package/dist/actions-runner.js +114 -0
- package/dist/api.js +3 -0
- package/dist/host.js +10 -1
- package/dist/index.js +56 -3
- package/dist/out.js +1 -1
- package/dist/tui/Dashboard.js +4 -2
- package/dist/tui/Panels.js +4 -2
- package/dist/tui/agent-rows.js +22 -6
- package/dist/tui/data.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
50
50
|
| `hd env set NAME=VALUE [NAME=VALUE...]` | Set workspace environment variables |
|
|
51
51
|
| `hd env rm NAME` | Remove a workspace environment variable |
|
|
52
52
|
| `hd host roll [--host HOST] [--address ADDR] [--checkout DIR]` | Pull, install, and restart a runner over SSH, then watch drain |
|
|
53
|
+
| `hd host runner-install` | Register the default host as the org's `self-hosted, hdx` Actions runner |
|
|
53
54
|
| `hd host env ls` | List environment variable names on the workspace's default host |
|
|
54
55
|
| `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
|
|
55
56
|
| `hd host env rm NAME` | Remove a host-only environment variable |
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, arch } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
export const ACTIONS_RUNNER_TOOLS = ["node", "pnpm", "gh", "supabase", "docker"];
|
|
8
|
+
/** One runner per repository: GitHub only shares runners across repositories for organizations. */
|
|
9
|
+
export function actionsRunnerSlug(repo) {
|
|
10
|
+
return repo.replace("/", "-").toLowerCase().replace(/[^a-z0-9.-]+/g, "-");
|
|
11
|
+
}
|
|
12
|
+
export function actionsRunnerUnitName(repo) {
|
|
13
|
+
return `hdx-actions-runner-${actionsRunnerSlug(repo)}.service`;
|
|
14
|
+
}
|
|
15
|
+
export function actionsRunnerDirectory(home, repo) {
|
|
16
|
+
return path.join(home, ".local/share/hdx/actions-runner", actionsRunnerSlug(repo));
|
|
17
|
+
}
|
|
18
|
+
/** systemd takes WorkingDirectory= and ExecStart= unquoted; only % needs escaping there. */
|
|
19
|
+
function unitPath(value) {
|
|
20
|
+
return value.replace(/%/g, "%%");
|
|
21
|
+
}
|
|
22
|
+
function unitEnvironment(name, value) {
|
|
23
|
+
return `"${name}=${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
|
|
24
|
+
}
|
|
25
|
+
export function actionsRunnerSystemdUnit(input) {
|
|
26
|
+
return `[Unit]
|
|
27
|
+
Description=HDX GitHub Actions runner${input.repo ? ` for ${input.repo}` : ""}
|
|
28
|
+
After=network-online.target
|
|
29
|
+
Wants=network-online.target
|
|
30
|
+
|
|
31
|
+
[Service]
|
|
32
|
+
Type=simple
|
|
33
|
+
WorkingDirectory=${unitPath(input.runnerDir)}
|
|
34
|
+
Environment=${unitEnvironment("PATH", input.path)}
|
|
35
|
+
ExecStart=${unitPath(path.join(input.runnerDir, "bin/runsvc.sh"))}
|
|
36
|
+
Restart=always
|
|
37
|
+
RestartSec=5
|
|
38
|
+
KillMode=process
|
|
39
|
+
|
|
40
|
+
[Install]
|
|
41
|
+
WantedBy=default.target
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
async function exists(target) {
|
|
45
|
+
try {
|
|
46
|
+
await access(target);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function runnerArchitecture(value) {
|
|
54
|
+
if (value === "x64")
|
|
55
|
+
return "x64";
|
|
56
|
+
if (value === "arm64")
|
|
57
|
+
return "arm64";
|
|
58
|
+
throw new Error(`GitHub Actions runner does not support architecture ${value}.`);
|
|
59
|
+
}
|
|
60
|
+
export async function installActionsRunner(options, deps = {}) {
|
|
61
|
+
const platform = deps.platform ?? process.platform;
|
|
62
|
+
if (platform !== "linux")
|
|
63
|
+
throw new Error("hd host runner-install must run on the Linux host.");
|
|
64
|
+
if (process.getuid?.() === 0 && !deps.platform)
|
|
65
|
+
throw new Error("Run hd host runner-install as the runner user, not root.");
|
|
66
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(options.repo))
|
|
67
|
+
throw new Error("--repo must be OWNER/NAME.");
|
|
68
|
+
if (!options.token.trim())
|
|
69
|
+
throw new Error("--token is required.");
|
|
70
|
+
const run = deps.exec ?? ((file, args, commandOptions = {}) => exec(file, args, {
|
|
71
|
+
...commandOptions, encoding: "utf8", maxBuffer: 4 * 1024 * 1024,
|
|
72
|
+
}));
|
|
73
|
+
const home = deps.home ?? homedir();
|
|
74
|
+
const runnerDir = options.runnerDir ?? actionsRunnerDirectory(home, options.repo);
|
|
75
|
+
const unitName = actionsRunnerUnitName(options.repo);
|
|
76
|
+
const unitPath = options.unitPath ?? path.join(home, ".config/systemd/user", unitName);
|
|
77
|
+
const pathValue = options.path ?? process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
78
|
+
for (const tool of ACTIONS_RUNNER_TOOLS) {
|
|
79
|
+
await run(tool, ["--version"], { env: process.env }).catch(() => {
|
|
80
|
+
throw new Error(`${tool} is required before installing the Actions runner.`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await mkdir(runnerDir, { recursive: true });
|
|
84
|
+
const configured = await exists(path.join(runnerDir, ".runner"));
|
|
85
|
+
if (!await exists(path.join(runnerDir, "bin/runsvc.sh"))) {
|
|
86
|
+
const release = (await run("gh", ["api", "repos/actions/runner/releases/latest", "--jq", ".tag_name"])).stdout.trim();
|
|
87
|
+
if (!/^v\d+\.\d+\.\d+$/.test(release))
|
|
88
|
+
throw new Error(`Invalid Actions runner release ${release || "(empty)"}.`);
|
|
89
|
+
const version = release.slice(1);
|
|
90
|
+
const archive = `actions-runner-linux-${runnerArchitecture(deps.architecture ?? arch())}-${version}.tar.gz`;
|
|
91
|
+
await run("gh", ["release", "download", release, "--repo", "actions/runner", "--pattern", archive,
|
|
92
|
+
"--dir", runnerDir, "--clobber"]);
|
|
93
|
+
await run("tar", ["-xzf", path.join(runnerDir, archive), "-C", runnerDir]);
|
|
94
|
+
await unlink(path.join(runnerDir, archive));
|
|
95
|
+
}
|
|
96
|
+
if (!configured) {
|
|
97
|
+
await run(path.join(runnerDir, "config.sh"), ["--unattended", "--replace",
|
|
98
|
+
"--url", `https://github.com/${options.repo}`, "--token", options.token, "--name", options.host,
|
|
99
|
+
"--labels", `hdx,${options.host}`, "--work", "_work"], { cwd: runnerDir, env: process.env });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// The runner writes .runner with a UTF-8 byte-order mark, which JSON.parse rejects.
|
|
103
|
+
const registration = JSON.parse((await readFile(path.join(runnerDir, ".runner"), "utf8")).replace(/^\uFEFF/, ""));
|
|
104
|
+
if (registration.agentName !== options.host || !registration.gitHubUrl?.toLowerCase().includes(`/${options.repo.toLowerCase()}`)) {
|
|
105
|
+
throw new Error(`Existing Actions runner in ${runnerDir} belongs to another host or repository.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await mkdir(path.dirname(unitPath), { recursive: true });
|
|
109
|
+
await writeFile(unitPath, actionsRunnerSystemdUnit({ runnerDir, path: pathValue, repo: options.repo }), "utf8");
|
|
110
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
111
|
+
await run("systemctl", ["--user", "enable", "--now", unitName]);
|
|
112
|
+
return [`GitHub Actions runner ${options.host} registered to ${options.repo}.`,
|
|
113
|
+
`labels: self-hosted, hdx, ${options.host}`, `systemd unit: ${unitPath}`];
|
|
114
|
+
}
|
package/dist/api.js
CHANGED
|
@@ -137,6 +137,9 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
|
|
|
137
137
|
export async function removeHostEnv(host, name, config = loadConfig()) {
|
|
138
138
|
return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
|
|
139
139
|
}
|
|
140
|
+
export async function setHostCi(host, owner, config = loadConfig()) {
|
|
141
|
+
return request(config, "PATCH", `/api/hosts/${encodeURIComponent(host)}/ci`, { ci: "self-hosted", owner });
|
|
142
|
+
}
|
|
140
143
|
export async function getRoadmap(config = loadConfig()) {
|
|
141
144
|
return request(config, "GET", `/api/w/${config.slug}/roadmap`);
|
|
142
145
|
}
|
package/dist/host.js
CHANGED
|
@@ -64,6 +64,15 @@ export function formatDrainStatus(live, until, now = Date.now()) {
|
|
|
64
64
|
const runs = live === 1 ? "1 run" : `${live} runs`;
|
|
65
65
|
return `draining: ${runs}, up to ${minutes}m`;
|
|
66
66
|
}
|
|
67
|
+
export function formatCiMinutes(minutes) {
|
|
68
|
+
const reset = new Date(minutes.resets_at).toLocaleDateString("en-US", {
|
|
69
|
+
month: "short", day: "numeric", timeZone: "UTC",
|
|
70
|
+
});
|
|
71
|
+
const share = minutes.total_runs
|
|
72
|
+
? `; ${Math.round(minutes.box_runs / minutes.total_runs * 100)}% of this month's runs used the box` : "";
|
|
73
|
+
return `GitHub Actions: ${minutes.used.toLocaleString("en-US")} of ${minutes.included.toLocaleString("en-US")}`
|
|
74
|
+
+ ` hosted minutes used, resets ${reset}${share}`;
|
|
75
|
+
}
|
|
67
76
|
export function hostDrainFromStats(stats) {
|
|
68
77
|
if (!stats || typeof stats !== "object" || Array.isArray(stats))
|
|
69
78
|
return null;
|
|
@@ -174,7 +183,7 @@ export function hostRollCommand(checkout) {
|
|
|
174
183
|
"npm i -g @higherdev/cli@latest",
|
|
175
184
|
].join(" && ");
|
|
176
185
|
}
|
|
177
|
-
async function defaultSsh(address, command) {
|
|
186
|
+
export async function defaultSsh(address, command) {
|
|
178
187
|
return execFileAsync("ssh", ["-o", "BatchMode=yes", address, "bash", "-lc", command], { encoding: "utf8" });
|
|
179
188
|
}
|
|
180
189
|
async function exists(file) {
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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, 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
|
-
import { formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.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, setHostCi, showTicket, updateAgent, updateEpic, updateCaps, } from "./api.js";
|
|
5
|
+
import { defaultSsh, formatCiMinutes, formatDrainStatus, hostRoll, initHost, parseHostFlags, parseHostRollFlags } from "./host.js";
|
|
6
|
+
import { installActionsRunner } from "./actions-runner.js";
|
|
6
7
|
import { login, parseLoginFlags } from "./login.js";
|
|
7
8
|
import { loadConfig } from "./config.js";
|
|
8
9
|
import { beginUpdateCheck, pendingUpdateNotice, runUpdate, shouldUpdateCheck, } from "./update-check.js";
|
|
@@ -69,6 +70,12 @@ async function cmdStatus() {
|
|
|
69
70
|
if (data.host?.draining) {
|
|
70
71
|
console.log(`${c.yellow(formatDrainStatus(data.host.draining.live, data.host.draining.until))}\n`);
|
|
71
72
|
}
|
|
73
|
+
const signedOut = data.host?.signed_out ?? [];
|
|
74
|
+
if (signedOut.length) {
|
|
75
|
+
console.log(`${c.bold("Host")} ${signedOut.map((row) => c.yellow(row.reason)).join("\n ")}\n`);
|
|
76
|
+
}
|
|
77
|
+
if (data.host?.ci_minutes)
|
|
78
|
+
console.log(`${c.bold("CI")} ${formatCiMinutes(data.host.ci_minutes)}\n`);
|
|
72
79
|
const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
|
|
73
80
|
?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
|
|
74
81
|
.map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
|
|
@@ -578,7 +585,53 @@ async function cmdHost(argv, deps = {}) {
|
|
|
578
585
|
await hostRoll(parseHostRollFlags([action, ...args].filter((value) => Boolean(value))), deps);
|
|
579
586
|
return;
|
|
580
587
|
}
|
|
581
|
-
const usage = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
|
|
588
|
+
const usage = "usage: hd host runner-install [--repo OWNER/NAME --token TOKEN] | roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
|
|
589
|
+
if (scope === "runner-install") {
|
|
590
|
+
const parsed = flags([action, ...args].filter((value) => Boolean(value)));
|
|
591
|
+
if (parsed.rest.length || parsed.bools.size)
|
|
592
|
+
fail(usage);
|
|
593
|
+
if (parsed.opts.repo || parsed.opts.token) {
|
|
594
|
+
// Host mode: register one runner for one repository with a token minted by the owner.
|
|
595
|
+
// With --host it needs no hd login on the host, so the first runner can be bootstrapped
|
|
596
|
+
// from a checkout before any published CLI can reach the host.
|
|
597
|
+
if (!parsed.opts.repo || !parsed.opts.token)
|
|
598
|
+
fail(usage);
|
|
599
|
+
if (process.platform !== "linux" && !deps.installActionsRunner)
|
|
600
|
+
fail("Run this form on the Linux host.");
|
|
601
|
+
const host = parsed.opts.host ?? (await getStatus()).host?.id ?? (await getStatus()).workspace.default_host;
|
|
602
|
+
const lines = await (deps.installActionsRunner ?? installActionsRunner)({
|
|
603
|
+
host, repo: parsed.opts.repo, token: parsed.opts.token,
|
|
604
|
+
});
|
|
605
|
+
console.log(lines.join("\n"));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const status = await getStatus();
|
|
609
|
+
const host = parsed.opts.host ?? status.host?.id ?? status.workspace.default_host;
|
|
610
|
+
// Owner mode: GitHub shares runners across repositories only for organizations, and a
|
|
611
|
+
// registration token needs admin on the repository, so mint one per workspace repository
|
|
612
|
+
// with the operator's gh here and hand each to the host over SSH. Tokens expire in an hour.
|
|
613
|
+
const address = status.host?.ssh_address;
|
|
614
|
+
if (!address)
|
|
615
|
+
fail(`hd host runner-install needs an SSH address for ${host}. Set RUNNER_SSH_ADDRESS on the host.`);
|
|
616
|
+
const workspaces = await listWorkspaces();
|
|
617
|
+
const repos = [...new Set(workspaces
|
|
618
|
+
.filter((workspace) => !("default_host" in workspace) || workspace.default_host === host)
|
|
619
|
+
.map((workspace) => workspace.repo))];
|
|
620
|
+
if (!repos.length)
|
|
621
|
+
fail(`No workspaces use host ${host}.`);
|
|
622
|
+
const gh = deps.gh ?? defaultGh;
|
|
623
|
+
for (const repo of repos) {
|
|
624
|
+
const token = (await gh(["api", "--method", "POST", `/repos/${repo}/actions/runners/registration-token`,
|
|
625
|
+
"--jq", ".token"])).trim();
|
|
626
|
+
if (!token)
|
|
627
|
+
fail(`GitHub did not return a runner registration token for ${repo}; you need admin on that repository.`);
|
|
628
|
+
const remote = await (deps.ssh ?? defaultSsh)(address, `hd --no-update-check host runner-install --host ${host} --repo ${repo} --token ${token}`);
|
|
629
|
+
console.log(remote.stdout.trim() || `Registered a runner for ${repo} on ${host}.`);
|
|
630
|
+
}
|
|
631
|
+
await setHostCi(host, repos[0].split("/")[0]);
|
|
632
|
+
console.log(`CI on ${host} enabled for ${repos.length} repositor${repos.length === 1 ? "y" : "ies"}; workflows move to [self-hosted, hdx] on the next reconcile.`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
582
635
|
if (scope !== "env")
|
|
583
636
|
fail(usage);
|
|
584
637
|
const host = (await getStatus()).workspace.default_host;
|
package/dist/out.js
CHANGED
|
@@ -73,7 +73,7 @@ export function usage() {
|
|
|
73
73
|
` ${c.blue("hd agents [add | rm | set]")} manage agents`,
|
|
74
74
|
` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
|
|
75
75
|
` ${c.blue("hd env ls [--vercel] | set | rm")} workspace environment`,
|
|
76
|
-
` ${c.blue("hd host
|
|
76
|
+
` ${c.blue("hd host runner-install | roll | env")} install CI, roll, or edit host env`,
|
|
77
77
|
` ${c.blue("hd logs KEY [-f]")} run events`,
|
|
78
78
|
` ${c.blue("hd msg KEY TEXT")} message a builder`,
|
|
79
79
|
` ${c.blue("hd attach PATH --ticket KEY | --to ROLE")} attach a file`,
|
package/dist/tui/Dashboard.js
CHANGED
|
@@ -118,11 +118,13 @@ export function AgentsColumn({ board, width, rows, }) {
|
|
|
118
118
|
]
|
|
119
119
|
: []),
|
|
120
120
|
...shown.map((row) => {
|
|
121
|
-
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
121
|
+
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
122
|
+
|| row.state === "draining" || row.state === "signed_out"
|
|
122
123
|
? "warning" : "muted";
|
|
123
124
|
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
125
|
? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
|
|
125
|
-
: ` · ${row.
|
|
126
|
+
: ` · ${row.state === "signed_out" ? "signed out"
|
|
127
|
+
: row.limitedUntil ? `limited until ${row.limitedUntil}` : row.detail ?? row.state}` }))] })] }, row.key));
|
|
126
128
|
}),
|
|
127
129
|
_jsx(More, { count: displayRows.length - shown.length }, "more"),
|
|
128
130
|
] }));
|
package/dist/tui/Panels.js
CHANGED
|
@@ -28,10 +28,12 @@ export function AgentsPanel({ board, width = 80, rows = 12, }) {
|
|
|
28
28
|
]
|
|
29
29
|
: []),
|
|
30
30
|
...shown.map((row) => {
|
|
31
|
-
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
31
|
+
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
32
|
+
|| row.state === "draining" || row.state === "signed_out"
|
|
32
33
|
? "warning" : "muted";
|
|
33
34
|
const state = row.state === "draining" ? ""
|
|
34
|
-
: row.
|
|
35
|
+
: row.state === "signed_out" ? "signed out"
|
|
36
|
+
: row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
|
|
35
37
|
return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(row.name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(row.agent?.role ?? row.run?.kind ?? "", 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(row.agent?.model ?? "", 23), 24) }), _jsx(Text, { color: UI.text, children: pad(state, row.limitedUntil ? 22 : 9) }), _jsxs(Text, { color: UI.dim, children: [row.ticket ? `${row.ticket.key} ` : "", row.run ? elapsed(row.run.started_at ?? row.run.created_at) : ""] })] }, row.key));
|
|
36
38
|
}),
|
|
37
39
|
_jsx(More, { count: displayRows.length - shown.length }, "more"),
|
package/dist/tui/agent-rows.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { formatDrainStatus } from "../host.js";
|
|
2
2
|
import { orchestratorIdleReason } from "../roadmap.js";
|
|
3
3
|
const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
|
|
4
|
+
const SIGNED_OUT = /^(?:Claude Code|Codex|Grok|Gemini) on host \S+ is signed out/;
|
|
4
5
|
export function limitedUntilByProvider(tickets) {
|
|
5
6
|
const limited = new Map();
|
|
6
7
|
for (const ticket of tickets) {
|
|
@@ -10,6 +11,15 @@ export function limitedUntilByProvider(tickets) {
|
|
|
10
11
|
}
|
|
11
12
|
return limited;
|
|
12
13
|
}
|
|
14
|
+
export function signedOutProviders(board) {
|
|
15
|
+
const providers = new Set((board.hostSignedOut ?? []).map((row) => row.provider));
|
|
16
|
+
for (const ticket of board.tickets) {
|
|
17
|
+
if (ticket.stuck_reason && SIGNED_OUT.test(ticket.stuck_reason) && ticket.provider) {
|
|
18
|
+
providers.add(ticket.provider);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return providers;
|
|
22
|
+
}
|
|
13
23
|
const roleForKind = {
|
|
14
24
|
architect: "architect",
|
|
15
25
|
build: "builder",
|
|
@@ -67,24 +77,30 @@ export function agentDisplayRows(board, now = Date.now()) {
|
|
|
67
77
|
};
|
|
68
78
|
});
|
|
69
79
|
const limited = limitedUntilByProvider(board.tickets);
|
|
80
|
+
const signedOut = signedOutProviders(board);
|
|
70
81
|
const idle = board.agents
|
|
71
82
|
.filter((agent) => agent.enabled && !activeAgents.has(agent.id))
|
|
72
83
|
.sort((left, right) => left.display_name.localeCompare(right.display_name))
|
|
73
84
|
.map((agent) => {
|
|
74
85
|
const until = limited.get(agent.provider);
|
|
86
|
+
const out = signedOut.has(agent.provider);
|
|
75
87
|
return {
|
|
76
88
|
key: `idle:${agent.id}`,
|
|
77
89
|
name: agent.display_name,
|
|
78
90
|
agent,
|
|
79
91
|
run: null,
|
|
80
92
|
ticket: null,
|
|
81
|
-
state:
|
|
82
|
-
? "
|
|
83
|
-
:
|
|
84
|
-
? "
|
|
85
|
-
:
|
|
93
|
+
state: out
|
|
94
|
+
? "signed_out"
|
|
95
|
+
: until
|
|
96
|
+
? "limited"
|
|
97
|
+
: board.availability.some((row) => row.provider === agent.provider && !row.available)
|
|
98
|
+
? "offline"
|
|
99
|
+
: "idle",
|
|
86
100
|
limitedUntil: until ?? null,
|
|
87
|
-
detail:
|
|
101
|
+
detail: out
|
|
102
|
+
? "signed out"
|
|
103
|
+
: agent.role === "orchestrator" ? orchestratorIdleReason(board.epics) : null,
|
|
88
104
|
};
|
|
89
105
|
});
|
|
90
106
|
return [...drain, ...live, ...idle];
|
package/dist/tui/data.js
CHANGED
|
@@ -84,6 +84,7 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
|
84
84
|
availability,
|
|
85
85
|
runs: status.recent_runs ?? status.live_runs,
|
|
86
86
|
hostDrain: status.host?.draining ?? null,
|
|
87
|
+
hostSignedOut: status.host?.signed_out ?? [],
|
|
87
88
|
},
|
|
88
89
|
feed: feedData.entries,
|
|
89
90
|
};
|