@qoren/cli 0.1.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/LICENSE +202 -0
- package/README.md +162 -0
- package/dist/args.d.ts +5 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +23 -0
- package/dist/args.js.map +1 -0
- package/dist/commands/account.d.ts +4 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +154 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/agent.d.ts +27 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +968 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/api.d.ts +4 -0
- package/dist/commands/api.d.ts.map +1 -0
- package/dist/commands/api.js +66 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/approvals.d.ts +11 -0
- package/dist/commands/approvals.d.ts.map +1 -0
- package/dist/commands/approvals.js +140 -0
- package/dist/commands/approvals.js.map +1 -0
- package/dist/commands/auth.d.ts +4 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +215 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/clients.d.ts +62 -0
- package/dist/commands/clients.d.ts.map +1 -0
- package/dist/commands/clients.js +311 -0
- package/dist/commands/clients.js.map +1 -0
- package/dist/commands/console.d.ts +4 -0
- package/dist/commands/console.d.ts.map +1 -0
- package/dist/commands/console.js +26 -0
- package/dist/commands/console.js.map +1 -0
- package/dist/commands/customAgent.d.ts +42 -0
- package/dist/commands/customAgent.d.ts.map +1 -0
- package/dist/commands/customAgent.js +449 -0
- package/dist/commands/customAgent.js.map +1 -0
- package/dist/commands/designer.d.ts +32 -0
- package/dist/commands/designer.d.ts.map +1 -0
- package/dist/commands/designer.js +682 -0
- package/dist/commands/designer.js.map +1 -0
- package/dist/commands/env.d.ts +15 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +245 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/interactive.d.ts +4 -0
- package/dist/commands/interactive.d.ts.map +1 -0
- package/dist/commands/interactive.js +30 -0
- package/dist/commands/interactive.js.map +1 -0
- package/dist/commands/jobs.d.ts +4 -0
- package/dist/commands/jobs.d.ts.map +1 -0
- package/dist/commands/jobs.js +59 -0
- package/dist/commands/jobs.js.map +1 -0
- package/dist/commands/secrets.d.ts +15 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/secrets.js +164 -0
- package/dist/commands/secrets.js.map +1 -0
- package/dist/commands/webhook.d.ts +4 -0
- package/dist/commands/webhook.d.ts.map +1 -0
- package/dist/commands/webhook.js +275 -0
- package/dist/commands/webhook.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +52 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +163 -0
- package/dist/context.js.map +1 -0
- package/dist/duration.d.ts +9 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +30 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/issues.d.ts +10 -0
- package/dist/issues.d.ts.map +1 -0
- package/dist/issues.js +45 -0
- package/dist/issues.js.map +1 -0
- package/dist/jobProgress.d.ts +12 -0
- package/dist/jobProgress.d.ts.map +1 -0
- package/dist/jobProgress.js +56 -0
- package/dist/jobProgress.js.map +1 -0
- package/dist/loopback.d.ts +28 -0
- package/dist/loopback.d.ts.map +1 -0
- package/dist/loopback.js +110 -0
- package/dist/loopback.js.map +1 -0
- package/dist/migrate/apply.d.ts +29 -0
- package/dist/migrate/apply.d.ts.map +1 -0
- package/dist/migrate/apply.js +135 -0
- package/dist/migrate/apply.js.map +1 -0
- package/dist/migrate/bundle.d.ts +89 -0
- package/dist/migrate/bundle.d.ts.map +1 -0
- package/dist/migrate/bundle.js +681 -0
- package/dist/migrate/bundle.js.map +1 -0
- package/dist/migrate/tar.d.ts +9 -0
- package/dist/migrate/tar.d.ts.map +1 -0
- package/dist/migrate/tar.js +89 -0
- package/dist/migrate/tar.js.map +1 -0
- package/dist/output.d.ts +46 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +152 -0
- package/dist/output.js.map +1 -0
- package/dist/program.d.ts +17 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +89 -0
- package/dist/program.js.map +1 -0
- package/dist/runtimes.d.ts +11 -0
- package/dist/runtimes.d.ts.map +1 -0
- package/dist/runtimes.js +20 -0
- package/dist/runtimes.js.map +1 -0
- package/dist/slug.d.ts +3 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +15 -0
- package/dist/slug.js.map +1 -0
- package/dist/taskChain.d.ts +20 -0
- package/dist/taskChain.d.ts.map +1 -0
- package/dist/taskChain.js +40 -0
- package/dist/taskChain.js.map +1 -0
- package/dist/taskDelivery.d.ts +33 -0
- package/dist/taskDelivery.d.ts.map +1 -0
- package/dist/taskDelivery.js +67 -0
- package/dist/taskDelivery.js.map +1 -0
- package/dist/tui/app.d.ts +5 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +130 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/index.d.ts +12 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +34 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/model.d.ts +110 -0
- package/dist/tui/model.d.ts.map +1 -0
- package/dist/tui/model.js +257 -0
- package/dist/tui/model.js.map +1 -0
- package/dist/tui/overlay.d.ts +66 -0
- package/dist/tui/overlay.d.ts.map +1 -0
- package/dist/tui/overlay.js +216 -0
- package/dist/tui/overlay.js.map +1 -0
- package/dist/tui/panes/account.d.ts +5 -0
- package/dist/tui/panes/account.d.ts.map +1 -0
- package/dist/tui/panes/account.js +108 -0
- package/dist/tui/panes/account.js.map +1 -0
- package/dist/tui/panes/agents.d.ts +9 -0
- package/dist/tui/panes/agents.d.ts.map +1 -0
- package/dist/tui/panes/agents.js +340 -0
- package/dist/tui/panes/agents.js.map +1 -0
- package/dist/tui/panes/chat.d.ts +14 -0
- package/dist/tui/panes/chat.d.ts.map +1 -0
- package/dist/tui/panes/chat.js +169 -0
- package/dist/tui/panes/chat.js.map +1 -0
- package/dist/tui/panes/environments.d.ts +6 -0
- package/dist/tui/panes/environments.d.ts.map +1 -0
- package/dist/tui/panes/environments.js +364 -0
- package/dist/tui/panes/environments.js.map +1 -0
- package/dist/tui/panes/jobs.d.ts +5 -0
- package/dist/tui/panes/jobs.d.ts.map +1 -0
- package/dist/tui/panes/jobs.js +104 -0
- package/dist/tui/panes/jobs.js.map +1 -0
- package/dist/tui/store.d.ts +40 -0
- package/dist/tui/store.d.ts.map +1 -0
- package/dist/tui/store.js +124 -0
- package/dist/tui/store.js.map +1 -0
- package/dist/tui/ui.d.ts +95 -0
- package/dist/tui/ui.d.ts.map +1 -0
- package/dist/tui/ui.js +144 -0
- package/dist/tui/ui.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
import { InvalidArgumentError } from "commander";
|
|
2
|
+
import { requireContext, run, } from "../context.js";
|
|
3
|
+
import { parseDuration } from "../duration.js";
|
|
4
|
+
import { slugify } from "../slug.js";
|
|
5
|
+
import { DELIVER_TO_HELP, DELIVERY_HELP, describeDelivery, resolveDelivery, } from "../taskDelivery.js";
|
|
6
|
+
import { CONTEXT_FROM_HELP, collectContextFrom, describeChain, resolveContextFrom, } from "../taskChain.js";
|
|
7
|
+
import { followJob } from "../jobProgress.js";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { gunzipSync } from "node:zlib";
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
import { applyPlan } from "../migrate/apply.js";
|
|
12
|
+
import { parseBundle, planImport } from "../migrate/bundle.js";
|
|
13
|
+
import { readTar } from "../migrate/tar.js";
|
|
14
|
+
import { CUSTOM_CREATE_REFUSAL, HOSTED_RUNTIMES, RUNTIMES, isCustomAgent, isHostedRuntime, } from "../runtimes.js";
|
|
15
|
+
import { customAgentCommands } from "./customAgent.js";
|
|
16
|
+
import { parseOnOff } from "../args.js";
|
|
17
|
+
import { age, bold, details, dim, emit, note, statusColor, table, warn, } from "../output.js";
|
|
18
|
+
// Agents.
|
|
19
|
+
export { parseOnOff };
|
|
20
|
+
/** One line saying where approval mode stands, for `agent approval-mode`. */
|
|
21
|
+
export function approvalModeLine(name, enabled) {
|
|
22
|
+
const who = name ? bold(name) : "the agent";
|
|
23
|
+
return enabled
|
|
24
|
+
? `Approval mode is on: ${who} proposes changes for you to approve instead of making them.`
|
|
25
|
+
: `Approval mode is off: ${who} acts on its own.`;
|
|
26
|
+
}
|
|
27
|
+
// Scheduled-task health states that need no attention.
|
|
28
|
+
const HEALTHY_TASK_STATES = new Set(["healthy", "pending", "native"]);
|
|
29
|
+
function formatRunDuration(ms) {
|
|
30
|
+
if (ms == null)
|
|
31
|
+
return "";
|
|
32
|
+
if (ms < 1000)
|
|
33
|
+
return `${ms}ms`;
|
|
34
|
+
const s = Math.round(ms / 1000);
|
|
35
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
36
|
+
}
|
|
37
|
+
/** One short line on why a run looks the way it does: its error, an inferred outcome, or the first
|
|
38
|
+
* failed delivery. Full text is in --json. */
|
|
39
|
+
function runNote(r) {
|
|
40
|
+
const oneLine = (text) => {
|
|
41
|
+
const line = text.split("\n")[0]?.trim() ?? "";
|
|
42
|
+
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
|
43
|
+
};
|
|
44
|
+
if (r.status === "unknown" && r.outcomeSource === "control-plane")
|
|
45
|
+
return dim("no result recorded");
|
|
46
|
+
if (r.status === "coalesced")
|
|
47
|
+
return dim("missed slots folded into one run");
|
|
48
|
+
if (r.error)
|
|
49
|
+
return oneLine(r.error);
|
|
50
|
+
const failed = r.deliveries.find((d) => d.error);
|
|
51
|
+
return failed?.error ? oneLine(`${failed.destination}: ${failed.error}`) : "";
|
|
52
|
+
}
|
|
53
|
+
// For historical reasons an agent carries its id as `_id` and its timestamps as
|
|
54
|
+
// epoch millis, unlike every other resource. The table converts rather than
|
|
55
|
+
// assuming the shape the others use.
|
|
56
|
+
function agentTable(rows) {
|
|
57
|
+
table(rows, [
|
|
58
|
+
{ header: "id", value: (a) => a._id },
|
|
59
|
+
{ header: "name", value: (a) => a.name },
|
|
60
|
+
{ header: "slug", value: (a) => a.slug },
|
|
61
|
+
{ header: "status", value: (a) => statusColor(a.status) },
|
|
62
|
+
{ header: "runtime", value: (a) => a.runtime ?? "" },
|
|
63
|
+
{ header: "model", value: (a) => a.model },
|
|
64
|
+
{ header: "age", value: (a) => age(new Date(a.createdAt).toISOString()) },
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
function doctorTable(rows) {
|
|
68
|
+
table(rows, [
|
|
69
|
+
{
|
|
70
|
+
header: "started",
|
|
71
|
+
value: (r) => age(new Date(r.startedAt).toISOString()),
|
|
72
|
+
},
|
|
73
|
+
{ header: "trigger", value: (r) => r.trigger },
|
|
74
|
+
{ header: "status", value: (r) => statusColor(r.status) },
|
|
75
|
+
{
|
|
76
|
+
header: "found",
|
|
77
|
+
value: (r) => (r.issuesFound == null ? "" : String(r.issuesFound)),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
header: "left",
|
|
81
|
+
value: (r) => r.issuesRemaining == null ? "" : String(r.issuesRemaining),
|
|
82
|
+
},
|
|
83
|
+
{ header: "summary", value: (r) => r.summary },
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
function printDoctorRun(r) {
|
|
87
|
+
note(`${bold(statusColor(r.status))} ${r.summary}`);
|
|
88
|
+
const detail = r.checkOutput?.trim();
|
|
89
|
+
if (detail) {
|
|
90
|
+
process.stdout.write(`${detail}\n`);
|
|
91
|
+
}
|
|
92
|
+
if (r.repairAttempted && r.repairOutput?.trim()) {
|
|
93
|
+
note(bold("\nRepair output"));
|
|
94
|
+
process.stdout.write(`${r.repairOutput.trim()}\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Poll the checkup history until the run we started leaves `running`. */
|
|
98
|
+
async function waitForDoctor(ctx, agentId, runId) {
|
|
99
|
+
const deadline = Date.now() + 20 * 60 * 1000;
|
|
100
|
+
for (;;) {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
102
|
+
const runs = await ctx.qoren.agents.doctorRuns(agentId, 5);
|
|
103
|
+
const mine = runs.find((r) => r.id === runId) ?? runs[0];
|
|
104
|
+
if (mine && mine.status !== "running")
|
|
105
|
+
return mine;
|
|
106
|
+
if (Date.now() > deadline) {
|
|
107
|
+
warn("Gave up waiting; check back with --history.");
|
|
108
|
+
return mine ?? runs[0];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Print an exec/message result the way a shell would: stdout to stdout,
|
|
113
|
+
* stderr to stderr, and the exit code carried through to ours. */
|
|
114
|
+
function printExec(result) {
|
|
115
|
+
if (result.stdOut)
|
|
116
|
+
process.stdout.write(result.stdOut);
|
|
117
|
+
if (result.stdErr)
|
|
118
|
+
process.stderr.write(result.stdErr);
|
|
119
|
+
if (result.exitCode !== 0)
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
/**
|
|
124
|
+
* Wait for a device sign-in job to publish the one-time code, or to end.
|
|
125
|
+
*
|
|
126
|
+
* The code arrives as the job's partial result, not as a step or a log line, so
|
|
127
|
+
* `jobs.await` is the wrong tool: it resolves only once the operator has already
|
|
128
|
+
* approved. Poll instead, on a cadence a person can watch without it feeling
|
|
129
|
+
* stuck. Null means the job reached a terminal state without ever printing a
|
|
130
|
+
* code, which `followJob` then reports properly.
|
|
131
|
+
*/
|
|
132
|
+
async function awaitDeviceCode(ctx, jobId) {
|
|
133
|
+
for (;;) {
|
|
134
|
+
const job = await ctx.qoren.jobs.get(jobId);
|
|
135
|
+
const result = job.result;
|
|
136
|
+
if (result?.userCode && result.verificationUrl) {
|
|
137
|
+
return {
|
|
138
|
+
awaitingApproval: true,
|
|
139
|
+
verificationUrl: result.verificationUrl,
|
|
140
|
+
userCode: result.userCode,
|
|
141
|
+
expiresAt: result.expiresAt ?? "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (job.status !== "Queued" && job.status !== "Running")
|
|
145
|
+
return null;
|
|
146
|
+
await sleep(1_500);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Spend limits: daily, weekly and monthly credit caps on an agent's own model key.
|
|
150
|
+
/** The largest cap the control plane accepts, in credits. */
|
|
151
|
+
export const MAX_SPEND_CAP = 100_000_000;
|
|
152
|
+
/** A `--daily`/`--weekly`/`--monthly` value: a whole number of credits, or
|
|
153
|
+
* `off` (or `none`) to remove the cap. "off" rather than null because
|
|
154
|
+
* commander turns a parser's null into an empty string. */
|
|
155
|
+
export function parseSpendCap(input) {
|
|
156
|
+
const v = input.trim().toLowerCase();
|
|
157
|
+
if (v === "off" || v === "none")
|
|
158
|
+
return "off";
|
|
159
|
+
const digits = v.replace(/[,_]/g, "");
|
|
160
|
+
if (!/^\d+$/.test(digits)) {
|
|
161
|
+
throw new InvalidArgumentError(`Expected a whole number of credits or "off", got "${input}".`);
|
|
162
|
+
}
|
|
163
|
+
const credits = Number(digits);
|
|
164
|
+
if (credits < 1 || credits > MAX_SPEND_CAP) {
|
|
165
|
+
throw new InvalidArgumentError(`A cap must be from 1 to ${MAX_SPEND_CAP.toLocaleString("en-US")} credits, or "off".`);
|
|
166
|
+
}
|
|
167
|
+
return credits;
|
|
168
|
+
}
|
|
169
|
+
/** The caps to send: the current ones with only the given flags changed,
|
|
170
|
+
* since the endpoint replaces all three. Null when no flag was given. */
|
|
171
|
+
export function mergeSpendCaps(current, flags) {
|
|
172
|
+
if (flags.daily === undefined &&
|
|
173
|
+
flags.weekly === undefined &&
|
|
174
|
+
flags.monthly === undefined) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const pick = (flag, cap) => flag === undefined ? cap : flag === "off" ? null : flag;
|
|
178
|
+
return {
|
|
179
|
+
dailyCredits: pick(flags.daily, current.dailyCredits),
|
|
180
|
+
weeklyCredits: pick(flags.weekly, current.weeklyCredits),
|
|
181
|
+
monthlyCredits: pick(flags.monthly, current.monthlyCredits),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** One line saying whether the caps bite, from the agent's key state. */
|
|
185
|
+
export function spendKeyLine(limits) {
|
|
186
|
+
switch (limits.key) {
|
|
187
|
+
case "agent":
|
|
188
|
+
return limits.enforced
|
|
189
|
+
? "Enforced on the agent's own key: model calls stop once a cap is reached and resume after the window resets."
|
|
190
|
+
: "Set on the agent's own key, but not enforced right now.";
|
|
191
|
+
case "pending":
|
|
192
|
+
return "The agent's own key is being issued (within a few minutes). The caps apply once it lands.";
|
|
193
|
+
case "own":
|
|
194
|
+
return "This agent runs on your own model key, so Qoren does not bill or cap its spend.";
|
|
195
|
+
case "none":
|
|
196
|
+
return "This agent has no Qoren model key, so there is nothing to cap.";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const SPEND_WINDOWS = [
|
|
200
|
+
{ label: "daily", key: "dailyCredits", resets: "midnight UTC" },
|
|
201
|
+
{ label: "weekly", key: "weeklyCredits", resets: "Monday, midnight UTC" },
|
|
202
|
+
{ label: "monthly", key: "monthlyCredits", resets: "the 1st, midnight UTC" },
|
|
203
|
+
];
|
|
204
|
+
/** The windows a cap is already at or under this window's spend. */
|
|
205
|
+
export function capsAlreadyReached(limits) {
|
|
206
|
+
const spent = limits.spent;
|
|
207
|
+
if (!spent)
|
|
208
|
+
return [];
|
|
209
|
+
return SPEND_WINDOWS.filter(({ key }) => {
|
|
210
|
+
const cap = limits[key];
|
|
211
|
+
return cap !== null && spent[key] >= cap;
|
|
212
|
+
}).map(({ label }) => label);
|
|
213
|
+
}
|
|
214
|
+
function printSpendLimits(limits) {
|
|
215
|
+
const n = (value) => Math.round(value).toLocaleString("en-US");
|
|
216
|
+
table([...SPEND_WINDOWS], [
|
|
217
|
+
{ header: "window", value: (w) => w.label },
|
|
218
|
+
{
|
|
219
|
+
header: "cap",
|
|
220
|
+
value: (w) => {
|
|
221
|
+
const cap = limits[w.key];
|
|
222
|
+
return cap === null ? dim("none") : `${n(cap)} credits`;
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
header: "spent",
|
|
227
|
+
value: (w) => (limits.spent ? `${n(limits.spent[w.key])} credits` : dim("unavailable")),
|
|
228
|
+
},
|
|
229
|
+
{ header: "resets", value: (w) => w.resets },
|
|
230
|
+
]);
|
|
231
|
+
note(dim(`\n${spendKeyLine(limits)}`));
|
|
232
|
+
if (!limits.canEdit) {
|
|
233
|
+
note(dim("Only the organization owner can change these caps."));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function agentCommands(program, global) {
|
|
237
|
+
const agent = program
|
|
238
|
+
.command("agent")
|
|
239
|
+
.alias("agents")
|
|
240
|
+
.description("Manage agents");
|
|
241
|
+
agent
|
|
242
|
+
.command("ls")
|
|
243
|
+
.alias("list")
|
|
244
|
+
.description("List your agents")
|
|
245
|
+
.option("--env <id>", "only agents on this environment")
|
|
246
|
+
.option("--runtime <runtime>", `only agents of this runtime: ${RUNTIMES.join(", ")}`)
|
|
247
|
+
.action((options) => run(async () => {
|
|
248
|
+
if (options.runtime !== undefined &&
|
|
249
|
+
!RUNTIMES.includes(options.runtime)) {
|
|
250
|
+
throw new Error(`Unknown runtime "${options.runtime}". Choose one of: ${RUNTIMES.join(", ")}.`);
|
|
251
|
+
}
|
|
252
|
+
const ctx = requireContext(global());
|
|
253
|
+
const agents = await ctx.qoren.agents.list({
|
|
254
|
+
...(options.env ? { environmentId: options.env } : {}),
|
|
255
|
+
...(options.runtime ? { runtime: options.runtime } : {}),
|
|
256
|
+
});
|
|
257
|
+
emit(agents, () => agentTable(agents));
|
|
258
|
+
})());
|
|
259
|
+
agent
|
|
260
|
+
.command("get <id>")
|
|
261
|
+
.description("Show one agent")
|
|
262
|
+
.action((id) => run(async () => {
|
|
263
|
+
const ctx = requireContext(global());
|
|
264
|
+
const a = await ctx.qoren.agents.get(id);
|
|
265
|
+
emit(a, () => {
|
|
266
|
+
details([
|
|
267
|
+
["Id", a._id],
|
|
268
|
+
["Name", a.name],
|
|
269
|
+
["Slug", a.slug],
|
|
270
|
+
["Status", statusColor(a.status)],
|
|
271
|
+
["Runtime", a.runtime ?? ""],
|
|
272
|
+
["Model", a.model],
|
|
273
|
+
["Template", a.presetName ?? ""],
|
|
274
|
+
["Environment", a.machineId ?? dim("none (Custom agent)")],
|
|
275
|
+
["Secrets", (a.clientSecretNames ?? []).join(", ")],
|
|
276
|
+
["Messaging", (a.gateways ?? []).map((g) => g.type).join(", ")],
|
|
277
|
+
["Approval mode", a.approvalModeEnabled ? "on" : "off"],
|
|
278
|
+
["Created", new Date(a.createdAt).toISOString()],
|
|
279
|
+
]);
|
|
280
|
+
if (isCustomAgent(a)) {
|
|
281
|
+
note(dim(`\nA Custom agent. Its design and inputs: qoren designer show ${a._id}`));
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
})());
|
|
285
|
+
agent
|
|
286
|
+
.command("create")
|
|
287
|
+
.description("Deploy a new agent onto an environment")
|
|
288
|
+
.requiredOption("--env <id>", "environment to deploy onto")
|
|
289
|
+
.requiredOption("--template <slug>", "template to build from")
|
|
290
|
+
.requiredOption("--name <name>", "display name")
|
|
291
|
+
.option("--slug <slug>", "machine identity (defaults to the name)")
|
|
292
|
+
.option("--model <model>", "model id")
|
|
293
|
+
.option("--runtime <runtime>", `one of: ${HOSTED_RUNTIMES.join(", ")}`,
|
|
294
|
+
// Refused while parsing, so the pointer to the Designer comes before any
|
|
295
|
+
// complaint about the flags a Custom agent would never need.
|
|
296
|
+
(value) => {
|
|
297
|
+
if (value === "custom")
|
|
298
|
+
throw new InvalidArgumentError(CUSTOM_CREATE_REFUSAL);
|
|
299
|
+
return value;
|
|
300
|
+
}, "hermes")
|
|
301
|
+
.option("--secret <name...>", "vault secret to inject; repeatable")
|
|
302
|
+
.option("--no-wait", "print the job id and exit instead of following it")
|
|
303
|
+
.action((options) => run(async () => {
|
|
304
|
+
if (options.runtime === "custom")
|
|
305
|
+
throw new Error(CUSTOM_CREATE_REFUSAL);
|
|
306
|
+
if (!isHostedRuntime(options.runtime)) {
|
|
307
|
+
throw new Error(`Unknown runtime "${options.runtime}". Choose one of: ${HOSTED_RUNTIMES.join(", ")}.`);
|
|
308
|
+
}
|
|
309
|
+
const runtime = options.runtime;
|
|
310
|
+
const ctx = requireContext(global());
|
|
311
|
+
// The slug is the agent's permanent identity (its Unix user), so it
|
|
312
|
+
// has to be a safe token. Derive a sane one from the name rather than
|
|
313
|
+
// making everyone pass both.
|
|
314
|
+
const slug = options.slug ?? slugify(options.name);
|
|
315
|
+
if (!slug) {
|
|
316
|
+
throw new Error("Could not derive a slug from that name. Pass --slug explicitly.");
|
|
317
|
+
}
|
|
318
|
+
// Fall back to the server's own default for this org, so a first
|
|
319
|
+
// deploy does not require knowing a model id.
|
|
320
|
+
const model = options.model ?? (await ctx.qoren.account.options()).defaultModel;
|
|
321
|
+
if (!model) {
|
|
322
|
+
throw new Error("No model available. Pass --model explicitly.");
|
|
323
|
+
}
|
|
324
|
+
const result = await ctx.qoren.agents.create({
|
|
325
|
+
machineId: options.env,
|
|
326
|
+
slug,
|
|
327
|
+
name: options.name,
|
|
328
|
+
runtime,
|
|
329
|
+
model,
|
|
330
|
+
presetName: options.template,
|
|
331
|
+
...(options.secret?.length
|
|
332
|
+
? { clientSecretNames: options.secret }
|
|
333
|
+
: {}),
|
|
334
|
+
});
|
|
335
|
+
for (const finding of result.safetyWarnings ?? []) {
|
|
336
|
+
warn(`${finding.severity}: ${finding.title} — ${finding.suggestion}`);
|
|
337
|
+
}
|
|
338
|
+
if (!options.wait) {
|
|
339
|
+
emit(result, () => note(`Job ${bold(result.jobId)} started.`));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
await followJob(ctx, result.jobId, `Deploying ${bold(options.name)}…`);
|
|
343
|
+
emit({ ...result, ok: true, slug }, () => note(`Agent ${bold(options.name)} is running.`));
|
|
344
|
+
})());
|
|
345
|
+
agent
|
|
346
|
+
.command("rm <id>")
|
|
347
|
+
.alias("destroy")
|
|
348
|
+
.description("Remove an agent (a final snapshot is taken first)")
|
|
349
|
+
.option("--no-wait", "print the job id and exit instead of following it")
|
|
350
|
+
.action((id, options) => run(async () => {
|
|
351
|
+
const ctx = requireContext(global());
|
|
352
|
+
const result = await ctx.qoren.agents.destroy(id);
|
|
353
|
+
if (!options.wait) {
|
|
354
|
+
emit(result, () => note(`Job ${bold(result.jobId)} started.`));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
await followJob(ctx, result.jobId, `Removing ${bold(id)}…`);
|
|
358
|
+
emit({ ...result, ok: true }, () => note("Agent removed. It can be restored from its final snapshot."));
|
|
359
|
+
})());
|
|
360
|
+
agent
|
|
361
|
+
.command("rename <id> <name>")
|
|
362
|
+
.description("Rename an agent")
|
|
363
|
+
.action((id, name) => run(async () => {
|
|
364
|
+
const ctx = requireContext(global());
|
|
365
|
+
const result = await ctx.qoren.agents.rename(id, name);
|
|
366
|
+
emit(result, () => {
|
|
367
|
+
note(`Renamed to ${bold(result.name)}.`);
|
|
368
|
+
if (!result.memorySynced) {
|
|
369
|
+
// The rename stands either way; saying nothing here would leave
|
|
370
|
+
// someone wondering why the agent still calls itself the old name.
|
|
371
|
+
warn("The agent could not be reached, so it still answers to its old name. It will learn the new one next time it is rebuilt.");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
})());
|
|
375
|
+
agent
|
|
376
|
+
.command("approval-mode <id> [state]")
|
|
377
|
+
.description("Show approval mode, or turn it on or off (the agent proposes changes for you to approve instead of making them)")
|
|
378
|
+
.action((id, state) => run(async () => {
|
|
379
|
+
const ctx = requireContext(global());
|
|
380
|
+
if (state === undefined) {
|
|
381
|
+
const a = await ctx.qoren.agents.get(id);
|
|
382
|
+
const enabled = a.approvalModeEnabled ?? false;
|
|
383
|
+
emit({ approvalModeEnabled: enabled }, () => note(approvalModeLine(a.name, enabled)));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const enabled = parseOnOff(state);
|
|
387
|
+
if (enabled === null) {
|
|
388
|
+
throw new Error(`Expected on or off, got "${state}".`);
|
|
389
|
+
}
|
|
390
|
+
const result = await ctx.qoren.agents.setApprovalMode(id, enabled);
|
|
391
|
+
emit(result, () => {
|
|
392
|
+
note(approvalModeLine(null, result.approvalModeEnabled));
|
|
393
|
+
if (result.approvalModeEnabled) {
|
|
394
|
+
note(dim("Its next turn proposes instead of acting. See what it asks with: qoren approvals"));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
})());
|
|
398
|
+
agent
|
|
399
|
+
.command("message <id> <message>")
|
|
400
|
+
.description("Send an agent a message and wait for its reply")
|
|
401
|
+
.option("--resume <sessionId>", "continue a previous conversation")
|
|
402
|
+
.option("--no-wait", "print the job id and exit instead of waiting")
|
|
403
|
+
.action((id, message, options) => run(async () => {
|
|
404
|
+
const ctx = requireContext(global());
|
|
405
|
+
const result = await ctx.qoren.agents.message(id, message, options.resume ?? null);
|
|
406
|
+
if (!options.wait) {
|
|
407
|
+
emit(result, () => note(`Job ${bold(result.jobId)} started.`));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// A turn can take minutes: the harness runs one-shot per message.
|
|
411
|
+
const job = await followJob(ctx, result.jobId, dim("Thinking…"));
|
|
412
|
+
const reply = job.result;
|
|
413
|
+
emit(reply ?? {}, () => {
|
|
414
|
+
if (reply)
|
|
415
|
+
printExec(reply);
|
|
416
|
+
});
|
|
417
|
+
})());
|
|
418
|
+
agent
|
|
419
|
+
.command("exec <id> <command>")
|
|
420
|
+
.description("Run a shell command as the agent's user")
|
|
421
|
+
.action((id, command) => run(async () => {
|
|
422
|
+
const ctx = requireContext(global());
|
|
423
|
+
const result = await ctx.qoren.agents.exec(id, command);
|
|
424
|
+
emit(result, () => printExec(result));
|
|
425
|
+
})());
|
|
426
|
+
agent
|
|
427
|
+
.command("logs <id>")
|
|
428
|
+
.description("Show an agent's recent log")
|
|
429
|
+
.option("--limit <n>", "how many lines", "200")
|
|
430
|
+
.action((id, options) => run(async () => {
|
|
431
|
+
const ctx = requireContext(global());
|
|
432
|
+
const lines = await ctx.qoren.agents.logs(id, Number.parseInt(options.limit, 10) || 200);
|
|
433
|
+
emit(lines, () => {
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
process.stdout.write(`${dim(line.ts)} ${dim(line.source)} ${line.message}\n`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
})());
|
|
439
|
+
agent
|
|
440
|
+
.command("tasks <id>")
|
|
441
|
+
.description("List an agent's scheduled tasks and how healthy each one is")
|
|
442
|
+
.action((id) => run(async () => {
|
|
443
|
+
const ctx = requireContext(global());
|
|
444
|
+
const tasks = await ctx.qoren.agents.listScheduledTasks(id);
|
|
445
|
+
emit(tasks, () => {
|
|
446
|
+
table(tasks, [
|
|
447
|
+
{ header: "id", value: (t) => t.id },
|
|
448
|
+
{ header: "name", value: (t) => t.name },
|
|
449
|
+
{ header: "cron", value: (t) => t.schedule },
|
|
450
|
+
{ header: "timezone", value: (t) => t.timezone },
|
|
451
|
+
{ header: "delivery", value: describeDelivery },
|
|
452
|
+
{ header: "reads", value: (t) => describeChain(t, tasks) },
|
|
453
|
+
{ header: "source", value: (t) => t.source ?? "" },
|
|
454
|
+
{ header: "state", value: (t) => statusColor(t.schedulerState) },
|
|
455
|
+
{ header: "health", value: (t) => statusColor(t.healthState) },
|
|
456
|
+
{
|
|
457
|
+
header: "failures",
|
|
458
|
+
value: (t) => t.consecutiveFailures > 0 ? String(t.consecutiveFailures) : "",
|
|
459
|
+
},
|
|
460
|
+
{ header: "next", value: (t) => t.nextExpectedAt ?? "" },
|
|
461
|
+
{ header: "enabled", value: (t) => (t.enabled ? "yes" : "no") },
|
|
462
|
+
]);
|
|
463
|
+
// The table has no room for prose: say why each unhealthy task is unhealthy underneath it.
|
|
464
|
+
for (const t of tasks) {
|
|
465
|
+
const why = t.healthDetail ?? t.schedulerError;
|
|
466
|
+
if (why && !HEALTHY_TASK_STATES.has(t.healthState))
|
|
467
|
+
warn(`${bold(t.name)}: ${why}`);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
})());
|
|
471
|
+
agent
|
|
472
|
+
.command("task-runs <id> <task-id>")
|
|
473
|
+
.description("Show a scheduled task's recent runs and delivery results")
|
|
474
|
+
.option("--limit <count>", "number of runs, up to 100", "25")
|
|
475
|
+
.action((id, taskId, options) => run(async () => {
|
|
476
|
+
const ctx = requireContext(global());
|
|
477
|
+
const count = Number.parseInt(options.limit, 10);
|
|
478
|
+
if (!Number.isInteger(count) || count < 1 || count > 100)
|
|
479
|
+
throw new Error("--limit must be between 1 and 100.");
|
|
480
|
+
const runs = await ctx.qoren.agents.listScheduledTaskRuns(id, taskId, count);
|
|
481
|
+
emit(runs, () => table(runs, [
|
|
482
|
+
{ header: "run", value: (r) => r.runId },
|
|
483
|
+
{ header: "started", value: (r) => r.startedAt ?? "" },
|
|
484
|
+
{
|
|
485
|
+
header: "status",
|
|
486
|
+
value: (r) => r.reason ? `${statusColor(r.status)} ${dim(`(${r.reason})`)}` : statusColor(r.status),
|
|
487
|
+
},
|
|
488
|
+
{ header: "duration", value: (r) => formatRunDuration(r.durationMs) },
|
|
489
|
+
{ header: "exit", value: (r) => (r.exitCode == null ? "" : String(r.exitCode)) },
|
|
490
|
+
{
|
|
491
|
+
header: "delivery",
|
|
492
|
+
value: (r) => r.deliveries.map((d) => `${d.destination}:${statusColor(d.status)}`).join(", "),
|
|
493
|
+
},
|
|
494
|
+
{ header: "note", value: runNote },
|
|
495
|
+
]));
|
|
496
|
+
})());
|
|
497
|
+
agent
|
|
498
|
+
.command("task-add <id> <name> <cron> <prompt>")
|
|
499
|
+
.description("Create a scheduled task")
|
|
500
|
+
.option("--timezone <zone>", "IANA timezone", "UTC")
|
|
501
|
+
.option("--delivery <policy>", DELIVERY_HELP)
|
|
502
|
+
.option("--deliver-to <target>", DELIVER_TO_HELP)
|
|
503
|
+
.option("--context-from <task>", CONTEXT_FROM_HELP, collectContextFrom)
|
|
504
|
+
.action((id, name, cron, prompt, options) => run(async () => {
|
|
505
|
+
const delivery = resolveDelivery(options);
|
|
506
|
+
const contextFrom = resolveContextFrom(options.contextFrom);
|
|
507
|
+
const ctx = requireContext(global());
|
|
508
|
+
const task = await ctx.qoren.agents.createScheduledTask(id, {
|
|
509
|
+
name, schedule: cron, prompt, timezone: options.timezone,
|
|
510
|
+
...delivery,
|
|
511
|
+
...(contextFrom && { contextFrom }),
|
|
512
|
+
});
|
|
513
|
+
emit(task, () => note(`Task ${bold(task.name)} queued for installation (${task.id}).`));
|
|
514
|
+
})());
|
|
515
|
+
agent
|
|
516
|
+
.command("task-set <id> <task-id> <name> <cron> <prompt>")
|
|
517
|
+
.description("Replace a scheduled task")
|
|
518
|
+
.option("--timezone <zone>", "IANA timezone")
|
|
519
|
+
.option("--delivery <policy>", `${DELIVERY_HELP}; unchanged when omitted`)
|
|
520
|
+
.option("--deliver-to <target>", DELIVER_TO_HELP)
|
|
521
|
+
.option("--disable", "keep the task without firing it")
|
|
522
|
+
.option("--enable", "resume a disabled task")
|
|
523
|
+
.option("--context-from <task>", `${CONTEXT_FROM_HELP}; replaces the current list, unchanged when omitted`, collectContextFrom)
|
|
524
|
+
.option("--no-context-from", "stop reading other tasks' output")
|
|
525
|
+
.action((id, taskId, name, cron, prompt, options) => run(async () => {
|
|
526
|
+
if (options.enable && options.disable)
|
|
527
|
+
throw new Error("Choose --enable or --disable.");
|
|
528
|
+
const contextFrom = resolveContextFrom(options.contextFrom);
|
|
529
|
+
// Check the flags on their own before a round trip; the existing task only matters for
|
|
530
|
+
// "--delivery chat" without a new target, which keeps the chat it already had.
|
|
531
|
+
if (options.delivery !== "chat")
|
|
532
|
+
resolveDelivery(options);
|
|
533
|
+
const ctx = requireContext(global());
|
|
534
|
+
const existing = (await ctx.qoren.agents.listScheduledTasks(id)).find(t => t.id === taskId);
|
|
535
|
+
if (!existing)
|
|
536
|
+
throw new Error(`Scheduled task ${taskId} was not found.`);
|
|
537
|
+
const task = await ctx.qoren.agents.replaceScheduledTask(id, taskId, {
|
|
538
|
+
name, schedule: cron, prompt,
|
|
539
|
+
timezone: options.timezone ?? existing.timezone,
|
|
540
|
+
enabled: options.enable ? true : options.disable ? false : existing.enabled,
|
|
541
|
+
...resolveDelivery(options, existing),
|
|
542
|
+
priority: existing.priority,
|
|
543
|
+
reservationBufferSec: existing.reservationBufferSec,
|
|
544
|
+
freezePeers: existing.freezePeers,
|
|
545
|
+
// Omitted keeps the chain the task has; an empty list clears it.
|
|
546
|
+
...(contextFrom && { contextFrom }),
|
|
547
|
+
});
|
|
548
|
+
emit(task, () => note(`Task ${bold(task.name)} queued for update.`));
|
|
549
|
+
})());
|
|
550
|
+
agent
|
|
551
|
+
.command("task-rm <id> <task-id>")
|
|
552
|
+
.description("Delete a scheduled task")
|
|
553
|
+
.action((id, taskId) => run(async () => {
|
|
554
|
+
const ctx = requireContext(global());
|
|
555
|
+
await ctx.qoren.agents.deleteScheduledTask(id, taskId);
|
|
556
|
+
emit({ accepted: true }, () => note("Task deletion queued."));
|
|
557
|
+
})());
|
|
558
|
+
agent
|
|
559
|
+
.command("keys <id>")
|
|
560
|
+
.description("List the keys an agent carries, and revoke the ones you set")
|
|
561
|
+
.option("--revoke <name...>", "take these keys off the agent (repeatable); only keys you set can go")
|
|
562
|
+
.option("--no-wait", "print the job id and exit instead of following it")
|
|
563
|
+
.action((id, options) => run(async () => {
|
|
564
|
+
const ctx = requireContext(global());
|
|
565
|
+
// Names only, always: the API cuts each .env line at its "=" on the
|
|
566
|
+
// host, so nothing here can print a secret even by accident.
|
|
567
|
+
if (!options.revoke?.length) {
|
|
568
|
+
const keys = await ctx.qoren.agents.envKeys(id);
|
|
569
|
+
emit(keys, () => {
|
|
570
|
+
if (keys.length === 0) {
|
|
571
|
+
note(dim("This agent carries no keys."));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
table(keys, [
|
|
575
|
+
{ header: "name", value: (k) => k.name },
|
|
576
|
+
{ header: "source", value: (k) => k.source },
|
|
577
|
+
{ header: "revocable", value: (k) => (k.revocable ? "yes" : "") },
|
|
578
|
+
]);
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const result = await ctx.qoren.agents.configure(id, {
|
|
583
|
+
removeEnvNames: options.revoke,
|
|
584
|
+
});
|
|
585
|
+
if (!options.wait) {
|
|
586
|
+
emit(result, () => note(`Job ${bold(result.jobId)} started.`));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
await followJob(ctx, result.jobId, `Revoking on ${bold(id)}…`);
|
|
590
|
+
emit({ ...result, ok: true, revoked: options.revoke }, () => note(`Revoked ${bold(options.revoke.join(", "))}.`));
|
|
591
|
+
})());
|
|
592
|
+
agent
|
|
593
|
+
.command("status <id>")
|
|
594
|
+
.description("Probe an agent and report what is wrong, if anything")
|
|
595
|
+
.action((id) => run(async () => {
|
|
596
|
+
const ctx = requireContext(global());
|
|
597
|
+
// Two calls because they answer different questions: the probe is live,
|
|
598
|
+
// the diagnostics are the recent failure history.
|
|
599
|
+
const [health, diagnostics] = await Promise.all([
|
|
600
|
+
ctx.qoren.agents.healthcheck(id),
|
|
601
|
+
ctx.qoren.agents.diagnostics(id),
|
|
602
|
+
]);
|
|
603
|
+
emit({ health, diagnostics }, () => {
|
|
604
|
+
process.stdout.write(`${JSON.stringify(health, null, 2)}\n`);
|
|
605
|
+
if (diagnostics.groups.length === 0) {
|
|
606
|
+
note(dim("\nNo recent failures."));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
note(bold("\nRecent failures"));
|
|
610
|
+
table(diagnostics.groups, [
|
|
611
|
+
{ header: "category", value: (g) => g.category },
|
|
612
|
+
{ header: "tool", value: (g) => g.tool ?? "" },
|
|
613
|
+
{ header: "count", value: (g) => String(g.count) },
|
|
614
|
+
{ header: "sample", value: (g) => g.sample },
|
|
615
|
+
]);
|
|
616
|
+
});
|
|
617
|
+
})());
|
|
618
|
+
agent
|
|
619
|
+
.command("doctor <id>")
|
|
620
|
+
.description("Run the runtime's own doctor and let it repair what it finds; --history shows past checkups")
|
|
621
|
+
.option("--no-repair", "report only, do not let the runtime repair itself")
|
|
622
|
+
.option("--no-wait", "return as soon as the checkup is underway")
|
|
623
|
+
.option("--history", "list recent checkups instead of running one")
|
|
624
|
+
.option("--limit <n>", "checkups to list with --history", "10")
|
|
625
|
+
.action((id, options) => run(async () => {
|
|
626
|
+
const ctx = requireContext(global());
|
|
627
|
+
if (options.history) {
|
|
628
|
+
const runs = await ctx.qoren.agents.doctorRuns(id, Number(options.limit));
|
|
629
|
+
emit(runs, () => doctorTable(runs));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
let current = await ctx.qoren.agents.runDoctor(id, {
|
|
633
|
+
repair: options.repair,
|
|
634
|
+
});
|
|
635
|
+
if (options.wait && current.status === "running") {
|
|
636
|
+
note(dim("Checkup underway; a repair can take a few minutes."));
|
|
637
|
+
current = await waitForDoctor(ctx, id, current.id);
|
|
638
|
+
}
|
|
639
|
+
emit(current, () => printDoctorRun(current));
|
|
640
|
+
if (current.status === "needs_attention" ||
|
|
641
|
+
current.status === "failed") {
|
|
642
|
+
process.exitCode = 1;
|
|
643
|
+
}
|
|
644
|
+
})());
|
|
645
|
+
agent
|
|
646
|
+
.command("health")
|
|
647
|
+
.description("Show the latest health check of every agent")
|
|
648
|
+
.action(() => run(async () => {
|
|
649
|
+
const ctx = requireContext(global());
|
|
650
|
+
const [readings, agents] = await Promise.all([
|
|
651
|
+
ctx.qoren.agents.health(),
|
|
652
|
+
ctx.qoren.agents.list(),
|
|
653
|
+
]);
|
|
654
|
+
const names = new Map(agents.map((a) => [a._id, a.name]));
|
|
655
|
+
emit(readings, () => table(readings, [
|
|
656
|
+
{ header: "agent", value: (r) => names.get(r.agentId) ?? r.agentId },
|
|
657
|
+
{ header: "status", value: (r) => r.status },
|
|
658
|
+
{ header: "detail", value: (r) => r.detail ?? "" },
|
|
659
|
+
{ header: "checked", value: (r) => age(new Date(r.checkedAt).toISOString()) },
|
|
660
|
+
]));
|
|
661
|
+
})());
|
|
662
|
+
agent
|
|
663
|
+
.command("telemetry <id>")
|
|
664
|
+
.description("Show an agent's model spend")
|
|
665
|
+
.option("--days <n>", "window in days")
|
|
666
|
+
.action((id, options) => run(async () => {
|
|
667
|
+
const ctx = requireContext(global());
|
|
668
|
+
const data = await ctx.qoren.agents.telemetry(id, options.days ? Number.parseInt(options.days, 10) : undefined);
|
|
669
|
+
emit(data, () => process.stdout.write(`${JSON.stringify(data, null, 2)}\n`));
|
|
670
|
+
})());
|
|
671
|
+
agent
|
|
672
|
+
.command("usage <id>")
|
|
673
|
+
.description("Show what an agent spent: credits charged, and its sessions on either key")
|
|
674
|
+
.option("--days <n>", "window in days (1 to 90)", "30")
|
|
675
|
+
.action((id, options) => run(async () => {
|
|
676
|
+
const ctx = requireContext(global());
|
|
677
|
+
const data = await ctx.qoren.agents.usage(id, Number.parseInt(options.days, 10));
|
|
678
|
+
emit(data, () => printUsage(data));
|
|
679
|
+
})());
|
|
680
|
+
agent
|
|
681
|
+
.command("limits <id>")
|
|
682
|
+
.description("Show an agent's daily, weekly and monthly spend caps in credits, or change them")
|
|
683
|
+
.option("--daily <credits>", 'cap per UTC day, or "off" to remove it', parseSpendCap)
|
|
684
|
+
.option("--weekly <credits>", 'cap per UTC week (Monday to Sunday), or "off"', parseSpendCap)
|
|
685
|
+
.option("--monthly <credits>", 'cap per UTC month, or "off"', parseSpendCap)
|
|
686
|
+
.action((id, options) => run(async () => {
|
|
687
|
+
const ctx = requireContext(global());
|
|
688
|
+
const current = await ctx.qoren.agents.getSpendLimits(id);
|
|
689
|
+
const caps = mergeSpendCaps(current, options);
|
|
690
|
+
if (caps === null) {
|
|
691
|
+
emit(current, () => printSpendLimits(current));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const updated = await ctx.qoren.agents.setSpendLimits(id, caps);
|
|
695
|
+
note("Spend limits saved.");
|
|
696
|
+
emit(updated, () => printSpendLimits(updated));
|
|
697
|
+
const reached = capsAlreadyReached(updated);
|
|
698
|
+
if (updated.key === "agent" && reached.length > 0) {
|
|
699
|
+
warn(reached.length === 1
|
|
700
|
+
? `The ${reached[0]} cap is already reached, so the agent's model calls stop until that window resets.`
|
|
701
|
+
: `The ${reached.join(" and ")} caps are already reached, so the agent's model calls stop until those windows reset.`);
|
|
702
|
+
}
|
|
703
|
+
})());
|
|
704
|
+
agent
|
|
705
|
+
.command("chatgpt-login <id>")
|
|
706
|
+
.description("Sign an agent into your ChatGPT account with a one-time code")
|
|
707
|
+
.option("--no-wait", "print the job id and exit instead of following it")
|
|
708
|
+
.action((id, options) => run(async () => {
|
|
709
|
+
const ctx = requireContext(global());
|
|
710
|
+
const started = await ctx.qoren.agents.startDeviceLogin(id);
|
|
711
|
+
if (!options.wait) {
|
|
712
|
+
emit(started, () => note(`Job ${bold(started.jobId)} started.`));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Half of this flow happens in a browser, so the code has to be on
|
|
716
|
+
// screen before anything else is said.
|
|
717
|
+
const prompt = await awaitDeviceCode(ctx, started.jobId);
|
|
718
|
+
if (prompt) {
|
|
719
|
+
emit({ jobId: started.jobId, ...prompt }, () => {
|
|
720
|
+
note(`\nOpen ${bold(prompt.verificationUrl)} and enter this code:`);
|
|
721
|
+
note(`\n ${bold(prompt.userCode)}\n`);
|
|
722
|
+
note(dim("The code is good for about 15 minutes. This command waits until you approve."));
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
const job = await followJob(ctx, started.jobId, prompt ? dim("Waiting for your approval…") : dim("Signing in…"));
|
|
726
|
+
const authMode = job.result?.authMode ?? "chatgpt";
|
|
727
|
+
emit({ jobId: started.jobId, ok: true, authMode }, () => note("Signed in. This agent's turns now run on your ChatGPT plan credits instead of the platform inference key."));
|
|
728
|
+
})());
|
|
729
|
+
agent
|
|
730
|
+
.command("chatgpt-logout <id>")
|
|
731
|
+
.description("Sign an agent out of ChatGPT and back onto the platform key")
|
|
732
|
+
.action((id) => run(async () => {
|
|
733
|
+
const ctx = requireContext(global());
|
|
734
|
+
const result = await ctx.qoren.agents.deviceLogout(id);
|
|
735
|
+
// The credential lives on the environment, so this takes effect on the
|
|
736
|
+
// agent's next turn rather than on one already running.
|
|
737
|
+
emit(result, () => note(`Signed out. From its next turn the agent runs on the platform inference key (${result.mode}).`));
|
|
738
|
+
})());
|
|
739
|
+
// --- public file links ---------------------------------------------------
|
|
740
|
+
//
|
|
741
|
+
// The same links an agent mints for itself with files_share. A link is a URL
|
|
742
|
+
// with no sign-in behind it, so the create response is the only place its
|
|
743
|
+
// token ever appears: `share` writes the URL to stdout and nothing else, which
|
|
744
|
+
// is also what makes it pipeable into a message. The URL is a page on the
|
|
745
|
+
// console that renders the file; the recipient downloads from there if they
|
|
746
|
+
// want the file itself.
|
|
747
|
+
agent
|
|
748
|
+
.command("share <id> <path>")
|
|
749
|
+
.description("Publish a workspace file as an expiring public URL")
|
|
750
|
+
.option("--expires <duration>", "how long the link works, e.g. 30m, 2h, 3d", "7d")
|
|
751
|
+
.option("--label <text>", "a short note so you can tell links apart")
|
|
752
|
+
.option("--max-downloads <n>", "stop serving after this many opens or downloads")
|
|
753
|
+
.action((id, path, options) => run(async () => {
|
|
754
|
+
const ctx = requireContext(global());
|
|
755
|
+
const expiresInSeconds = parseDuration(options.expires);
|
|
756
|
+
if (expiresInSeconds === null) {
|
|
757
|
+
throw new Error(`Could not read "${options.expires}" as a duration. Use a number and a unit, like 30m, 2h or 3d.`);
|
|
758
|
+
}
|
|
759
|
+
let maxDownloads;
|
|
760
|
+
if (options.maxDownloads !== undefined) {
|
|
761
|
+
maxDownloads = Number.parseInt(options.maxDownloads, 10);
|
|
762
|
+
if (!Number.isFinite(maxDownloads) || maxDownloads < 1) {
|
|
763
|
+
throw new Error("--max-downloads must be a whole number of 1 or more.");
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const link = await ctx.qoren.agents.createFileLink(id, {
|
|
767
|
+
path,
|
|
768
|
+
expiresInSeconds,
|
|
769
|
+
...(options.label ? { label: options.label } : {}),
|
|
770
|
+
...(maxDownloads !== undefined ? { maxDownloads } : {}),
|
|
771
|
+
});
|
|
772
|
+
emit(link, () => {
|
|
773
|
+
// The URL is the whole point of the command, so it is the only thing
|
|
774
|
+
// on stdout. Everything explaining it goes to stderr.
|
|
775
|
+
if (link.url)
|
|
776
|
+
process.stdout.write(`${link.url}\n`);
|
|
777
|
+
note(dim(`Anyone with this URL can open ${link.path} in a browser, and download it from there, until ${new Date(link.expiresAt).toISOString()}, with no sign-in.`));
|
|
778
|
+
warn("This URL is shown once. Revoke it with qoren agent unshare once it has been collected.");
|
|
779
|
+
});
|
|
780
|
+
})());
|
|
781
|
+
agent
|
|
782
|
+
.command("links <id>")
|
|
783
|
+
.description("List the files this agent has published as public links")
|
|
784
|
+
.action((id) => run(async () => {
|
|
785
|
+
const ctx = requireContext(global());
|
|
786
|
+
const links = await ctx.qoren.agents.listFileLinks(id);
|
|
787
|
+
emit(links, () => {
|
|
788
|
+
if (links.length === 0) {
|
|
789
|
+
note(dim("This agent has published nothing."));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
// No url column: a listing cannot read a token back, so printing an
|
|
793
|
+
// empty one would only look like something had gone missing.
|
|
794
|
+
table(links, [
|
|
795
|
+
{ header: "linkId", value: (l) => l.linkId },
|
|
796
|
+
{ header: "path", value: (l) => l.path },
|
|
797
|
+
{ header: "label", value: (l) => l.label ?? "" },
|
|
798
|
+
{
|
|
799
|
+
header: "status",
|
|
800
|
+
value: (l) => statusColor(l.status),
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
header: "expires",
|
|
804
|
+
value: (l) => new Date(l.expiresAt).toISOString(),
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
// Opening the link counts here too: the viewer fetches the file to show it.
|
|
808
|
+
header: "opened",
|
|
809
|
+
value: (l) => l.maxDownloads == null
|
|
810
|
+
? String(l.downloadCount)
|
|
811
|
+
: `${l.downloadCount}/${l.maxDownloads}`,
|
|
812
|
+
},
|
|
813
|
+
]);
|
|
814
|
+
});
|
|
815
|
+
})());
|
|
816
|
+
agent
|
|
817
|
+
.command("unshare <id> <linkId>")
|
|
818
|
+
.description("Switch off a public link (see qoren agent links)")
|
|
819
|
+
.action((id, linkId) => run(async () => {
|
|
820
|
+
const ctx = requireContext(global());
|
|
821
|
+
const link = await ctx.qoren.agents.revokeFileLink(id, linkId);
|
|
822
|
+
emit(link, () => note(`Revoked. The URL for ${bold(link.path)} no longer works for anyone holding it.`));
|
|
823
|
+
})());
|
|
824
|
+
agent
|
|
825
|
+
.command("import <bundle>")
|
|
826
|
+
.description("Deploy an agent from an export made with `curl -fsSL https://qoren.sh/migrate.sh | sh`")
|
|
827
|
+
.option("--env <id>", "environment to deploy onto (required unless --dry-run)")
|
|
828
|
+
.option("--name <name>", "display name (defaults to the bundle's file name)")
|
|
829
|
+
.option("--slug <slug>", "machine identity (defaults to the name)")
|
|
830
|
+
.option("--model <model>", "model id (defaults to what the export used, then your account default)")
|
|
831
|
+
.option("--template <name>", "name for the saved template (defaults to the agent name)")
|
|
832
|
+
.option("--import-secrets", "store the secret values carried by a --with-secrets export in the vault and attach them")
|
|
833
|
+
.option("--dry-run", "print what would be sent and stop before any API call")
|
|
834
|
+
.option("--no-wait", "print the job id and exit instead of following it")
|
|
835
|
+
.action((bundlePath, options) => run(async () => {
|
|
836
|
+
const bundle = parseBundle(readTar(gunzipSync(readFileSync(bundlePath))));
|
|
837
|
+
const plan = planImport(bundle);
|
|
838
|
+
const name = options.name ??
|
|
839
|
+
basename(bundlePath)
|
|
840
|
+
.replace(/\.tar\.gz$|\.tgz$/i, "")
|
|
841
|
+
.replace(/^qoren-export-/, "")
|
|
842
|
+
.replace(/-\d{8}-\d{6}$/, "");
|
|
843
|
+
const slug = options.slug ?? slugify(name);
|
|
844
|
+
if (!slug) {
|
|
845
|
+
throw new Error("Could not derive a slug from that name. Pass --slug explicitly.");
|
|
846
|
+
}
|
|
847
|
+
const templateName = options.template ?? name;
|
|
848
|
+
if (options.dryRun) {
|
|
849
|
+
const preview = {
|
|
850
|
+
...plan,
|
|
851
|
+
name,
|
|
852
|
+
slug,
|
|
853
|
+
templateName,
|
|
854
|
+
// Values never belong in a preview; names say what would be set.
|
|
855
|
+
secrets: Object.keys(plan.secrets),
|
|
856
|
+
workspaceFiles: plan.workspaceFiles.map((f) => f.path),
|
|
857
|
+
runtimeFiles: plan.runtimeFiles.map((f) => f.path),
|
|
858
|
+
};
|
|
859
|
+
emit(preview, () => {
|
|
860
|
+
details([
|
|
861
|
+
["runtime", plan.runtime],
|
|
862
|
+
["model", plan.model ?? dim("(account default)")],
|
|
863
|
+
["name", `${name} (${slug})`],
|
|
864
|
+
["persona", `${plan.soulMd.length} chars`],
|
|
865
|
+
[
|
|
866
|
+
"operating manual",
|
|
867
|
+
plan.agentsMd ? `${plan.agentsMd.length} chars` : dim("none"),
|
|
868
|
+
],
|
|
869
|
+
[
|
|
870
|
+
"mcp servers",
|
|
871
|
+
plan.mcpServers.map((m) => m.name).join(", ") || dim("none"),
|
|
872
|
+
],
|
|
873
|
+
[
|
|
874
|
+
"scheduled tasks",
|
|
875
|
+
plan.scheduledTasks.map((t) => t.name).join(", ") ||
|
|
876
|
+
dim("none"),
|
|
877
|
+
],
|
|
878
|
+
["seed files", String(plan.workspaceFiles.length)],
|
|
879
|
+
["runtime files", String(plan.runtimeFiles.length)],
|
|
880
|
+
["env keys", plan.envNames.join(", ") || dim("none")],
|
|
881
|
+
[
|
|
882
|
+
"secret values",
|
|
883
|
+
Object.keys(plan.secrets).join(", ") || dim("none"),
|
|
884
|
+
],
|
|
885
|
+
]);
|
|
886
|
+
for (const line of plan.notes)
|
|
887
|
+
warn(line);
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (!options.env) {
|
|
892
|
+
throw new Error("Pass --env <environment-id> (see qoren env ls), or --dry-run to preview.");
|
|
893
|
+
}
|
|
894
|
+
const ctx = requireContext(global());
|
|
895
|
+
for (const line of plan.notes)
|
|
896
|
+
warn(line);
|
|
897
|
+
const model = options.model ??
|
|
898
|
+
plan.model ??
|
|
899
|
+
(await ctx.qoren.account.options()).defaultModel;
|
|
900
|
+
if (!model) {
|
|
901
|
+
throw new Error("No model available. Pass --model explicitly.");
|
|
902
|
+
}
|
|
903
|
+
const result = await applyPlan(ctx, plan, {
|
|
904
|
+
environmentId: options.env,
|
|
905
|
+
name,
|
|
906
|
+
slug,
|
|
907
|
+
runtime: plan.runtime,
|
|
908
|
+
model,
|
|
909
|
+
templateName,
|
|
910
|
+
importSecrets: options.importSecrets === true,
|
|
911
|
+
wait: options.wait,
|
|
912
|
+
});
|
|
913
|
+
emit(result, () => {
|
|
914
|
+
if (!result.ok) {
|
|
915
|
+
note(`Job ${bold(result.jobId)} started. Template saved as ${bold(result.templateSlug)}.`);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
note(`Agent ${bold(name)} is running on Qoren.`);
|
|
919
|
+
if (result.runtimeFilesCopied > 0) {
|
|
920
|
+
note(` ${dim("·")} ${result.runtimeFilesCopied} memory/skill file(s) copied into the runtime home`);
|
|
921
|
+
}
|
|
922
|
+
if (plan.envNames.some((n) => !result.secretsSet.includes(n))) {
|
|
923
|
+
note(` ${dim("·")} attach the remaining keys from the console's Secrets tab`);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
})());
|
|
927
|
+
customAgentCommands(agent, global);
|
|
928
|
+
}
|
|
929
|
+
// The usage summary for a terminal: the settled credits first (what the
|
|
930
|
+
// account paid), then the session ledger grouped by source (what the agent
|
|
931
|
+
// did, whichever key paid for it).
|
|
932
|
+
function printUsage(data) {
|
|
933
|
+
const n = (value) => Math.round(value).toLocaleString("en-US");
|
|
934
|
+
const c = data.charges;
|
|
935
|
+
details([
|
|
936
|
+
["Window", `${new Date(data.from).toLocaleDateString()} to now`],
|
|
937
|
+
["Key", data.billingMode === "byok" ? "your own key" : data.billingMode],
|
|
938
|
+
["Model credits", n(c.llmCredits)],
|
|
939
|
+
["Web search", `${n(c.webSearchCredits)} credits, ${n(c.webSearches)} searches`],
|
|
940
|
+
[
|
|
941
|
+
"Environment",
|
|
942
|
+
`${n(c.environmentCredits)} credits, shared by ${c.environmentAgents} agents`,
|
|
943
|
+
],
|
|
944
|
+
["Unsettled", c.pendingSettlements > 0 ? `${c.pendingSettlements} charges` : ""],
|
|
945
|
+
]);
|
|
946
|
+
const ledger = data.ledger;
|
|
947
|
+
if (ledger.status !== "ok") {
|
|
948
|
+
note(dim(ledger.detail ?? `Session ledger: ${ledger.status}.`));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const bySource = new Map();
|
|
952
|
+
for (const b of ledger.buckets) {
|
|
953
|
+
const source = ledger.sources[b[1] ?? 0] ?? "unknown";
|
|
954
|
+
const row = bySource.get(source) ?? { source, sessions: 0, tokens: 0, costUsd: 0 };
|
|
955
|
+
row.sessions += b[3] ?? 0;
|
|
956
|
+
row.tokens += (b[4] ?? 0) + (b[5] ?? 0) + (b[6] ?? 0) + (b[7] ?? 0) + (b[8] ?? 0);
|
|
957
|
+
row.costUsd += b[9] ?? 0;
|
|
958
|
+
bySource.set(source, row);
|
|
959
|
+
}
|
|
960
|
+
process.stdout.write("\n");
|
|
961
|
+
table([...bySource.values()].sort((a, b) => b.costUsd - a.costUsd), [
|
|
962
|
+
{ header: "Source", value: (r) => r.source },
|
|
963
|
+
{ header: "Sessions", value: (r) => n(r.sessions) },
|
|
964
|
+
{ header: "Tokens", value: (r) => n(r.tokens) },
|
|
965
|
+
{ header: "Provider cost", value: (r) => `$${r.costUsd.toFixed(4)}` },
|
|
966
|
+
]);
|
|
967
|
+
}
|
|
968
|
+
//# sourceMappingURL=agent.js.map
|