@higherdev/cli 0.17.0 → 0.21.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 +8 -1
- package/dist/api.js +16 -0
- package/dist/host.js +120 -0
- package/dist/index.js +71 -11
- package/dist/out.js +3 -2
- package/dist/roadmap.js +23 -0
- package/dist/tui/App.js +164 -29
- package/dist/tui/Chat.js +28 -0
- package/dist/tui/Dashboard.js +11 -5
- package/dist/tui/Help.js +3 -2
- package/dist/tui/Panels.js +4 -2
- package/dist/tui/Roadmap.js +38 -0
- package/dist/tui/agent-rows.js +14 -2
- package/dist/tui/chat-view.js +125 -0
- package/dist/tui/data.js +37 -5
- package/dist/tui/parse.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
49
49
|
| `hd env ls` | List workspace environment variable names |
|
|
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
|
+
| `hd host roll [--host HOST] [--address ADDR] [--checkout DIR]` | Pull, install, and restart a runner over SSH, then watch drain |
|
|
52
53
|
| `hd host env ls` | List environment variable names on the workspace's default host |
|
|
53
54
|
| `hd host env set NAME=VALUE [NAME=VALUE...]` | Set host-only environment variables |
|
|
54
55
|
| `hd host env rm NAME` | Remove a host-only environment variable |
|
|
@@ -64,9 +65,15 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
64
65
|
| `hd upgrade --host HOST [--checkout DIR] [options]` | Refresh this host configuration |
|
|
65
66
|
|
|
66
67
|
`hd init` uses `~/hdx` as its checkout by default. Pass `--checkout DIR` when the runner source is
|
|
67
|
-
elsewhere.
|
|
68
|
+
elsewhere. Pass `--address HOST` to store `RUNNER_SSH_ADDRESS` so `hd host roll` can SSH to that
|
|
69
|
+
host. If the runner environment or service unit already exists, inspect the paths it prints and
|
|
68
70
|
pass `--force` only when replacing that host configuration is intentional.
|
|
69
71
|
|
|
72
|
+
`hd host roll` SSHes to the host address (`--address`, or `RUNNER_SSH_ADDRESS` from the host
|
|
73
|
+
heartbeat), pulls the checkout, installs, and restarts the systemd unit. The runner drains live
|
|
74
|
+
runs for up to 15 minutes (`RUNNER_DRAIN_TIMEOUT_MS`) before force-closing leftovers. The systemd
|
|
75
|
+
unit `TimeoutStopSec` is one minute above that drain timeout.
|
|
76
|
+
|
|
70
77
|
`hd workspace new` uses the operator's authenticated `gh`, defaults to the repository's real default
|
|
71
78
|
branch, bootstraps an empty repository unless `--no-bootstrap` is set, and invites `mel-ilotus` with push
|
|
72
79
|
access for user-owned repositories or admin for organization-owned repositories unless `--runner-user USER`
|
package/dist/api.js
CHANGED
|
@@ -75,6 +75,9 @@ export async function listTicketStory(key, config = loadConfig()) {
|
|
|
75
75
|
export async function postMessage(fields, config = loadConfig()) {
|
|
76
76
|
return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
|
|
77
77
|
}
|
|
78
|
+
export async function getRun(id, config = loadConfig()) {
|
|
79
|
+
return request(config, "GET", `/api/w/${config.slug}/runs/${encodeURIComponent(id)}`);
|
|
80
|
+
}
|
|
78
81
|
export async function answerDecision(id, answer_md, config = loadConfig()) {
|
|
79
82
|
return request(config, "POST", `/api/w/${config.slug}/decisions/${encodeURIComponent(id)}/answer`, { answer_md });
|
|
80
83
|
}
|
|
@@ -128,6 +131,12 @@ export async function setHostEnv(host, name, value, config = loadConfig()) {
|
|
|
128
131
|
export async function removeHostEnv(host, name, config = loadConfig()) {
|
|
129
132
|
return request(config, "DELETE", `/api/hosts/${encodeURIComponent(host)}/env`, { name });
|
|
130
133
|
}
|
|
134
|
+
export async function getRoadmap(config = loadConfig()) {
|
|
135
|
+
return request(config, "GET", `/api/w/${config.slug}/roadmap`);
|
|
136
|
+
}
|
|
137
|
+
export async function updateRoadmap(vision_md, config = loadConfig()) {
|
|
138
|
+
return request(config, "PATCH", `/api/w/${config.slug}/roadmap`, { vision_md });
|
|
139
|
+
}
|
|
131
140
|
export async function listEpics(config = loadConfig()) {
|
|
132
141
|
return request(config, "GET", `/api/w/${config.slug}/epics`);
|
|
133
142
|
}
|
|
@@ -137,6 +146,9 @@ export async function createEpic(fields, config = loadConfig()) {
|
|
|
137
146
|
export async function approveEpic(id, config = loadConfig()) {
|
|
138
147
|
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
|
|
139
148
|
}
|
|
149
|
+
export async function updateEpic(id, fields, config = loadConfig()) {
|
|
150
|
+
return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id, ...fields });
|
|
151
|
+
}
|
|
140
152
|
export async function deleteEpic(id, config = loadConfig()) {
|
|
141
153
|
return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
|
|
142
154
|
}
|
|
@@ -152,6 +164,10 @@ export async function listMessages(options = {}, config = loadConfig()) {
|
|
|
152
164
|
query.set("offset", String(options.offset));
|
|
153
165
|
if (options.toRoles?.length)
|
|
154
166
|
query.set("to_role", options.toRoles.join(","));
|
|
167
|
+
if (options.fromRoles?.length)
|
|
168
|
+
query.set("from_role", options.fromRoles.join(","));
|
|
169
|
+
if (options.unticketed)
|
|
170
|
+
query.set("unticketed", "true");
|
|
155
171
|
if (options.undelivered)
|
|
156
172
|
query.set("undelivered", "true");
|
|
157
173
|
if (options.delivered)
|
package/dist/host.js
CHANGED
|
@@ -53,6 +53,35 @@ export function launchdPlist(opts) {
|
|
|
53
53
|
</plist>
|
|
54
54
|
`;
|
|
55
55
|
}
|
|
56
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 15 * 60_000;
|
|
57
|
+
export const HOST_ROLL_USAGE = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR]";
|
|
58
|
+
export function systemdStopTimeoutSec(drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS) {
|
|
59
|
+
return Math.ceil(drainTimeoutMs / 1_000) + 60;
|
|
60
|
+
}
|
|
61
|
+
export function formatDrainStatus(live, until, now = Date.now()) {
|
|
62
|
+
const remaining = Date.parse(until) - now;
|
|
63
|
+
const minutes = Number.isFinite(remaining) ? Math.max(0, Math.ceil(remaining / 60_000)) : 0;
|
|
64
|
+
const runs = live === 1 ? "1 run" : `${live} runs`;
|
|
65
|
+
return `draining: ${runs}, up to ${minutes}m`;
|
|
66
|
+
}
|
|
67
|
+
export function hostDrainFromStats(stats) {
|
|
68
|
+
if (!stats || typeof stats !== "object" || Array.isArray(stats))
|
|
69
|
+
return null;
|
|
70
|
+
const draining = stats.draining;
|
|
71
|
+
if (!draining || typeof draining !== "object" || Array.isArray(draining))
|
|
72
|
+
return null;
|
|
73
|
+
const live = Number(draining.live);
|
|
74
|
+
const until = draining.until;
|
|
75
|
+
if (!Number.isFinite(live) || live < 0 || typeof until !== "string")
|
|
76
|
+
return null;
|
|
77
|
+
return { live, until };
|
|
78
|
+
}
|
|
79
|
+
export function sshAddressFromStats(stats) {
|
|
80
|
+
if (!stats || typeof stats !== "object" || Array.isArray(stats))
|
|
81
|
+
return null;
|
|
82
|
+
const value = stats.ssh_address;
|
|
83
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
84
|
+
}
|
|
56
85
|
export function systemdUnit(opts) {
|
|
57
86
|
return `[Unit]
|
|
58
87
|
Description=HDX runner
|
|
@@ -65,6 +94,8 @@ WorkingDirectory=${opts.workDir}
|
|
|
65
94
|
ExecStart=${opts.nodePath} --experimental-strip-types ${opts.runnerPath}
|
|
66
95
|
Restart=always
|
|
67
96
|
RestartSec=5
|
|
97
|
+
TimeoutStopSec=${systemdStopTimeoutSec(opts.drainTimeoutMs)}
|
|
98
|
+
KillMode=mixed
|
|
68
99
|
|
|
69
100
|
[Install]
|
|
70
101
|
WantedBy=default.target
|
|
@@ -85,6 +116,8 @@ export function parseHostFlags(argv) {
|
|
|
85
116
|
flags.host = next();
|
|
86
117
|
else if (arg === "--checkout")
|
|
87
118
|
flags.checkout = next();
|
|
119
|
+
else if (arg === "--address")
|
|
120
|
+
flags.address = next();
|
|
88
121
|
else if (arg === "--force")
|
|
89
122
|
flags.force = true;
|
|
90
123
|
else if (arg === "--env-out")
|
|
@@ -104,6 +137,45 @@ export function parseHostFlags(argv) {
|
|
|
104
137
|
}
|
|
105
138
|
return flags;
|
|
106
139
|
}
|
|
140
|
+
export function parseHostRollFlags(argv) {
|
|
141
|
+
const flags = {};
|
|
142
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
143
|
+
const arg = argv[i];
|
|
144
|
+
const next = () => {
|
|
145
|
+
const value = argv[i + 1];
|
|
146
|
+
if (!value || value.startsWith("--"))
|
|
147
|
+
throw new Error(`Missing value for ${arg}`);
|
|
148
|
+
i += 1;
|
|
149
|
+
return value;
|
|
150
|
+
};
|
|
151
|
+
if (arg === "--host")
|
|
152
|
+
flags.host = next();
|
|
153
|
+
else if (arg === "--address")
|
|
154
|
+
flags.address = next();
|
|
155
|
+
else if (arg === "--checkout")
|
|
156
|
+
flags.checkout = next();
|
|
157
|
+
else if (!arg.startsWith("-") && !flags.host)
|
|
158
|
+
flags.host = arg;
|
|
159
|
+
else
|
|
160
|
+
throw new Error(arg.startsWith("-") ? `Unknown option: ${arg}` : HOST_ROLL_USAGE);
|
|
161
|
+
}
|
|
162
|
+
return flags;
|
|
163
|
+
}
|
|
164
|
+
function shellQuote(value) {
|
|
165
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
166
|
+
}
|
|
167
|
+
export function hostRollCommand(checkout) {
|
|
168
|
+
return [
|
|
169
|
+
"set -e",
|
|
170
|
+
`cd ${shellQuote(checkout)}`,
|
|
171
|
+
"git pull --ff-only",
|
|
172
|
+
"pnpm install",
|
|
173
|
+
"systemctl --user restart hdx-runner.service",
|
|
174
|
+
].join(" && ");
|
|
175
|
+
}
|
|
176
|
+
async function defaultSsh(address, command) {
|
|
177
|
+
return execFileAsync("ssh", ["-o", "BatchMode=yes", address, "bash", "-lc", command], { encoding: "utf8" });
|
|
178
|
+
}
|
|
107
179
|
async function exists(file) {
|
|
108
180
|
try {
|
|
109
181
|
await access(file);
|
|
@@ -159,6 +231,9 @@ export async function initHost(flags, env = process.env) {
|
|
|
159
231
|
RUNNER_HOST: host,
|
|
160
232
|
WORKTREE_ROOT: worktreeRoot,
|
|
161
233
|
GH_TOKEN: env.GH_TOKEN || existing.GH_TOKEN || "",
|
|
234
|
+
...(flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS
|
|
235
|
+
? { RUNNER_SSH_ADDRESS: flags.address || env.RUNNER_SSH_ADDRESS || existing.RUNNER_SSH_ADDRESS }
|
|
236
|
+
: {}),
|
|
162
237
|
};
|
|
163
238
|
await mkdir(path.dirname(envFile), { recursive: true });
|
|
164
239
|
await writeFile(envFile, formatEnvFile(merged), { encoding: "utf8", mode: 0o600 });
|
|
@@ -198,3 +273,48 @@ export async function initHost(flags, env = process.env) {
|
|
|
198
273
|
lines.push("Start the runner service to register this host.");
|
|
199
274
|
return lines.join("\n");
|
|
200
275
|
}
|
|
276
|
+
const ROLL_WAIT_MS = DEFAULT_DRAIN_TIMEOUT_MS + 2 * 60_000;
|
|
277
|
+
export async function hostRoll(flags, deps = {}) {
|
|
278
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
279
|
+
const now = deps.now ?? Date.now;
|
|
280
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
281
|
+
const ssh = deps.ssh ?? defaultSsh;
|
|
282
|
+
const loadStatus = deps.getStatus ?? (await import("./api.js")).getStatus;
|
|
283
|
+
const snapshot = await loadStatus();
|
|
284
|
+
const host = flags.host ?? snapshot.workspace.default_host;
|
|
285
|
+
const address = flags.address ?? snapshot.host?.ssh_address ?? null;
|
|
286
|
+
if (!address) {
|
|
287
|
+
throw new Error(`hd host roll needs an SSH address for ${host}. Pass --address or set RUNNER_SSH_ADDRESS on the host.`);
|
|
288
|
+
}
|
|
289
|
+
const checkout = expandHome(flags.checkout ?? "~/hdx", homedir());
|
|
290
|
+
const command = hostRollCommand(checkout);
|
|
291
|
+
const lines = [`rolling ${host} at ${address}`];
|
|
292
|
+
log(lines[0]);
|
|
293
|
+
await ssh(address, command);
|
|
294
|
+
const restarted = `restarted ${host}; waiting for drain`;
|
|
295
|
+
lines.push(restarted);
|
|
296
|
+
log(restarted);
|
|
297
|
+
const before = snapshot.host?.last_seen_at ?? null;
|
|
298
|
+
const deadline = now() + (deps.waitMs ?? ROLL_WAIT_MS);
|
|
299
|
+
let lastDrain = "";
|
|
300
|
+
while (now() < deadline) {
|
|
301
|
+
const status = await loadStatus();
|
|
302
|
+
const drain = status.host?.draining ?? null;
|
|
303
|
+
if (drain) {
|
|
304
|
+
const line = formatDrainStatus(drain.live, drain.until, now());
|
|
305
|
+
if (line !== lastDrain) {
|
|
306
|
+
lastDrain = line;
|
|
307
|
+
lines.push(line);
|
|
308
|
+
log(line);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (status.host?.last_seen_at && status.host.last_seen_at !== before && status.host.runner_version) {
|
|
312
|
+
const line = `${host} runner ${status.host.runner_version}`;
|
|
313
|
+
lines.push(line);
|
|
314
|
+
log(line);
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
await sleep(2_000);
|
|
318
|
+
}
|
|
319
|
+
throw new Error(`timed out waiting for ${host} to finish draining`);
|
|
320
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
|
|
5
|
-
import { initHost, parseHostFlags } from "./host.js";
|
|
4
|
+
import { approveEpic, answerDecision, cancelTicket, 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";
|
|
6
6
|
import { login, parseLoginFlags } from "./login.js";
|
|
7
7
|
import { loadConfig } from "./config.js";
|
|
8
8
|
import { epicProgressRows, readEpicSpec } from "./epics.js";
|
|
9
|
+
import { roadmapText } from "./roadmap.js";
|
|
9
10
|
import { banner, c, statusChip, table, truncate, usage } from "./out.js";
|
|
10
11
|
import { ticketNew } from "./ticket-commands.js";
|
|
11
12
|
import { ticketViewLines } from "./tui/ticket-view.js";
|
|
@@ -63,6 +64,9 @@ async function cmdStatus() {
|
|
|
63
64
|
const data = await getStatus();
|
|
64
65
|
const state = data.workspace.paused ? c.yellow("off") : c.green("on");
|
|
65
66
|
console.log(`${c.bold(data.workspace.name)} ${c.dim(data.workspace.repo)} ${state}\n`);
|
|
67
|
+
if (data.host?.draining) {
|
|
68
|
+
console.log(`${c.yellow(formatDrainStatus(data.host.draining.live, data.host.draining.until))}\n`);
|
|
69
|
+
}
|
|
66
70
|
const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
|
|
67
71
|
?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
|
|
68
72
|
.map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
|
|
@@ -175,7 +179,44 @@ async function cmdEpic(argv) {
|
|
|
175
179
|
}
|
|
176
180
|
return;
|
|
177
181
|
}
|
|
178
|
-
|
|
182
|
+
if (action === "set") {
|
|
183
|
+
const parsed = flags(rest);
|
|
184
|
+
const id = parsed.rest[0];
|
|
185
|
+
const choices = ["after", "position", "depends-on"].filter((name) => parsed.opts[name] !== undefined);
|
|
186
|
+
if (!id || parsed.rest.length !== 1 || choices.length !== 1) {
|
|
187
|
+
fail("usage: hd epic set ID --after ID | --position N | --depends-on ID");
|
|
188
|
+
}
|
|
189
|
+
const position = parsed.opts.position ? Number(parsed.opts.position) : undefined;
|
|
190
|
+
if (position !== undefined && (!Number.isInteger(position) || position < 1)) {
|
|
191
|
+
fail("position must be a positive integer.");
|
|
192
|
+
}
|
|
193
|
+
const { epic } = await updateEpic(id, {
|
|
194
|
+
...(parsed.opts.after ? { after: parsed.opts.after } : {}),
|
|
195
|
+
...(position !== undefined ? { position } : {}),
|
|
196
|
+
...(parsed.opts["depends-on"] ? { depends_on: [parsed.opts["depends-on"]] } : {}),
|
|
197
|
+
});
|
|
198
|
+
console.log(`${c.bold(epic.id)} position ${epic.position}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID | set ID --position N");
|
|
202
|
+
}
|
|
203
|
+
async function cmdRoadmap(argv) {
|
|
204
|
+
const parsed = flags(argv);
|
|
205
|
+
if (parsed.rest.length || [...parsed.bools].some((name) => name !== "json") || Object.keys(parsed.opts).length) {
|
|
206
|
+
fail("usage: hd roadmap [--json]");
|
|
207
|
+
}
|
|
208
|
+
const data = await getRoadmap();
|
|
209
|
+
if (parsed.bools.has("json"))
|
|
210
|
+
return console.log(JSON.stringify(data));
|
|
211
|
+
if (!data.roadmap)
|
|
212
|
+
return console.log(c.dim("No roadmap."));
|
|
213
|
+
console.log(c.bold("Vision"));
|
|
214
|
+
console.log(data.roadmap.vision_md);
|
|
215
|
+
if (!data.epics.length)
|
|
216
|
+
return console.log(`\n${c.dim("No epics.")}`);
|
|
217
|
+
console.log("");
|
|
218
|
+
for (const line of roadmapText(data.epics))
|
|
219
|
+
console.log(line);
|
|
179
220
|
}
|
|
180
221
|
async function cmdPlan(argv) {
|
|
181
222
|
if (argv.length)
|
|
@@ -423,12 +464,23 @@ function githubSecretMissing(error) {
|
|
|
423
464
|
return /404|not found|does not exist/i.test(message);
|
|
424
465
|
}
|
|
425
466
|
export async function cmdEnv(argv, deps = {}) {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
467
|
+
const parsed = flags(argv);
|
|
468
|
+
const [rawAction, ...args] = parsed.rest;
|
|
469
|
+
const action = !rawAction && parsed.bools.has("vercel") ? "ls" : rawAction;
|
|
470
|
+
if (action === "ls" && !args.length && !Object.keys(parsed.opts).length
|
|
471
|
+
&& [...parsed.bools].every((name) => name === "vercel")) {
|
|
472
|
+
const showVercel = parsed.bools.has("vercel");
|
|
473
|
+
const [{ env }, detail] = await Promise.all([listWorkspaceEnv(), showVercel ? getWorkspace() : null]);
|
|
429
474
|
if (!env.length)
|
|
430
475
|
return console.log(c.dim("No workspace environment variables."));
|
|
431
|
-
|
|
476
|
+
if (!showVercel)
|
|
477
|
+
console.log(table(["NAME", "UPDATED"], env.map((row) => [row.name, row.updated_at])));
|
|
478
|
+
else {
|
|
479
|
+
const production = new Set(detail?.workspace.settings?.vercel?.production_env ?? []);
|
|
480
|
+
console.log(table(["NAME", "UPDATED", "VERCEL"], env.map((row) => [
|
|
481
|
+
row.name, row.updated_at, production.has(row.name) ? "yes" : "no",
|
|
482
|
+
])));
|
|
483
|
+
}
|
|
432
484
|
return;
|
|
433
485
|
}
|
|
434
486
|
if (action === "set" && args.length) {
|
|
@@ -471,11 +523,15 @@ export async function cmdEnv(argv, deps = {}) {
|
|
|
471
523
|
console.log(`synced removal of ${name} to ${repo}`);
|
|
472
524
|
return;
|
|
473
525
|
}
|
|
474
|
-
fail("usage: hd env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME");
|
|
526
|
+
fail("usage: hd env ls [--vercel] | set NAME=VALUE [NAME=VALUE...] | rm NAME");
|
|
475
527
|
}
|
|
476
|
-
async function cmdHost(argv) {
|
|
528
|
+
async function cmdHost(argv, deps = {}) {
|
|
477
529
|
const [scope, action, ...args] = argv;
|
|
478
|
-
|
|
530
|
+
if (scope === "roll") {
|
|
531
|
+
await hostRoll(parseHostRollFlags([action, ...args].filter((value) => Boolean(value))), deps);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const usage = "usage: hd host roll [--host HOST] [--address ADDR] [--checkout DIR] | env ls | set NAME=VALUE [NAME=VALUE...] | rm NAME";
|
|
479
535
|
if (scope !== "env")
|
|
480
536
|
fail(usage);
|
|
481
537
|
const host = (await getStatus()).workspace.default_host;
|
|
@@ -536,6 +592,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
536
592
|
await cmdEpic(rest);
|
|
537
593
|
return;
|
|
538
594
|
}
|
|
595
|
+
if (cmd === "roadmap") {
|
|
596
|
+
await cmdRoadmap(rest);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
539
599
|
if (cmd === "plan") {
|
|
540
600
|
await cmdPlan(rest);
|
|
541
601
|
return;
|
|
@@ -573,7 +633,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
573
633
|
return;
|
|
574
634
|
}
|
|
575
635
|
if (cmd === "host") {
|
|
576
|
-
await cmdHost(rest);
|
|
636
|
+
await cmdHost(rest, deps);
|
|
577
637
|
return;
|
|
578
638
|
}
|
|
579
639
|
if (cmd === "pause" || cmd === "off") {
|
package/dist/out.js
CHANGED
|
@@ -66,12 +66,13 @@ export function usage() {
|
|
|
66
66
|
` ${c.blue("hd status")} workspace overview`,
|
|
67
67
|
` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
|
|
68
68
|
` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
|
|
69
|
+
` ${c.blue("hd roadmap [--json]")} ordered workspace roadmap`,
|
|
69
70
|
` ${c.blue("hd plan")} use /architect in the TUI`,
|
|
70
71
|
` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
|
|
71
72
|
` ${c.blue("hd agents [add | rm | set]")} manage agents`,
|
|
72
73
|
` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
|
|
73
|
-
` ${c.blue("hd env ls | set | rm")}
|
|
74
|
-
` ${c.blue("hd host env ls | set | rm")}
|
|
74
|
+
` ${c.blue("hd env ls [--vercel] | set | rm")} workspace environment`,
|
|
75
|
+
` ${c.blue("hd host roll | env ls | set | rm")} roll a host or edit host env`,
|
|
75
76
|
` ${c.blue("hd logs KEY [-f]")} run events`,
|
|
76
77
|
` ${c.blue("hd msg KEY TEXT")} message a builder`,
|
|
77
78
|
` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
|
package/dist/roadmap.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function currentRoadmapEpic(epics) {
|
|
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;
|
|
5
|
+
}
|
|
6
|
+
export function progressBar(merged, total, width = 10) {
|
|
7
|
+
const complete = total > 0 ? Math.round((Math.max(0, Math.min(merged, total)) / total) * width) : 0;
|
|
8
|
+
return `${"█".repeat(complete)}${"░".repeat(Math.max(0, width - complete))}`;
|
|
9
|
+
}
|
|
10
|
+
export function roadmapText(epics) {
|
|
11
|
+
const names = new Map(epics.map((epic) => [epic.id, epic.title]));
|
|
12
|
+
const current = currentRoadmapEpic(epics);
|
|
13
|
+
return [...epics].sort((a, b) => a.position - b.position).flatMap((epic) => {
|
|
14
|
+
const dependencies = epic.depends_on.map((id) => names.get(id) ?? id);
|
|
15
|
+
const status = epic.status === "draft" ? "draft, awaiting approval" : epic.status;
|
|
16
|
+
return [
|
|
17
|
+
`${epic.id === current?.id ? "▶" : " "} ${epic.position}. ${epic.title} [${status}]`,
|
|
18
|
+
` Outcome: ${epic.outcome_md || "Not described"}`,
|
|
19
|
+
` Depends on: ${dependencies.length ? dependencies.join(", ") : "none"}`,
|
|
20
|
+
` ${progressBar(epic.merged, epic.total)} ${epic.merged}/${epic.total} merged`,
|
|
21
|
+
];
|
|
22
|
+
});
|
|
23
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -9,20 +9,23 @@ import { agentAdd } from "../agent-commands.js";
|
|
|
9
9
|
import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
|
|
10
10
|
import { Banner } from "./Banner.js";
|
|
11
11
|
import { Bubble } from "./Bubble.js";
|
|
12
|
+
import { ChatPanel } from "./Chat.js";
|
|
13
|
+
import { chatAgentLabel, chatScrollOffset, chatViewLines, emptyThread, followShouldStop, leaveChat, mergeHydratedThread, pendingActivity, rehydrateThread, threadKey, } from "./chat-view.js";
|
|
12
14
|
import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
|
|
13
15
|
import { DecisionPanel, decisionRows } from "./Decision.js";
|
|
14
16
|
import { COMMANDS, Help } from "./Help.js";
|
|
15
17
|
import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
|
|
16
18
|
import { sectionAt, ticketScrollOffset, ticketViewLines, toggleSection } from "./ticket-view.js";
|
|
17
19
|
import { SettingsPanel } from "./Settings.js";
|
|
20
|
+
import { RoadmapPanel, roadmapLines } from "./Roadmap.js";
|
|
18
21
|
import { Splash } from "./Splash.js";
|
|
19
22
|
import TextInput from "./TextInput.js";
|
|
20
23
|
import { alertOnce } from "./alert.js";
|
|
21
24
|
import { bubbleRows } from "./height.js";
|
|
22
25
|
import { planLayout, splitPanels } from "./layout.js";
|
|
23
26
|
import { parseLine } from "./parse.js";
|
|
24
|
-
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace,
|
|
25
|
-
import { inputActive,
|
|
27
|
+
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, 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 { inputActive, NO_REPLY_NOTE, promptPlaceholder, REPLY_WAIT_MS } from "./chat-wait.js";
|
|
26
29
|
import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
|
|
27
30
|
import { EARLIER_PAGE } from "./inbox.js";
|
|
28
31
|
import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
|
|
@@ -46,11 +49,15 @@ export function App({ initial }) {
|
|
|
46
49
|
const [view, setView] = useState("home");
|
|
47
50
|
const [live, setLive] = useState("connecting");
|
|
48
51
|
const [messages, setMessages] = useState([]);
|
|
52
|
+
const [threads, setThreads] = useState({});
|
|
53
|
+
const [chatOffset, setChatOffset] = useState(0);
|
|
54
|
+
const [now, setNow] = useState(Date.now());
|
|
49
55
|
const [draft, setDraft] = useState("");
|
|
50
56
|
const [busy, setBusy] = useState(false);
|
|
51
57
|
const [notice, setNotice] = useState(null);
|
|
52
58
|
const [ticketKey, setTicketKey] = useState(null);
|
|
53
59
|
const [ticketOffset, setTicketOffset] = useState(0);
|
|
60
|
+
const [roadmapOffset, setRoadmapOffset] = useState(0);
|
|
54
61
|
const [ticketCollapsed, setTicketCollapsed] = useState([]);
|
|
55
62
|
const [ready, setReady] = useState(false);
|
|
56
63
|
const [stream, setStream] = useState([]);
|
|
@@ -97,6 +104,12 @@ export function App({ initial }) {
|
|
|
97
104
|
if (live.cockpit > 0)
|
|
98
105
|
setStarted(true);
|
|
99
106
|
}, [ready, rows, columns, width, board.decisions]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (view !== "chat")
|
|
109
|
+
return;
|
|
110
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
|
111
|
+
return () => clearInterval(timer);
|
|
112
|
+
}, [view]);
|
|
100
113
|
const applySnapshot = useCallback((snapshot) => {
|
|
101
114
|
const token = loads.current.start(snapshot.workspace.id);
|
|
102
115
|
if (!loads.current.isCurrent(token))
|
|
@@ -302,27 +315,95 @@ export function App({ initial }) {
|
|
|
302
315
|
setBusy(false);
|
|
303
316
|
}
|
|
304
317
|
}, [config, say]);
|
|
318
|
+
const agentName = useCallback((role) => chatAgentLabel(role, board.agents.find((agent) => agent.role === role)?.display_name), [board.agents]);
|
|
319
|
+
const setThread = useCallback((key, update) => {
|
|
320
|
+
setThreads((prior) => {
|
|
321
|
+
const current = prior[key] ?? emptyThread(key.split(":")[1], key.split(":")[0] ?? workspace.id);
|
|
322
|
+
return { ...prior, [key]: update(current) };
|
|
323
|
+
});
|
|
324
|
+
}, [workspace.id]);
|
|
325
|
+
const hydrateChat = useCallback(async (role) => {
|
|
326
|
+
const key = threadKey(workspace.id, role);
|
|
327
|
+
try {
|
|
328
|
+
const remote = rehydrateThread(await loadChatMessages(config, role), role, workspace.id);
|
|
329
|
+
setThreads((prior) => ({
|
|
330
|
+
...prior,
|
|
331
|
+
[key]: mergeHydratedThread(prior[key] ?? null, remote),
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
336
|
+
}
|
|
337
|
+
}, [config, workspace.id]);
|
|
338
|
+
const openAgentChat = useCallback((role) => {
|
|
339
|
+
setMode(role);
|
|
340
|
+
setView("chat");
|
|
341
|
+
setCursor(null);
|
|
342
|
+
selectedRef.current = null;
|
|
343
|
+
setChatOffset(10_000);
|
|
344
|
+
void hydrateChat(role);
|
|
345
|
+
}, [hydrateChat]);
|
|
305
346
|
const askAgent = useCallback((role, text) => {
|
|
306
|
-
const
|
|
307
|
-
setMessages((prior) => [...prior, { id, speaker: role, body: "", pending: true }]);
|
|
347
|
+
const key = threadKey(workspace.id, role);
|
|
308
348
|
const since = new Date().toISOString();
|
|
349
|
+
const youTurn = { id: nextId(), speaker: "you", body: text, at: since };
|
|
350
|
+
setMode(role);
|
|
351
|
+
setView("chat");
|
|
352
|
+
setChatOffset(10_000);
|
|
353
|
+
setThread(key, (current) => ({
|
|
354
|
+
...current,
|
|
355
|
+
role,
|
|
356
|
+
workspaceId: workspace.id,
|
|
357
|
+
turns: [...current.turns, youTurn],
|
|
358
|
+
pending: { messageId: null, runId: null, startedAt: since, status: "queued", activity: [] },
|
|
359
|
+
}));
|
|
309
360
|
void (async () => {
|
|
310
361
|
try {
|
|
311
|
-
await postAgentMessage(role, text, config);
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
362
|
+
const posted = await postAgentMessage(role, text, config);
|
|
363
|
+
const deadline = Date.now() + REPLY_WAIT_MS;
|
|
364
|
+
while (Date.now() <= deadline) {
|
|
365
|
+
const follow = await followChat(config, role, posted.id, since);
|
|
366
|
+
const activity = pendingActivity(follow.events, follow.run, agentName(role));
|
|
367
|
+
setThread(key, (current) => ({
|
|
368
|
+
...current,
|
|
369
|
+
pending: {
|
|
370
|
+
messageId: posted.id,
|
|
371
|
+
runId: follow.run?.id ?? null,
|
|
372
|
+
startedAt: follow.run?.started_at ?? current.pending?.startedAt ?? since,
|
|
373
|
+
status: follow.run?.status ?? "queued",
|
|
374
|
+
activity: activity.lines,
|
|
375
|
+
},
|
|
376
|
+
}));
|
|
377
|
+
if (followShouldStop(follow.run, follow.reply)) {
|
|
378
|
+
const body = follow.reply ?? follow.run?.summary ?? NO_REPLY_NOTE;
|
|
379
|
+
setThread(key, (current) => ({
|
|
380
|
+
...current,
|
|
381
|
+
turns: [...current.turns, { id: `reply-${posted.id}`, speaker: role, body, at: new Date().toISOString() }],
|
|
382
|
+
pending: null,
|
|
383
|
+
}));
|
|
384
|
+
setChatOffset(10_000);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
388
|
+
}
|
|
389
|
+
setThread(key, (current) => ({
|
|
390
|
+
...current,
|
|
391
|
+
turns: [...current.turns, {
|
|
392
|
+
id: `timeout-${posted.id}`, speaker: role, body: NO_REPLY_NOTE, at: new Date().toISOString(),
|
|
393
|
+
}],
|
|
394
|
+
pending: null,
|
|
395
|
+
}));
|
|
319
396
|
}
|
|
320
397
|
catch (error) {
|
|
321
398
|
const body = error instanceof Error ? error.message : String(error);
|
|
322
|
-
|
|
399
|
+
setThread(key, (current) => ({
|
|
400
|
+
...current,
|
|
401
|
+
turns: [...current.turns, { id: nextId(), speaker: role, body, at: new Date().toISOString() }],
|
|
402
|
+
pending: null,
|
|
403
|
+
}));
|
|
323
404
|
}
|
|
324
405
|
})();
|
|
325
|
-
}, [config]);
|
|
406
|
+
}, [agentName, config, setThread, workspace.id]);
|
|
326
407
|
const openTicket = useCallback(async (key) => {
|
|
327
408
|
setTicketKey(key);
|
|
328
409
|
setView("ticket");
|
|
@@ -415,7 +496,6 @@ export function App({ initial }) {
|
|
|
415
496
|
setNotice(null);
|
|
416
497
|
const action = parseLine(text);
|
|
417
498
|
if (action.kind === "say") {
|
|
418
|
-
say("you", text);
|
|
419
499
|
if (mode !== "browse")
|
|
420
500
|
askAgent(mode, text);
|
|
421
501
|
else
|
|
@@ -424,15 +504,12 @@ export function App({ initial }) {
|
|
|
424
504
|
}
|
|
425
505
|
switch (action.kind) {
|
|
426
506
|
case "mode":
|
|
427
|
-
|
|
428
|
-
setCursor(null);
|
|
429
|
-
selectedRef.current = null;
|
|
430
|
-
say("system", action.mode === "architect"
|
|
431
|
-
? "Talking to the architect. Ask questions, refine the epic, then explicitly request a draft."
|
|
432
|
-
: "Talking to the orchestrator. It moves work already in flight.");
|
|
507
|
+
openAgentChat(action.mode);
|
|
433
508
|
return;
|
|
434
509
|
case "view":
|
|
435
510
|
setView(action.view);
|
|
511
|
+
if (action.view === "roadmap")
|
|
512
|
+
setRoadmapOffset(0);
|
|
436
513
|
if (action.view === "inbox") {
|
|
437
514
|
const header = selectedDecisionId ? decisionHeaderIndex(inbox, selectedDecisionId) : 0;
|
|
438
515
|
setInboxFocus(header >= 0 ? header : 0);
|
|
@@ -736,9 +813,9 @@ export function App({ initial }) {
|
|
|
736
813
|
default:
|
|
737
814
|
return;
|
|
738
815
|
}
|
|
739
|
-
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder,
|
|
740
|
-
config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
741
|
-
submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
816
|
+
}, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
|
|
817
|
+
changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
|
|
818
|
+
selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
|
|
742
819
|
useInput((input, key) => {
|
|
743
820
|
if (key.ctrl && input === "c")
|
|
744
821
|
exit();
|
|
@@ -758,14 +835,21 @@ export function App({ initial }) {
|
|
|
758
835
|
const settled = messages.filter((message) => message.done);
|
|
759
836
|
const inFlight = messages.filter((message) => !message.done);
|
|
760
837
|
const splash = !started;
|
|
838
|
+
const chatRole = view === "chat" && mode !== "browse" ? mode : null;
|
|
839
|
+
const activeThread = chatRole
|
|
840
|
+
? threads[threadKey(workspace.id, chatRole)] ?? emptyThread(chatRole, workspace.id)
|
|
841
|
+
: null;
|
|
842
|
+
const chatLabel = chatRole ? agentName(chatRole) : "";
|
|
843
|
+
const chatLines = activeThread ? chatViewLines(activeThread, Math.max(20, width), chatLabel, now) : [];
|
|
761
844
|
const scrollback = splash ? [] : [
|
|
762
845
|
{ key: "banner" },
|
|
763
846
|
{ key: "help", message: { id: "help", speaker: "system", body: "", panel: "help" } },
|
|
764
847
|
...settled.map((message) => ({ key: message.id, message })),
|
|
765
848
|
];
|
|
849
|
+
const chatting = view === "chat";
|
|
766
850
|
const plan = planLayout({
|
|
767
|
-
rows, columns, width, splash, ready, decision: decisionRows(decisions),
|
|
768
|
-
inFlight: inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
851
|
+
rows, columns, width, splash, ready, decision: chatting ? 0 : decisionRows(decisions),
|
|
852
|
+
inFlight: chatting ? 0 : inFlight.reduce((total, message) => total + bubbleRows(message, width), 0),
|
|
769
853
|
notice: Boolean(notice), home: view === "home",
|
|
770
854
|
});
|
|
771
855
|
const agentsView = splitPanels(plan.panels);
|
|
@@ -777,15 +861,15 @@ export function App({ initial }) {
|
|
|
777
861
|
if (item.message.panel === "help")
|
|
778
862
|
return _jsx(Help, { width: width }, item.key);
|
|
779
863
|
return _jsx(Bubble, { message: item.message, width: width }, item.key);
|
|
780
|
-
} }), 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, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
|
|
864
|
+
} }), 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 === "roadmap" && plan.panels > 0 ? (_jsx(RoadmapPanel, { board: board, width: width, rows: plan.panels, offset: roadmapOffset })) : 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, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
|
|
781
865
|
? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
|
|
782
|
-
: _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,
|
|
866
|
+
: _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, chatting && plan.panels > 0 && activeThread ? (_jsx(ChatPanel, { thread: activeThread, width: width, rows: plan.panels, offset: chatOffset, label: chatLabel, now: now })) : null, chatting ? null : _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), 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, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", chatting ? chatLabel : mode, answering ? " esc cancels" : chatting ? " ↑↓ scroll · pgup/pgdn · esc hides" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "roadmap" ? " ↑↓ scroll · pgup/pgdn" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
|
|
783
867
|
setDraft(next);
|
|
784
868
|
if (editingRef.current)
|
|
785
869
|
setEditing({ key: editingRef.current.key, draft: next });
|
|
786
870
|
}, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
|
|
787
871
|
? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
|
|
788
|
-
: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
|
|
872
|
+
: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || chatting || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : chatting ? `${chatLabel}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
|
|
789
873
|
if (answering) {
|
|
790
874
|
setAnswering(null);
|
|
791
875
|
setDraft("");
|
|
@@ -798,6 +882,14 @@ export function App({ initial }) {
|
|
|
798
882
|
setNotice(null);
|
|
799
883
|
return;
|
|
800
884
|
}
|
|
885
|
+
if (chatting || mode !== "browse") {
|
|
886
|
+
const left = leaveChat();
|
|
887
|
+
setMode(left.mode);
|
|
888
|
+
setView(left.view);
|
|
889
|
+
setDraft("");
|
|
890
|
+
setNotice(left.notice);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
801
893
|
if (view !== "home") {
|
|
802
894
|
setView("home");
|
|
803
895
|
setTicketKey(null);
|
|
@@ -806,6 +898,16 @@ export function App({ initial }) {
|
|
|
806
898
|
setCursor(null);
|
|
807
899
|
selectedRef.current = null;
|
|
808
900
|
}, onUp: () => {
|
|
901
|
+
if (chatting && !draft) {
|
|
902
|
+
const overflow = chatLines.length > plan.panels;
|
|
903
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
904
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - 1));
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (view === "roadmap" && !draft) {
|
|
908
|
+
setRoadmapOffset((current) => Math.max(0, current - 1));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
809
911
|
if (view === "ticket" && !draft) {
|
|
810
912
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
811
913
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
|
|
@@ -824,6 +926,17 @@ export function App({ initial }) {
|
|
|
824
926
|
historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
|
|
825
927
|
setDraft(history.current[historyAt.current] ?? "");
|
|
826
928
|
}, onDown: () => {
|
|
929
|
+
if (chatting && !draft) {
|
|
930
|
+
const overflow = chatLines.length > plan.panels;
|
|
931
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
932
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + 1));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (view === "roadmap" && !draft) {
|
|
936
|
+
const max = Math.max(0, roadmapLines(board, width).length - Math.max(1, plan.panels - 1));
|
|
937
|
+
setRoadmapOffset((current) => Math.min(max, current + 1));
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
827
940
|
if (view === "ticket" && !draft) {
|
|
828
941
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
829
942
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
|
|
@@ -847,11 +960,33 @@ export function App({ initial }) {
|
|
|
847
960
|
}
|
|
848
961
|
setDraft(history.current[historyAt.current] ?? "");
|
|
849
962
|
}, onPageUp: () => {
|
|
963
|
+
if (chatting && !draft) {
|
|
964
|
+
const overflow = chatLines.length > plan.panels;
|
|
965
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
966
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current - Math.max(1, inner)));
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (view === "roadmap" && !draft) {
|
|
970
|
+
setRoadmapOffset((current) => Math.max(0, current - Math.max(1, plan.panels - 1)));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
850
973
|
if (view === "ticket" && !draft) {
|
|
851
974
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
852
975
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
|
|
853
976
|
}
|
|
854
977
|
}, onPageDown: () => {
|
|
978
|
+
if (chatting && !draft) {
|
|
979
|
+
const overflow = chatLines.length > plan.panels;
|
|
980
|
+
const inner = overflow ? Math.max(0, plan.panels - 1) : Math.max(1, plan.panels);
|
|
981
|
+
setChatOffset((current) => chatScrollOffset(chatLines.length, inner, current + Math.max(1, inner)));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (view === "roadmap" && !draft) {
|
|
985
|
+
const page = Math.max(1, plan.panels - 1);
|
|
986
|
+
const max = Math.max(0, roadmapLines(board, width).length - page);
|
|
987
|
+
setRoadmapOffset((current) => Math.min(max, current + page));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
855
990
|
if (view === "ticket" && !draft) {
|
|
856
991
|
const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
|
|
857
992
|
setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + Math.max(1, plan.panels)));
|
package/dist/tui/Chat.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { BoundedPanel as Panel } from "./bounded.js";
|
|
4
|
+
import { chatScrollOffset, chatViewLines, } from "./chat-view.js";
|
|
5
|
+
import { UI } from "./theme.js";
|
|
6
|
+
export function ChatPanel({ thread, width = 80, rows = 24, offset = 0, label, now = Date.now(), }) {
|
|
7
|
+
const entries = chatViewLines(thread, Math.max(20, width), label, now);
|
|
8
|
+
const drawable = Math.max(0, rows);
|
|
9
|
+
const overflow = entries.length > drawable;
|
|
10
|
+
const inner = overflow ? Math.max(0, drawable - 1) : drawable;
|
|
11
|
+
const start = chatScrollOffset(entries.length, inner, offset);
|
|
12
|
+
const shown = entries.slice(start, start + inner);
|
|
13
|
+
const hiddenAbove = start;
|
|
14
|
+
const hiddenBelow = Math.max(0, entries.length - start - shown.length);
|
|
15
|
+
return (_jsx(Panel, { width: width, rows: drawable, children: [
|
|
16
|
+
...(shown.length === 0
|
|
17
|
+
? [
|
|
18
|
+
_jsx(Text, { color: UI.dim, children: `No messages yet. Say something to ${label}.` }, "empty"),
|
|
19
|
+
]
|
|
20
|
+
: shown.map((entry) => (_jsx(Text, { color: entry.kind === "status" ? UI.accent : entry.kind === "activity" ? UI.dim
|
|
21
|
+
: entry.kind === "speaker" ? UI.warn : UI.text, wrap: "truncate", children: entry.text }, entry.key)))),
|
|
22
|
+
overflow ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [hiddenAbove ? `${hiddenAbove}↑ ` : "", hiddenBelow ? `${hiddenBelow}↓` : ""] }, "more")) : null,
|
|
23
|
+
] }));
|
|
24
|
+
}
|
|
25
|
+
/** Thread stacked above a prompt, for layout tests. */
|
|
26
|
+
export function ChatFrame({ thread, width = 80, rows = 12, offset = 0, label, prompt, now = Date.now(), }) {
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(ChatPanel, { thread: thread, width: width, rows: rows, offset: offset, label: label, now: now }), _jsx(Text, { children: prompt })] }));
|
|
28
|
+
}
|
package/dist/tui/Dashboard.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
import { agentDisplayRows } from "./agent-rows.js";
|
|
9
|
+
import { currentRoadmapEpic } from "../roadmap.js";
|
|
9
10
|
const DOT = "●";
|
|
10
11
|
/**
|
|
11
12
|
* The width at which the board and the agents stop competing for the same
|
|
@@ -117,10 +118,11 @@ export function AgentsColumn({ board, width, rows, }) {
|
|
|
117
118
|
]
|
|
118
119
|
: []),
|
|
119
120
|
...shown.map((row) => {
|
|
120
|
-
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
121
|
-
|
|
121
|
+
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
|
|
122
|
+
? "warning" : "muted";
|
|
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
|
|
122
124
|
? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
|
|
123
|
-
: ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` })] })] }, row.key));
|
|
125
|
+
: ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` }))] })] }, row.key));
|
|
124
126
|
}),
|
|
125
127
|
_jsx(More, { count: displayRows.length - shown.length }, "more"),
|
|
126
128
|
] }));
|
|
@@ -156,8 +158,12 @@ export function epicsRows(board, cap = 3) {
|
|
|
156
158
|
return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
|
|
157
159
|
}
|
|
158
160
|
export function nowEntries(board) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
const current = currentRoadmapEpic(board.epics ?? []);
|
|
162
|
+
return [
|
|
163
|
+
...(current ? [{ key: `epic:${current.id}`, headline: `Current epic: ${current.title}` }] : []),
|
|
164
|
+
...board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
|
|
165
|
+
.map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` })),
|
|
166
|
+
];
|
|
161
167
|
}
|
|
162
168
|
function NowStrip({ board, width, rows }) {
|
|
163
169
|
const entries = nowEntries(board);
|
package/dist/tui/Help.js
CHANGED
|
@@ -6,6 +6,7 @@ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are
|
|
|
6
6
|
/** Everything you can type. The app is driven from here, not from flags. */
|
|
7
7
|
export const COMMANDS = [
|
|
8
8
|
{ name: "/board", help: "the kanban board" },
|
|
9
|
+
{ name: "/roadmap", help: "the vision and ordered epic progress" },
|
|
9
10
|
{ name: "/inbox", args: "[more]", help: "unread, earlier, and numbered decisions; Enter answers" },
|
|
10
11
|
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open a full ticket view or create one" },
|
|
11
12
|
{ name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
|
|
@@ -14,7 +15,7 @@ export const COMMANDS = [
|
|
|
14
15
|
{ name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
|
|
15
16
|
{ name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
|
|
16
17
|
{ name: "/epics", help: "list epics and ticket progress" },
|
|
17
|
-
{ name: "/architect", help: "
|
|
18
|
+
{ name: "/architect", help: "open a chat with the architect" },
|
|
18
19
|
{ name: "/plan", help: "alias for /architect" },
|
|
19
20
|
{ name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
|
|
20
21
|
{ name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
|
|
@@ -24,7 +25,7 @@ export const COMMANDS = [
|
|
|
24
25
|
{ name: "/on", help: "turn on the current workspace" },
|
|
25
26
|
{ name: "/off", help: "turn off the current workspace" },
|
|
26
27
|
{ name: "/feed", help: "what just happened" },
|
|
27
|
-
{ name: "/orchestrator", help: "
|
|
28
|
+
{ name: "/orchestrator", help: "open a chat with the orchestrator" },
|
|
28
29
|
{ name: "/refresh", help: "reload the board now" },
|
|
29
30
|
{ name: "/help", help: "this list" },
|
|
30
31
|
{ name: "/exit", help: "leave" },
|
package/dist/tui/Panels.js
CHANGED
|
@@ -28,8 +28,10 @@ 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"
|
|
32
|
-
|
|
31
|
+
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
|
|
32
|
+
? "warning" : "muted";
|
|
33
|
+
const state = row.state === "draining" ? ""
|
|
34
|
+
: row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
|
|
33
35
|
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));
|
|
34
36
|
}),
|
|
35
37
|
_jsx(More, { count: displayRows.length - shown.length }, "more"),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { roadmapText } from "../roadmap.js";
|
|
4
|
+
import { inkColor } from "../out/theme.js";
|
|
5
|
+
import { epicProgress } from "./data.js";
|
|
6
|
+
import { BoundedPanel as Panel, Heading } from "./bounded.js";
|
|
7
|
+
import { wrapLines } from "./ticket-view.js";
|
|
8
|
+
import { UI } from "./theme.js";
|
|
9
|
+
export function roadmapLines(board, width) {
|
|
10
|
+
if (!board.roadmap)
|
|
11
|
+
return ["No roadmap yet. Talk to /architect to create one."];
|
|
12
|
+
const lines = ["Vision"];
|
|
13
|
+
lines.push(...wrapLines(board.roadmap.vision_md, Math.max(12, width - 2)));
|
|
14
|
+
lines.push("");
|
|
15
|
+
const progress = new Map(epicProgress(board).map((row) => [row.epic.id, row]));
|
|
16
|
+
lines.push(...roadmapText(board.epics.map((epic) => ({ ...epic,
|
|
17
|
+
merged: progress.get(epic.id)?.merged ?? 0,
|
|
18
|
+
total: progress.get(epic.id)?.total ?? 0,
|
|
19
|
+
}))));
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
22
|
+
export function RoadmapPanel({ board, width = 80, rows = 12, offset = 0 }) {
|
|
23
|
+
const lines = roadmapLines(board, width);
|
|
24
|
+
const budget = Math.max(0, rows - 1);
|
|
25
|
+
const start = Math.max(0, Math.min(offset, Math.max(0, lines.length - budget)));
|
|
26
|
+
const shown = lines.slice(start, start + budget);
|
|
27
|
+
const note = `${board.epics.length} epics${start ? ` · ${start}↑` : ""}`
|
|
28
|
+
+ `${start + budget < lines.length ? ` · ${lines.length - start - budget}↓` : ""}`;
|
|
29
|
+
return _jsx(Panel, { width: width, rows: rows, children: [
|
|
30
|
+
_jsx(Heading, { text: "Roadmap", note: note }, "h"),
|
|
31
|
+
...shown.map((line, index) => {
|
|
32
|
+
const current = line.startsWith("▶");
|
|
33
|
+
const draft = line.includes("draft, awaiting approval");
|
|
34
|
+
return _jsx(Text, { color: current ? UI.accent
|
|
35
|
+
: draft ? inkColor("warning") : line === "Vision" ? UI.text : UI.dim, bold: current || line === "Vision", inverse: current, wrap: "truncate", children: line || " " }, `${start + index}:${line}`);
|
|
36
|
+
}),
|
|
37
|
+
] });
|
|
38
|
+
}
|
package/dist/tui/agent-rows.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDrainStatus } from "../host.js";
|
|
1
2
|
const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
|
|
2
3
|
export function limitedUntilByProvider(tickets) {
|
|
3
4
|
const limited = new Map();
|
|
@@ -22,7 +23,18 @@ function agentForRun(board, run) {
|
|
|
22
23
|
return board.agents.find((agent) => agent.enabled && agent.role === role && agent.provider === run.provider) ?? null;
|
|
23
24
|
}
|
|
24
25
|
/** Expand live invocations into rows, then append enabled agents with no live run. */
|
|
25
|
-
export function agentDisplayRows(board) {
|
|
26
|
+
export function agentDisplayRows(board, now = Date.now()) {
|
|
27
|
+
const drain = board.hostDrain
|
|
28
|
+
? [{
|
|
29
|
+
key: "drain",
|
|
30
|
+
name: formatDrainStatus(board.hostDrain.live, board.hostDrain.until, now),
|
|
31
|
+
agent: null,
|
|
32
|
+
run: null,
|
|
33
|
+
ticket: null,
|
|
34
|
+
state: "draining",
|
|
35
|
+
limitedUntil: null,
|
|
36
|
+
}]
|
|
37
|
+
: [];
|
|
26
38
|
const activeAgents = new Set();
|
|
27
39
|
const live = board.runs
|
|
28
40
|
.filter((run) => run.status === "running" || run.status === "queued")
|
|
@@ -71,5 +83,5 @@ export function agentDisplayRows(board) {
|
|
|
71
83
|
limitedUntil: until ?? null,
|
|
72
84
|
};
|
|
73
85
|
});
|
|
74
|
-
return [...live, ...idle];
|
|
86
|
+
return [...drain, ...live, ...idle];
|
|
75
87
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { narrateEvents } from "./narrate.js";
|
|
2
|
+
import { wrapLines } from "./ticket-view.js";
|
|
3
|
+
/** Last 50 exchanges (a human turn and an agent turn) when rehydrating. */
|
|
4
|
+
export const CHAT_EXCHANGE_LIMIT = 50;
|
|
5
|
+
export const CHAT_MESSAGE_LIMIT = CHAT_EXCHANGE_LIMIT * 2;
|
|
6
|
+
const HUMAN_ROLES = new Set(["human", "operator"]);
|
|
7
|
+
const HUMAN_TARGETS = new Set(["human", "operator", "all"]);
|
|
8
|
+
export function threadKey(workspaceId, role) {
|
|
9
|
+
return `${workspaceId}:${role}`;
|
|
10
|
+
}
|
|
11
|
+
export function emptyThread(role, workspaceId) {
|
|
12
|
+
return { role, workspaceId, turns: [], pending: null };
|
|
13
|
+
}
|
|
14
|
+
export function chatAgentLabel(role, displayName) {
|
|
15
|
+
const named = displayName?.trim();
|
|
16
|
+
if (named)
|
|
17
|
+
return named;
|
|
18
|
+
return role === "architect" ? "Architect" : "Orchestrator";
|
|
19
|
+
}
|
|
20
|
+
export function formatWorkingElapsed(ms) {
|
|
21
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
22
|
+
if (seconds < 60)
|
|
23
|
+
return `${seconds}s`;
|
|
24
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
25
|
+
}
|
|
26
|
+
export function workingStatus(label, startedAt, now = Date.now()) {
|
|
27
|
+
const start = startedAt ? Date.parse(startedAt) : Number.NaN;
|
|
28
|
+
const elapsed = Number.isFinite(start) ? Math.max(0, now - start) : 0;
|
|
29
|
+
return `${label} is working (${formatWorkingElapsed(elapsed)})`;
|
|
30
|
+
}
|
|
31
|
+
export function isChatConversation(message, role) {
|
|
32
|
+
if (message.ticket_id)
|
|
33
|
+
return false;
|
|
34
|
+
const fromAgent = message.from_role === role && HUMAN_TARGETS.has(message.to_role);
|
|
35
|
+
const toAgent = HUMAN_ROLES.has(message.from_role) && message.to_role === role;
|
|
36
|
+
return fromAgent || toAgent;
|
|
37
|
+
}
|
|
38
|
+
export function rehydrateThread(messages, role, workspaceId) {
|
|
39
|
+
const conversation = messages
|
|
40
|
+
.filter((message) => isChatConversation(message, role))
|
|
41
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))
|
|
42
|
+
.slice(-CHAT_MESSAGE_LIMIT);
|
|
43
|
+
return {
|
|
44
|
+
role,
|
|
45
|
+
workspaceId,
|
|
46
|
+
turns: conversation.map((message) => ({
|
|
47
|
+
id: message.id,
|
|
48
|
+
speaker: message.from_role === role ? role : "you",
|
|
49
|
+
body: message.body_md,
|
|
50
|
+
at: message.created_at,
|
|
51
|
+
})),
|
|
52
|
+
pending: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function restoreThread(threads, workspaceId, role) {
|
|
56
|
+
return threads[threadKey(workspaceId, role)] ?? null;
|
|
57
|
+
}
|
|
58
|
+
export function leaveChat() {
|
|
59
|
+
return { view: "home", mode: "browse", notice: "Back to browse." };
|
|
60
|
+
}
|
|
61
|
+
export function openChat(role) {
|
|
62
|
+
return { view: "chat", mode: role };
|
|
63
|
+
}
|
|
64
|
+
export function chatScrollOffset(count, rows, offset) {
|
|
65
|
+
if (count <= rows)
|
|
66
|
+
return 0;
|
|
67
|
+
return Math.max(0, Math.min(offset, count - rows));
|
|
68
|
+
}
|
|
69
|
+
export function chatWindow(count, rows, offset) {
|
|
70
|
+
const start = chatScrollOffset(count, rows, offset);
|
|
71
|
+
return { start, end: Math.min(count, start + Math.max(0, rows)) };
|
|
72
|
+
}
|
|
73
|
+
export function pendingActivity(events, run, label, now = Date.now()) {
|
|
74
|
+
const live = !run || ["queued", "running", ""].includes(run.status ?? "running");
|
|
75
|
+
return {
|
|
76
|
+
status: live ? workingStatus(label, run?.started_at ?? null, now) : "",
|
|
77
|
+
lines: narrateEvents(events).map((line) => line.title),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function followShouldStop(run, reply) {
|
|
81
|
+
if (reply)
|
|
82
|
+
return true;
|
|
83
|
+
if (run && !["queued", "running"].includes(run.status))
|
|
84
|
+
return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
export function chatViewLines(thread, width, label, now = Date.now()) {
|
|
88
|
+
const lines = [];
|
|
89
|
+
const bodyWidth = Math.max(8, width);
|
|
90
|
+
for (const turn of thread.turns) {
|
|
91
|
+
const speaker = turn.speaker === "you" ? "you" : label;
|
|
92
|
+
lines.push({ key: `${turn.id}:who`, kind: "speaker", text: speaker });
|
|
93
|
+
const body = turn.body.trim() || (turn.pending ? "" : "");
|
|
94
|
+
if (body) {
|
|
95
|
+
wrapLines(body, bodyWidth).forEach((text, index) => {
|
|
96
|
+
lines.push({ key: `${turn.id}:body:${index}`, kind: "body", text });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (thread.pending) {
|
|
101
|
+
const live = pendingActivity([], { started_at: thread.pending.startedAt, status: thread.pending.status }, label, now);
|
|
102
|
+
const status = thread.pending.status && !["queued", "running", ""].includes(thread.pending.status)
|
|
103
|
+
? ""
|
|
104
|
+
: live.status;
|
|
105
|
+
if (status)
|
|
106
|
+
lines.push({ key: "pending:status", kind: "status", text: status });
|
|
107
|
+
for (const [index, title] of thread.pending.activity.entries()) {
|
|
108
|
+
wrapLines(title, bodyWidth).forEach((text, line) => {
|
|
109
|
+
lines.push({ key: `pending:activity:${index}:${line}`, kind: "activity", text });
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
export function mergeHydratedThread(local, remote) {
|
|
116
|
+
if (!local)
|
|
117
|
+
return remote;
|
|
118
|
+
const known = new Set(remote.turns.map((turn) => turn.id));
|
|
119
|
+
const extras = local.turns.filter((turn) => !known.has(turn.id));
|
|
120
|
+
return {
|
|
121
|
+
...remote,
|
|
122
|
+
turns: [...remote.turns, ...extras],
|
|
123
|
+
pending: local.pending,
|
|
124
|
+
};
|
|
125
|
+
}
|
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, getWorkspace, listAgents,
|
|
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, 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";
|
|
@@ -31,12 +31,12 @@ function withTicketKeys(messages, tickets) {
|
|
|
31
31
|
}
|
|
32
32
|
export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
33
33
|
const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
|
|
34
|
-
const [status, workspaceData, ticketData, agentData,
|
|
34
|
+
const [status, workspaceData, ticketData, agentData, roadmapData, feedData, unreadData, earlierData] = await Promise.all([
|
|
35
35
|
getStatus(config),
|
|
36
36
|
getWorkspace(config),
|
|
37
37
|
listTickets(config),
|
|
38
38
|
listAgents(config),
|
|
39
|
-
|
|
39
|
+
getRoadmap(config),
|
|
40
40
|
listFeed(config),
|
|
41
41
|
listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
|
|
42
42
|
listMessages({
|
|
@@ -73,13 +73,15 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
|
73
73
|
board: {
|
|
74
74
|
tickets,
|
|
75
75
|
agents: agentData.agents,
|
|
76
|
-
epics:
|
|
76
|
+
epics: roadmapData.epics,
|
|
77
|
+
roadmap: roadmapData.roadmap,
|
|
77
78
|
decisions: status.decisions,
|
|
78
79
|
messages: withTicketKeys(unreadData.messages, tickets),
|
|
79
80
|
earlier: earlier.slice(0, earlierLimit),
|
|
80
81
|
earlierHasMore: earlier.length > earlierLimit,
|
|
81
82
|
availability,
|
|
82
83
|
runs: status.recent_runs ?? status.live_runs,
|
|
84
|
+
hostDrain: status.host?.draining ?? null,
|
|
83
85
|
},
|
|
84
86
|
feed: feedData.entries,
|
|
85
87
|
};
|
|
@@ -122,7 +124,37 @@ export async function switchWorkspace(slug, config = loadConfig()) {
|
|
|
122
124
|
return snapshot;
|
|
123
125
|
}
|
|
124
126
|
export async function postAgentMessage(role, body, config) {
|
|
125
|
-
await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
127
|
+
const { message } = await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
128
|
+
return message;
|
|
129
|
+
}
|
|
130
|
+
export async function loadChatMessages(config, role) {
|
|
131
|
+
const { messages } = await listMessages({
|
|
132
|
+
unticketed: true,
|
|
133
|
+
fromRoles: ["human", "operator", role],
|
|
134
|
+
limit: 200,
|
|
135
|
+
order: "desc",
|
|
136
|
+
}, config);
|
|
137
|
+
return messages.slice().reverse();
|
|
138
|
+
}
|
|
139
|
+
export async function followChat(config, role, messageId, since) {
|
|
140
|
+
const kind = role === "architect" ? "architect" : "orchestrate";
|
|
141
|
+
const status = await getStatus(config);
|
|
142
|
+
const runs = [...(status.live_runs ?? []), ...(status.recent_runs ?? [])];
|
|
143
|
+
const tagged = runs.find((run) => run.message_id === messageId);
|
|
144
|
+
const live = runs.find((run) => run.kind === kind && ["queued", "running"].includes(run.status));
|
|
145
|
+
const match = tagged ?? live ?? null;
|
|
146
|
+
let run = null;
|
|
147
|
+
let events = [];
|
|
148
|
+
if (match) {
|
|
149
|
+
const detail = await getRun(match.id, config);
|
|
150
|
+
run = detail.run;
|
|
151
|
+
events = detail.events;
|
|
152
|
+
}
|
|
153
|
+
const { messages } = await listMessages({ since, limit: 50 }, config);
|
|
154
|
+
const reply = messages.find((message) => message.from_role === role
|
|
155
|
+
&& ["human", "operator", "all"].includes(message.to_role)
|
|
156
|
+
&& (!run || !message.run_id || message.run_id === run.id));
|
|
157
|
+
return { run, events, reply: reply?.body_md ?? null };
|
|
126
158
|
}
|
|
127
159
|
export async function loadTicketDetail(config, key) {
|
|
128
160
|
const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
|
package/dist/tui/parse.js
CHANGED
|
@@ -18,6 +18,7 @@ export function parseLine(raw) {
|
|
|
18
18
|
return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
|
|
19
19
|
: { kind: "mode", mode: "architect" };
|
|
20
20
|
case "board":
|
|
21
|
+
case "roadmap":
|
|
21
22
|
case "feed":
|
|
22
23
|
case "settings":
|
|
23
24
|
return { kind: "view", view: word.toLowerCase() };
|