@higherdev/cli 0.15.3 → 0.16.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 +5 -1
- package/dist/agent-commands.js +80 -0
- package/dist/api.js +6 -0
- package/dist/index.js +61 -9
- package/dist/out.js +1 -0
- package/dist/tui/App.js +27 -4
- package/dist/tui/Help.js +2 -2
- package/dist/tui/Panels.js +37 -15
- package/dist/tui/agent-rows.js +1 -1
- package/dist/tui/data.js +23 -10
- package/dist/tui/inbox.js +29 -0
- package/dist/tui/parse.js +8 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ workspace-map config shapes are migrated automatically when they are read.
|
|
|
54
54
|
| `hd host env rm NAME` | Remove a host-only environment variable |
|
|
55
55
|
| `hd logs KEY [-f]` | Show or follow run events |
|
|
56
56
|
| `hd msg KEY "message" [--interrupt]` | Message a builder |
|
|
57
|
+
| `hd inbox [--all] [--limit N] [--json]` | List unread inbox messages, or earlier history with `--all` |
|
|
57
58
|
| `hd decide` | List open decisions |
|
|
58
59
|
| `hd decide ID --answer TEXT` | Answer a decision |
|
|
59
60
|
| `hd on` / `hd off` | Turn the workspace on or off |
|
|
@@ -91,8 +92,11 @@ to `gh` through stdin and are never printed.
|
|
|
91
92
|
|
|
92
93
|
Use `hd host env set SUPABASE_ACCESS_TOKEN=... SUPABASE_ORG_ID=...` once to let the runner provision
|
|
93
94
|
Supabase for workspaces assigned to that host. Host values are never passed to builder processes.
|
|
95
|
+
Use `hd host env set VERCEL_TOKEN=... VERCEL_TEAM_ID=...` to let the runner adopt or provision a linked
|
|
96
|
+
Vercel project. It syncs `NEXT_PUBLIC_*`, names listed in `settings.vercel.env`, and existing Vercel-held names
|
|
97
|
+
to production and preview. Later `hd env set` changes wake the orchestrator to update matching Vercel variables.
|
|
94
98
|
|
|
95
|
-
Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
|
|
99
|
+
Inside the TUI, use `/board`, `/inbox`, `/inbox more`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
|
|
96
100
|
`/epic approve`, `/epic rm`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
|
|
97
101
|
`/workspace new`, `/workspace set`, `/workspace rotate-key`, `/workspace grant-runner-access`, `/feed`,
|
|
98
102
|
`/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createAgent, getStatus, listAgents } from "./api.js";
|
|
2
|
+
import { promptOnStdin } from "./prompt.js";
|
|
3
|
+
export const AGENT_ADD_USAGE = "usage: hd agents add [ROLE] --name NAME --provider P --model M --effort E";
|
|
4
|
+
const EFFORTS = ["low", "medium", "high"];
|
|
5
|
+
const ROLES = ["architect", "orchestrator", "reviewer", "builder"];
|
|
6
|
+
function parse(argv) {
|
|
7
|
+
const opts = {};
|
|
8
|
+
const rest = [];
|
|
9
|
+
const missingValues = [];
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const arg = argv[index];
|
|
12
|
+
if (!arg.startsWith("--")) {
|
|
13
|
+
rest.push(arg);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const name = arg.slice(2);
|
|
17
|
+
const value = argv[index + 1];
|
|
18
|
+
if (!value || value.startsWith("--")) {
|
|
19
|
+
missingValues.push(name);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
opts[name] = value;
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
return { opts, rest, missingValues };
|
|
26
|
+
}
|
|
27
|
+
export function nextBuilderName(agents) {
|
|
28
|
+
return `Builder ${agents.filter((agent) => agent.role === "builder").length + 1}`;
|
|
29
|
+
}
|
|
30
|
+
export function availableProvider(capabilities, requested) {
|
|
31
|
+
const capability = capabilities.find((item) => item.provider === requested);
|
|
32
|
+
if (!capability?.available)
|
|
33
|
+
throw new Error(capability?.reason ?? `Unsupported provider ${requested}.`);
|
|
34
|
+
return capability;
|
|
35
|
+
}
|
|
36
|
+
function choose(answer, choices) {
|
|
37
|
+
if (!answer)
|
|
38
|
+
return choices[0];
|
|
39
|
+
return /^\d+$/.test(answer) ? choices[Number(answer) - 1] : answer;
|
|
40
|
+
}
|
|
41
|
+
export async function agentAdd(argv, deps = {}) {
|
|
42
|
+
const { opts, rest, missingValues } = parse(argv);
|
|
43
|
+
if (missingValues.length || rest.length > 1 || Object.keys(opts).some((key) => !["name", "provider", "model", "effort"].includes(key)))
|
|
44
|
+
throw new Error(AGENT_ADD_USAGE);
|
|
45
|
+
const role = rest[0] ?? "builder";
|
|
46
|
+
if (!ROLES.includes(role))
|
|
47
|
+
throw new Error(AGENT_ADD_USAGE);
|
|
48
|
+
const interactive = deps.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
49
|
+
const needsForm = ["name", "provider", "model", "effort"].some((field) => !opts[field]);
|
|
50
|
+
if (needsForm && !interactive)
|
|
51
|
+
throw new Error(AGENT_ADD_USAGE);
|
|
52
|
+
const [{ agents }, status] = await Promise.all([
|
|
53
|
+
(deps.listAgents ?? listAgents)(deps.config), (deps.getStatus ?? getStatus)(deps.config),
|
|
54
|
+
]);
|
|
55
|
+
const prompt = deps.prompt ?? promptOnStdin;
|
|
56
|
+
const defaultName = nextBuilderName(agents);
|
|
57
|
+
const name = opts.name?.trim() || (await prompt(`Name [${defaultName}]: `)).trim() || defaultName;
|
|
58
|
+
const capabilities = status.workspace.provider_capabilities ?? [];
|
|
59
|
+
if (opts.provider?.trim())
|
|
60
|
+
availableProvider(capabilities, opts.provider.trim());
|
|
61
|
+
const providers = capabilities.filter((item) => item.available).map((item) => item.provider);
|
|
62
|
+
if (!providers.length && !opts.provider) {
|
|
63
|
+
throw new Error(`No providers are available on runner host ${status.workspace.default_host}.`);
|
|
64
|
+
}
|
|
65
|
+
const providerAnswer = opts.provider?.trim() || (await prompt(`Provider (${providers.map((item, index) => `${index + 1}=${item}`).join(", ")}) [${providers[0]}]: `)).trim();
|
|
66
|
+
const provider = choose(providerAnswer, providers);
|
|
67
|
+
if (!provider)
|
|
68
|
+
throw new Error("Provider is required.");
|
|
69
|
+
availableProvider(capabilities, provider);
|
|
70
|
+
const model = opts.model?.trim() || (await prompt("Model: ")).trim();
|
|
71
|
+
if (!model)
|
|
72
|
+
throw new Error("Model is required.");
|
|
73
|
+
const effortAnswer = opts.effort?.trim() || (await prompt("Effort (1=low, 2=medium, 3=high) [high]: ")).trim();
|
|
74
|
+
const effort = choose(effortAnswer || "high", [...EFFORTS]);
|
|
75
|
+
if (!effort || !EFFORTS.includes(effort)) {
|
|
76
|
+
throw new Error(`Choose effort from: ${EFFORTS.join(", ")}.`);
|
|
77
|
+
}
|
|
78
|
+
return (deps.createAgent ?? createAgent)({ role, provider, model, effort,
|
|
79
|
+
display_name: name }, deps.config);
|
|
80
|
+
}
|
package/dist/api.js
CHANGED
|
@@ -143,10 +143,16 @@ export async function listMessages(options = {}, config = loadConfig()) {
|
|
|
143
143
|
query.set("since", options.since);
|
|
144
144
|
if (options.limit)
|
|
145
145
|
query.set("limit", String(options.limit));
|
|
146
|
+
if (options.offset)
|
|
147
|
+
query.set("offset", String(options.offset));
|
|
146
148
|
if (options.toRoles?.length)
|
|
147
149
|
query.set("to_role", options.toRoles.join(","));
|
|
148
150
|
if (options.undelivered)
|
|
149
151
|
query.set("undelivered", "true");
|
|
152
|
+
if (options.delivered)
|
|
153
|
+
query.set("delivered", "true");
|
|
154
|
+
if (options.order)
|
|
155
|
+
query.set("order", options.order);
|
|
150
156
|
const suffix = query.size ? `?${query.toString()}` : "";
|
|
151
157
|
return request(config, "GET", `/api/w/${config.slug}/messages${suffix}`);
|
|
152
158
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
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,
|
|
4
|
+
import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
|
|
5
5
|
import { initHost, parseHostFlags } 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
9
|
import { banner, c, statusChip, table, truncate, usage } from "./out.js";
|
|
10
10
|
import { ticketNew } from "./ticket-commands.js";
|
|
11
|
+
import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
|
|
11
12
|
import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
|
|
12
13
|
import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
|
|
13
14
|
function fail(message) {
|
|
@@ -60,6 +61,11 @@ async function cmdStatus() {
|
|
|
60
61
|
const data = await getStatus();
|
|
61
62
|
const state = data.workspace.paused ? c.yellow("off") : c.green("on");
|
|
62
63
|
console.log(`${c.bold(data.workspace.name)} ${c.dim(data.workspace.repo)} ${state}\n`);
|
|
64
|
+
const waiting = [...new Set(data.tickets.map((ticket) => ticket.stuck_reason
|
|
65
|
+
?.match(/^Waiting on (\w+) until (\d{1,2}:\d{2})\.$/)).filter(Boolean)
|
|
66
|
+
.map((match) => `${match?.[1]} waiting until ${match?.[2]}`))];
|
|
67
|
+
if (waiting.length)
|
|
68
|
+
console.log(`${c.bold("Providers")} ${waiting.map((line) => c.yellow(line)).join(", ")}\n`);
|
|
63
69
|
printTickets(data.tickets);
|
|
64
70
|
if (data.live_runs.length > 0) {
|
|
65
71
|
console.log(`\n${c.bold("Live runs")}`);
|
|
@@ -209,6 +215,36 @@ async function cmdMsg(argv) {
|
|
|
209
215
|
});
|
|
210
216
|
console.log(`sent ${message.id}`);
|
|
211
217
|
}
|
|
218
|
+
async function cmdInbox(argv) {
|
|
219
|
+
const { rest, opts, bools } = flags(argv);
|
|
220
|
+
if (rest.length || (bools.has("limit") && !opts.limit))
|
|
221
|
+
fail("usage: hd inbox [--all] [--limit N] [--json]");
|
|
222
|
+
const all = bools.has("all");
|
|
223
|
+
const json = bools.has("json");
|
|
224
|
+
const limit = opts.limit ? Number(opts.limit) : 50;
|
|
225
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500)
|
|
226
|
+
fail("usage: hd inbox [--all] [--limit N] [--json]");
|
|
227
|
+
const { messages } = await listMessages({
|
|
228
|
+
toRoles: ["human", "all"],
|
|
229
|
+
limit,
|
|
230
|
+
...(all ? { order: "desc" } : { undelivered: true }),
|
|
231
|
+
});
|
|
232
|
+
if (json) {
|
|
233
|
+
console.log(JSON.stringify({ messages }));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!messages.length) {
|
|
237
|
+
console.log(all ? "No messages." : "No unread messages.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const message of messages) {
|
|
241
|
+
const sender = message.from_name?.trim() || message.from_role;
|
|
242
|
+
const ticket = message.ticket_key?.trim();
|
|
243
|
+
console.log([message.created_at, sender, ticket].filter(Boolean).join(" "));
|
|
244
|
+
console.log(message.body_md);
|
|
245
|
+
console.log("");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
212
248
|
async function cmdDecide(argv) {
|
|
213
249
|
const { rest, opts } = flags(argv);
|
|
214
250
|
if (rest.length === 0) {
|
|
@@ -293,7 +329,7 @@ function selectAgent(agents, target, provider) {
|
|
|
293
329
|
fail(`No unambiguous agent ${target}${provider ? ` for provider ${provider}` : ""}.`);
|
|
294
330
|
return selected;
|
|
295
331
|
}
|
|
296
|
-
async function cmdAgents(argv) {
|
|
332
|
+
async function cmdAgents(argv, deps = {}) {
|
|
297
333
|
const [action, ...rest] = argv;
|
|
298
334
|
if (!action) {
|
|
299
335
|
const { agents } = await listAgents();
|
|
@@ -307,14 +343,11 @@ async function cmdAgents(argv) {
|
|
|
307
343
|
])));
|
|
308
344
|
return;
|
|
309
345
|
}
|
|
310
|
-
const agentUsage =
|
|
346
|
+
const agentUsage = `${AGENT_ADD_USAGE} | hd agents rm ROLE|ID | hd agents set ROLE|ID [flags]`;
|
|
311
347
|
const { rest: args, opts, bools } = flags(rest);
|
|
312
348
|
const target = args[0];
|
|
313
349
|
if (action === "add") {
|
|
314
|
-
|
|
315
|
-
fail(agentUsage);
|
|
316
|
-
const { agent } = await createAgent({ role: target, provider: opts.provider, model: opts.model,
|
|
317
|
-
effort: opts.effort, display_name: opts.name ?? opts.provider });
|
|
350
|
+
const { agent } = await agentAdd(rest, deps);
|
|
318
351
|
console.log(`${agent.id} ${agent.display_name} ${agent.role} ${agent.provider}`);
|
|
319
352
|
return;
|
|
320
353
|
}
|
|
@@ -339,11 +372,26 @@ async function cmdAgents(argv) {
|
|
|
339
372
|
const { agent } = await updateAgent(current.id, fields);
|
|
340
373
|
console.log(`${agent.display_name} ${agent.role} ${agent.provider} ${agent.model} ${agent.enabled ? "on" : "off"}`);
|
|
341
374
|
}
|
|
375
|
+
export function mailerCapsRow(auth) {
|
|
376
|
+
if (auth === "smtp")
|
|
377
|
+
return "mailer: custom SMTP";
|
|
378
|
+
if (auth === "built-in" || auth === "configured")
|
|
379
|
+
return "mailer: built-in (2/h)";
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
export function previewCapsRow(preview) {
|
|
383
|
+
return `preview: ${preview === "bypass" ? "bypass" : "protected"}`;
|
|
384
|
+
}
|
|
342
385
|
async function cmdCaps(argv) {
|
|
343
386
|
const [action, provider, raw, ...extra] = argv;
|
|
344
387
|
if (!action) {
|
|
345
|
-
const
|
|
388
|
+
const [status, detail] = await Promise.all([getStatus(), getWorkspace()]);
|
|
389
|
+
const caps = status.workspace.provider_caps ?? {};
|
|
346
390
|
console.log(table(["PROVIDER", "CAP"], Object.entries(caps).sort().map(([name, cap]) => [name, String(cap)])));
|
|
391
|
+
const mailer = mailerCapsRow(detail.workspace.settings?.infra?.auth);
|
|
392
|
+
if (mailer)
|
|
393
|
+
console.log(mailer);
|
|
394
|
+
console.log(previewCapsRow(detail.workspace.settings?.infra?.vercel_preview));
|
|
347
395
|
return;
|
|
348
396
|
}
|
|
349
397
|
const cap = Number(raw);
|
|
@@ -490,6 +538,10 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
490
538
|
await cmdMsg(rest);
|
|
491
539
|
return;
|
|
492
540
|
}
|
|
541
|
+
if (cmd === "inbox") {
|
|
542
|
+
await cmdInbox(rest);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
493
545
|
if (cmd === "decide") {
|
|
494
546
|
await cmdDecide(rest);
|
|
495
547
|
return;
|
|
@@ -499,7 +551,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
499
551
|
return;
|
|
500
552
|
}
|
|
501
553
|
if (cmd === "agents") {
|
|
502
|
-
await cmdAgents(rest);
|
|
554
|
+
await cmdAgents(rest, deps);
|
|
503
555
|
return;
|
|
504
556
|
}
|
|
505
557
|
if (cmd === "caps") {
|
package/dist/out.js
CHANGED
|
@@ -74,6 +74,7 @@ export function usage() {
|
|
|
74
74
|
` ${c.blue("hd host env ls | set | rm")} host environment`,
|
|
75
75
|
` ${c.blue("hd logs KEY [-f]")} run events`,
|
|
76
76
|
` ${c.blue("hd msg KEY TEXT")} message a builder`,
|
|
77
|
+
` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
|
|
77
78
|
` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
|
|
78
79
|
` ${c.blue("hd on | hd off")} workspace switch`,
|
|
79
80
|
` ${c.blue("hd login --url URL --api-key KEY")} client setup`,
|
package/dist/tui/App.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig } from "../config.js";
|
|
|
5
5
|
import { epicProgressRows } from "../epics.js";
|
|
6
6
|
import { promptOnStdin } from "../prompt.js";
|
|
7
7
|
import { ticketNew } from "../ticket-commands.js";
|
|
8
|
+
import { agentAdd } from "../agent-commands.js";
|
|
8
9
|
import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
|
|
9
10
|
import { Banner } from "./Banner.js";
|
|
10
11
|
import { Bubble } from "./Bubble.js";
|
|
@@ -19,8 +20,9 @@ import { alertOnce } from "./alert.js";
|
|
|
19
20
|
import { bubbleRows } from "./height.js";
|
|
20
21
|
import { planLayout, splitPanels } from "./layout.js";
|
|
21
22
|
import { parseLine } from "./parse.js";
|
|
22
|
-
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket,
|
|
23
|
+
import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
|
|
23
24
|
import { inputActive, promptPlaceholder, QUEUED_STEP, REPLY_WAIT_MS, settleChatReply } from "./chat-wait.js";
|
|
25
|
+
import { EARLIER_PAGE } from "./inbox.js";
|
|
24
26
|
import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
|
|
25
27
|
import { appendLines, runLabels, toStreamLines } from "./stream.js";
|
|
26
28
|
import { UI } from "./theme.js";
|
|
@@ -54,6 +56,9 @@ export function App({ initial }) {
|
|
|
54
56
|
const [field, setField] = useState(null);
|
|
55
57
|
const [editing, setEditing] = useState(null);
|
|
56
58
|
const [inboxFocus, setInboxFocus] = useState(0);
|
|
59
|
+
const [earlierLimit, setEarlierLimit] = useState(EARLIER_PAGE);
|
|
60
|
+
const earlierLimitRef = useRef(EARLIER_PAGE);
|
|
61
|
+
earlierLimitRef.current = earlierLimit;
|
|
57
62
|
const selectedRef = useRef(null);
|
|
58
63
|
const fieldRef = useRef(null);
|
|
59
64
|
const editingRef = useRef(null);
|
|
@@ -94,7 +99,7 @@ export function App({ initial }) {
|
|
|
94
99
|
}, []);
|
|
95
100
|
useEffect(() => {
|
|
96
101
|
setLive("connecting");
|
|
97
|
-
const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)));
|
|
102
|
+
const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)), () => earlierLimitRef.current);
|
|
98
103
|
refreshRef.current = polling.refresh;
|
|
99
104
|
return polling.close;
|
|
100
105
|
}, [config, applySnapshot]);
|
|
@@ -212,6 +217,8 @@ export function App({ initial }) {
|
|
|
212
217
|
setStream([]);
|
|
213
218
|
setLogsFilter(null);
|
|
214
219
|
acknowledgedMessages.current.clear();
|
|
220
|
+
setEarlierLimit(EARLIER_PAGE);
|
|
221
|
+
earlierLimitRef.current = EARLIER_PAGE;
|
|
215
222
|
setCursor(null);
|
|
216
223
|
selectedRef.current = null;
|
|
217
224
|
setView("home");
|
|
@@ -502,8 +509,13 @@ export function App({ initial }) {
|
|
|
502
509
|
case "agent-add":
|
|
503
510
|
setBusy(true);
|
|
504
511
|
try {
|
|
505
|
-
|
|
506
|
-
|
|
512
|
+
let created;
|
|
513
|
+
await suspendTerminal(async () => {
|
|
514
|
+
created = await agentAdd(action.args, { config, isTTY: true, prompt: tuiPrompt });
|
|
515
|
+
});
|
|
516
|
+
if (!created)
|
|
517
|
+
throw new Error("Agent wizard did not finish.");
|
|
518
|
+
const { agent } = created;
|
|
507
519
|
say("system", `Added ${agent.display_name} (${agent.id}).`);
|
|
508
520
|
await refresh();
|
|
509
521
|
}
|
|
@@ -599,6 +611,17 @@ export function App({ initial }) {
|
|
|
599
611
|
case "help":
|
|
600
612
|
setMessages((prior) => [...prior, { id: nextId(), speaker: "system", body: "", panel: "help", done: true }]);
|
|
601
613
|
return;
|
|
614
|
+
case "inbox-more":
|
|
615
|
+
if (view !== "inbox")
|
|
616
|
+
setView("inbox");
|
|
617
|
+
if (!board.earlierHasMore) {
|
|
618
|
+
say("system", "No more earlier messages.");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
setEarlierLimit((current) => current + EARLIER_PAGE);
|
|
622
|
+
earlierLimitRef.current += EARLIER_PAGE;
|
|
623
|
+
await refresh();
|
|
624
|
+
return;
|
|
602
625
|
case "refresh":
|
|
603
626
|
await refresh();
|
|
604
627
|
return;
|
package/dist/tui/Help.js
CHANGED
|
@@ -6,7 +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: "/inbox", help: "
|
|
9
|
+
{ name: "/inbox", args: "[more]", help: "unread and earlier messages; more pages earlier" },
|
|
10
10
|
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open or create a ticket" },
|
|
11
11
|
{ name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
|
|
12
12
|
{ name: "/cancel", args: "HD-12", help: "cancel a ticket" },
|
|
@@ -17,7 +17,7 @@ export const COMMANDS = [
|
|
|
17
17
|
{ name: "/architect", help: "talk to the agent that shapes draft epics" },
|
|
18
18
|
{ name: "/plan", help: "alias for /architect" },
|
|
19
19
|
{ name: "/decide", args: "[N|ID] answer", help: "answer a targeted or focused decision" },
|
|
20
|
-
{ name: "/agents", args: "[add
|
|
20
|
+
{ name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
|
|
21
21
|
{ name: "/env", help: "list workspace environment variable names" },
|
|
22
22
|
{ name: "/settings", help: "change provider caps and agent settings" },
|
|
23
23
|
{ name: "/workspace", args: "[slug | new | set | rotate-key | grant-runner-access]", help: "list, switch, create, or configure" },
|
package/dist/tui/Panels.js
CHANGED
|
@@ -7,6 +7,7 @@ import { UI } from "./theme.js";
|
|
|
7
7
|
import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js";
|
|
8
8
|
import { scrollWindow } from "./Dashboard.js";
|
|
9
9
|
import { agentDisplayRows } from "./agent-rows.js";
|
|
10
|
+
import { inboxHeader } from "./inbox.js";
|
|
10
11
|
const DOT = "●";
|
|
11
12
|
/**
|
|
12
13
|
* The single-purpose views behind /board, /agents, /feed and /inbox. Each is
|
|
@@ -86,27 +87,31 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
|
|
|
86
87
|
*/
|
|
87
88
|
export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
|
|
88
89
|
const entries = inboxEntries(board, width);
|
|
89
|
-
const
|
|
90
|
+
const unread = board.messages ?? [];
|
|
91
|
+
const earlier = board.earlier ?? [];
|
|
90
92
|
const inner = Math.max(0, rows - 1);
|
|
91
93
|
const window = scrollWindow(entries.length, inner, focus);
|
|
92
94
|
const hiddenAbove = window.start;
|
|
93
95
|
const hiddenBelow = entries.length - window.end;
|
|
94
|
-
const note = `${board.decisions.length}d · ${
|
|
96
|
+
const note = `${board.decisions.length}d · ${unread.length} unread`
|
|
95
97
|
+ `${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
|
|
98
|
+
const empty = board.decisions.length === 0 && unread.length === 0 && earlier.length === 0;
|
|
96
99
|
return (_jsx(Panel, { width: width, rows: rows, children: [
|
|
97
100
|
_jsx(Heading, { text: "Inbox", note: note }, "h"),
|
|
98
|
-
...(
|
|
101
|
+
...(empty
|
|
99
102
|
? [
|
|
100
103
|
_jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
|
|
101
104
|
]
|
|
102
105
|
: []),
|
|
103
|
-
...entries.slice(window.start, window.end).map((entry, index) => (_jsx(Text, { color: entry.kind === "
|
|
106
|
+
...entries.slice(window.start, window.end).map((entry, index) => (_jsx(Text, { color: entry.kind === "body" ? UI.text : UI.warn, wrap: "truncate", inverse: window.start + index === focus, children: entry.kind === "body" ? ` ${entry.text}` : entry.text }, entry.key))),
|
|
104
107
|
] }));
|
|
105
108
|
}
|
|
106
|
-
/** Every physical line in /inbox, with decision bodies wrapped but never clipped. */
|
|
109
|
+
/** Every physical line in /inbox, with decision and message bodies wrapped but never clipped. */
|
|
107
110
|
export function inboxEntries(board, width) {
|
|
108
111
|
const entries = [];
|
|
109
112
|
const bodyWidth = Math.max(12, width - 2);
|
|
113
|
+
const unread = board.messages ?? [];
|
|
114
|
+
const earlier = board.earlier ?? [];
|
|
110
115
|
board.decisions.forEach((decision, index) => {
|
|
111
116
|
const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
|
|
112
117
|
entries.push({
|
|
@@ -118,18 +123,35 @@ export function inboxEntries(board, width) {
|
|
|
118
123
|
entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
|
|
119
124
|
});
|
|
120
125
|
});
|
|
121
|
-
(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
entries.push({ key: "unread:section", kind: "section", text: "Unread" });
|
|
127
|
+
if (unread.length === 0) {
|
|
128
|
+
entries.push({ key: "unread:empty", kind: "body", text: "No unread messages." });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
unread.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
|
|
132
|
+
}
|
|
133
|
+
entries.push({ key: "earlier:section", kind: "section", text: "Earlier" });
|
|
134
|
+
if (earlier.length === 0) {
|
|
135
|
+
entries.push({ key: "earlier:empty", kind: "body", text: "No earlier messages." });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
earlier.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
|
|
139
|
+
if (board.earlierHasMore) {
|
|
140
|
+
entries.push({ key: "earlier:more", kind: "body", text: "Type /inbox more for older messages." });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
131
143
|
return entries;
|
|
132
144
|
}
|
|
145
|
+
function pushMessageEntries(entries, message, bodyWidth) {
|
|
146
|
+
entries.push({
|
|
147
|
+
key: `${message.id}:header`,
|
|
148
|
+
kind: "header",
|
|
149
|
+
text: inboxHeader(message),
|
|
150
|
+
});
|
|
151
|
+
wrapLines(message.body_md.trim(), bodyWidth).forEach((text, line) => {
|
|
152
|
+
entries.push({ key: `${message.id}:body:${line}`, kind: "body", text });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
133
155
|
function wrapLines(text, width) {
|
|
134
156
|
if (!text)
|
|
135
157
|
return [""];
|
package/dist/tui/agent-rows.js
CHANGED
package/dist/tui/data.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketEvents, 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
|
+
import { EARLIER_PAGE } from "./inbox.js";
|
|
4
5
|
export const POLL_MS = 5_000;
|
|
5
6
|
export const providers = ["claude", "codex", "gemini", "grok"];
|
|
6
7
|
export const efforts = ["low", "medium", "high"];
|
|
@@ -21,8 +22,16 @@ export const BOARD_COLUMNS = [
|
|
|
21
22
|
export async function configuredSlugs(config = loadConfig()) {
|
|
22
23
|
return (await listWorkspaces(config)).map((workspace) => workspace.slug).sort();
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
const
|
|
25
|
+
function withTicketKeys(messages, tickets) {
|
|
26
|
+
const keys = new Map(tickets.map((ticket) => [ticket.id, ticket.key]));
|
|
27
|
+
return messages.map((message) => ({
|
|
28
|
+
...message,
|
|
29
|
+
ticket_key: message.ticket_key ?? (message.ticket_id ? keys.get(message.ticket_id) ?? null : null),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
33
|
+
const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
|
|
34
|
+
const [status, workspaceData, ticketData, agentData, epicData, feedData, unreadData, earlierData] = await Promise.all([
|
|
26
35
|
getStatus(config),
|
|
27
36
|
getWorkspace(config),
|
|
28
37
|
listTickets(config),
|
|
@@ -30,6 +39,9 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
30
39
|
listEpics(config),
|
|
31
40
|
listFeed(config),
|
|
32
41
|
listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
|
|
42
|
+
listMessages({
|
|
43
|
+
toRoles: ["human", "all"], delivered: true, order: "desc", limit: earlierLimit + 1,
|
|
44
|
+
}, config),
|
|
33
45
|
]);
|
|
34
46
|
const byId = new Map(ticketData.tickets.map((ticket) => [ticket.id, ticket]));
|
|
35
47
|
const agents = new Map(agentData.agents.map((agent) => [agent.id, agent]));
|
|
@@ -39,11 +51,10 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
39
51
|
blocker_keys: ticket.blocked_by.map((id) => byId.get(id)?.key).filter((key) => Boolean(key)),
|
|
40
52
|
stuck: ticket.stuck_reason,
|
|
41
53
|
}));
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
54
|
+
const availability = status.workspace.provider_capabilities?.length
|
|
55
|
+
? status.workspace.provider_capabilities
|
|
56
|
+
: providers.map((provider) => ({ provider, available: false, reason: "runner capability unknown" }));
|
|
57
|
+
const earlier = withTicketKeys(earlierData.messages, tickets);
|
|
47
58
|
return {
|
|
48
59
|
config,
|
|
49
60
|
workspace: {
|
|
@@ -64,14 +75,16 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
64
75
|
agents: agentData.agents,
|
|
65
76
|
epics: epicData.epics,
|
|
66
77
|
decisions: status.decisions,
|
|
67
|
-
messages:
|
|
78
|
+
messages: withTicketKeys(unreadData.messages, tickets),
|
|
79
|
+
earlier: earlier.slice(0, earlierLimit),
|
|
80
|
+
earlierHasMore: earlier.length > earlierLimit,
|
|
68
81
|
availability,
|
|
69
82
|
runs: status.live_runs,
|
|
70
83
|
},
|
|
71
84
|
feed: feedData.entries,
|
|
72
85
|
};
|
|
73
86
|
}
|
|
74
|
-
export function pollSnapshot(config, onSnapshot, onState, onError) {
|
|
87
|
+
export function pollSnapshot(config, onSnapshot, onState, onError, earlierLimit) {
|
|
75
88
|
let closed = false;
|
|
76
89
|
let active = false;
|
|
77
90
|
const refresh = async () => {
|
|
@@ -79,7 +92,7 @@ export function pollSnapshot(config, onSnapshot, onState, onError) {
|
|
|
79
92
|
return;
|
|
80
93
|
active = true;
|
|
81
94
|
try {
|
|
82
|
-
const snapshot = await loadSnapshot(config);
|
|
95
|
+
const snapshot = await loadSnapshot(config, { earlierLimit: earlierLimit?.() ?? EARLIER_PAGE });
|
|
83
96
|
if (closed)
|
|
84
97
|
return;
|
|
85
98
|
onSnapshot(snapshot);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const EARLIER_PAGE = 50;
|
|
2
|
+
/** Split a mixed inbox fixture into Unread and Earlier, newest-first for delivered. */
|
|
3
|
+
export function inboxHistory(messages, earlierLimit = EARLIER_PAGE) {
|
|
4
|
+
const unread = messages
|
|
5
|
+
.filter((message) => !message.delivered_at)
|
|
6
|
+
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
|
|
7
|
+
const earlier = messages
|
|
8
|
+
.filter((message) => Boolean(message.delivered_at))
|
|
9
|
+
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
|
10
|
+
return {
|
|
11
|
+
unread,
|
|
12
|
+
earlier: earlier.slice(0, earlierLimit),
|
|
13
|
+
earlierHasMore: earlier.length > earlierLimit,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function inboxTime(iso) {
|
|
17
|
+
const at = Date.parse(iso);
|
|
18
|
+
if (Number.isNaN(at))
|
|
19
|
+
return iso;
|
|
20
|
+
return new Date(at).toISOString().slice(0, 16).replace("T", " ");
|
|
21
|
+
}
|
|
22
|
+
export function inboxSender(message) {
|
|
23
|
+
return message.from_name?.trim() || message.from_role;
|
|
24
|
+
}
|
|
25
|
+
export function inboxHeader(message) {
|
|
26
|
+
const key = message.ticket_key?.trim();
|
|
27
|
+
const prefix = `${inboxTime(message.created_at)} ${inboxSender(message)}`;
|
|
28
|
+
return key ? `${prefix} ${key}` : prefix;
|
|
29
|
+
}
|
package/dist/tui/parse.js
CHANGED
|
@@ -19,22 +19,22 @@ export function parseLine(raw) {
|
|
|
19
19
|
: { kind: "mode", mode: "architect" };
|
|
20
20
|
case "board":
|
|
21
21
|
case "feed":
|
|
22
|
-
case "inbox":
|
|
23
22
|
case "settings":
|
|
24
23
|
return { kind: "view", view: word.toLowerCase() };
|
|
24
|
+
case "inbox":
|
|
25
|
+
if (!argument)
|
|
26
|
+
return { kind: "view", view: "inbox" };
|
|
27
|
+
return rest.length === 1 && rest[0].toLowerCase() === "more"
|
|
28
|
+
? { kind: "inbox-more" }
|
|
29
|
+
: { kind: "unknown", command: "inbox takes no arguments, or more" };
|
|
25
30
|
case "agents": {
|
|
26
31
|
if (!argument)
|
|
27
32
|
return { kind: "view", view: "agents" };
|
|
28
33
|
const [verb, target, ...tail] = rest;
|
|
29
34
|
if (verb === "rm" && target && !tail.length)
|
|
30
35
|
return { kind: "agent-rm", target };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (tail[i]?.startsWith("--") && tail[i + 1])
|
|
34
|
-
opts[tail[i].slice(2)] = tail[i + 1];
|
|
35
|
-
return verb === "add" && target && opts.provider && opts.model
|
|
36
|
-
? { kind: "agent-add", role: target, provider: opts.provider, model: opts.model,
|
|
37
|
-
...(opts.effort ? { effort: opts.effort } : {}), ...(opts.name ? { name: opts.name } : {}) }
|
|
36
|
+
return verb === "add"
|
|
37
|
+
? { kind: "agent-add", args: rest.slice(1) }
|
|
38
38
|
: { kind: "unknown", command: "agents needs add ROLE --provider P --model M or rm ROLE|ID" };
|
|
39
39
|
}
|
|
40
40
|
case "env":
|