@meffecta/agent 1.1.1 → 1.1.2

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/engine.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "engineTag": "1.1.1",
3
- "builtFrom": "ab3338abfafb491dc74fc4480d5ecbd6421fb827"
2
+ "engineTag": "1.1.2",
3
+ "builtFrom": "d7e2ae69bb32ec92bbc927d67cc0c96190d2789a"
4
4
  }
package/lib/commands.js CHANGED
@@ -8,6 +8,7 @@ import { checkSystems, createSystem } from "./create-system.js";
8
8
  import { runDoctor } from "./doctor.js";
9
9
  import { parseFlags } from "./flags.js";
10
10
  import { api, requireCommand, stream } from "./gcloud.js";
11
+ import { history } from "./history.js";
11
12
  import { connect } from "./integrations.js";
12
13
  import { resources } from "./resources.js";
13
14
  import { verifyCredentials } from "./verify-credentials.js";
@@ -311,7 +312,15 @@ async function jobs() {
311
312
  }
312
313
 
313
314
  async function runJob(args) {
314
- const { flags, positional } = parseFlags(args, { in: { type: "int", min: 1, max: 3600 } });
315
+ const { flags, positional, help } = parseFlags(
316
+ args,
317
+ { in: { type: "int", min: 1, max: 3600 } },
318
+ { usage: "meffecta-agent run <job> [--in <seconds>]" },
319
+ );
320
+ if (help) {
321
+ console.log(help);
322
+ return 0;
323
+ }
315
324
  const name = positional[0];
316
325
  if (!name) {
317
326
  throw new UserError("Which job? Try: meffecta-agent jobs");
@@ -332,11 +341,19 @@ async function ask(args) {
332
341
  // model and effort are deliberately not validated here. The engine owns that list, this
333
342
  // CLI may be deploying an engine older or newer than itself, and /test already answers
334
343
  // with the valid values — so the authority stays in one place and the error stays good.
335
- const { flags, positional } = parseFlags(args, {
336
- model: {},
337
- effort: {},
338
- timeoutSeconds: { type: "int", min: 1, max: 3600 },
339
- });
344
+ const { flags, positional, help } = parseFlags(
345
+ args,
346
+ {
347
+ model: {},
348
+ effort: {},
349
+ timeoutSeconds: { type: "int", min: 1, max: 3600 },
350
+ },
351
+ { usage: 'meffecta-agent ask "how did search do last week" [--model M] [--effort E]' },
352
+ );
353
+ if (help) {
354
+ console.log(help);
355
+ return 0;
356
+ }
340
357
  const prompt = positional.join(" ");
341
358
  if (!prompt) {
342
359
  throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
@@ -539,7 +556,15 @@ async function triggers() {
539
556
  /** Recent service logs, without making anyone remember the filter syntax. */
540
557
  async function logs(args) {
541
558
  requireCommand("gcloud", "logs come from Cloud Logging");
542
- const { flags } = parseFlags(args, { limit: { type: "int", min: 1, max: 1000 } });
559
+ const { flags, help } = parseFlags(
560
+ args,
561
+ { limit: { type: "int", min: 1, max: 1000 } },
562
+ { usage: "meffecta-agent logs [--limit N]" },
563
+ );
564
+ if (help) {
565
+ console.log(help);
566
+ return 0;
567
+ }
543
568
  const d = requireDeployment();
544
569
  const limit = String(flags.limit ?? 40);
545
570
  const { code } = await stream("gcloud", [
@@ -772,6 +797,7 @@ export const GROUPS = [
772
797
  ["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
773
798
  ["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
774
799
  ["logs", "Recent service logs (--limit N)", logs],
800
+ ["history", "What it has actually run: outcome, duration and cost per run (--job, --since, --failed)", history],
775
801
  ["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
776
802
  ["check-systems", "Validate the register here, and every job's systems: against it", checkSystems],
777
803
  ["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
package/lib/create-job.js CHANGED
@@ -215,7 +215,15 @@ function buildPrompt(description, { timezone, existing }) {
215
215
  }
216
216
 
217
217
  export async function createJob(args) {
218
- const { flags, positional } = parseFlags(args, { name: {}, model: {}, "dry-run": { type: "boolean" } });
218
+ const { flags, positional, help } = parseFlags(
219
+ args,
220
+ { name: {}, model: {}, "dry-run": { type: "boolean" } },
221
+ { usage: 'meffecta-agent create-job "a report every Wednesday at 2pm" [--name <file>] [--dry-run]' },
222
+ );
223
+ if (help) {
224
+ console.log(help);
225
+ return 0;
226
+ }
219
227
  const description = positional.join(" ").trim();
220
228
  if (!description) {
221
229
  throw new UserError(
@@ -242,7 +242,15 @@ function configuredVariables(d) {
242
242
  }
243
243
 
244
244
  export async function createSystem(args) {
245
- const { flags, positional } = parseFlags(args, { name: {}, model: {}, "dry-run": { type: "boolean" } });
245
+ const { flags, positional, help } = parseFlags(
246
+ args,
247
+ { name: {}, model: {}, "dry-run": { type: "boolean" } },
248
+ { usage: 'meffecta-agent create-system "our HubSpot CRM" [--name <file>] [--dry-run]' },
249
+ );
250
+ if (help) {
251
+ console.log(help);
252
+ return 0;
253
+ }
246
254
  const description = positional.join(" ").trim();
247
255
  if (!description) {
248
256
  throw new UserError(
package/lib/doctor.js CHANGED
@@ -347,7 +347,7 @@ export async function doctor() {
347
347
  if (badAuth.length) {
348
348
  r.fail(
349
349
  "An inbox job has a requireAuth: it cannot honor",
350
- `${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — AgentMail authenticates mail itself and reports no verdict to check. Such a job refuses every message.`,
350
+ `${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — neither AgentMail nor Graph reports a per-message verdict this engine can check. Such a job refuses every message.`,
351
351
  "Fix the value, or remove the line",
352
352
  );
353
353
  } else if (unauthenticated.length) {
@@ -357,6 +357,15 @@ export async function doctor() {
357
357
  "Add requireAuth: dmarc to the job",
358
358
  );
359
359
  }
360
+ // Not a fix the operator can apply in the job file, so it is stated once rather than
361
+ // nagged about: on these, allowFrom: is the whole of the protection.
362
+ const unverifiable = inboxJobs.filter((j) => !j.inbox.startsWith("gmail:"));
363
+ if (unverifiable.length) {
364
+ r.ok(
365
+ `${unverifiable.length} inbox job(s) rely on allowFrom: alone`,
366
+ `${unverifiable.map((j) => `${j.name} (${j.inbox.split(":")[0]})`).join(", ")} — no per-message verdict to check on these systems, so the sender list is the whole of the protection`,
367
+ );
368
+ }
360
369
 
361
370
  // --- what each job may reach ------------------------------------------------
362
371
  const register = readLocalSystems(`${process.cwd()}/systems`);
package/lib/flags.js CHANGED
@@ -11,11 +11,19 @@ import { UserError } from "./config.js";
11
11
  *
12
12
  * Supports `--name value`, `--name=value`, boolean flags, and `--` to end flag parsing.
13
13
  */
14
- export function parseFlags(argv, spec = {}) {
14
+ export function parseFlags(argv, spec = {}, { usage } = {}) {
15
15
  const flags = {};
16
16
  const positional = [];
17
17
  let onlyPositional = false;
18
18
 
19
+ // `help` is answered here rather than by each command, because the spec is the only
20
+ // description of a command's options that exists — and the top-level help tells people
21
+ // this works. It comes back as a flag rather than printing and exiting, so the caller
22
+ // still owns its own exit code and this stays testable.
23
+ if (argv.includes("--help") || argv.includes("-h")) {
24
+ return { flags, positional, help: describeFlags(spec, usage) };
25
+ }
26
+
19
27
  for (let i = 0; i < argv.length; i += 1) {
20
28
  const token = argv[i];
21
29
 
@@ -78,5 +86,36 @@ export function parseFlags(argv, spec = {}) {
78
86
  flags[name] = raw;
79
87
  }
80
88
 
81
- return { flags, positional };
89
+ return { flags, positional, help: undefined };
90
+ }
91
+
92
+ /** What a command takes, from the only place that knows: its own spec. */
93
+ export function describeFlags(spec, usage) {
94
+ const lines = usage ? [usage, ""] : [];
95
+ const names = Object.keys(spec);
96
+ if (names.length === 0) {
97
+ return [...lines, "This command takes no options."].join("\n");
98
+ }
99
+ const shown = names.map((name) => {
100
+ const definition = spec[name];
101
+ if (definition.type === "boolean") {
102
+ return [`--${name}`, ""];
103
+ }
104
+ if (definition.type === "int") {
105
+ const bounds = [
106
+ definition.min !== undefined && `min ${definition.min}`,
107
+ definition.max !== undefined && `max ${definition.max}`,
108
+ ]
109
+ .filter(Boolean)
110
+ .join(", ");
111
+ return [`--${name} <n>`, bounds];
112
+ }
113
+ return [`--${name} <value>`, definition.choices ? `one of: ${definition.choices.join(", ")}` : ""];
114
+ });
115
+ const width = Math.max(...shown.map(([flag]) => flag.length));
116
+ return [
117
+ ...lines,
118
+ "Options:",
119
+ ...shown.map(([flag, note]) => ` ${flag.padEnd(width)}${note ? ` ${note}` : ""}`),
120
+ ].join("\n");
82
121
  }
package/lib/history.js ADDED
@@ -0,0 +1,360 @@
1
+ import { requireDeployment, UserError } from "./config.js";
2
+ import { parseFlags } from "./flags.js";
3
+ import { capture, gcloudArgs, probe, requireCommand } from "./gcloud.js";
4
+
5
+ /**
6
+ * What the deployment has actually been doing.
7
+ *
8
+ * Every other read this CLI offers is present-tense — `jobs` is the boot-time registration
9
+ * list, `queue` is what is pending, `status` is right now. The engine records the past
10
+ * faithfully, one `job-end` object per run in the audit bucket with the outcome, the
11
+ * duration and what the model cost, and until this there was no way to read it back short
12
+ * of composing a storage query by hand.
13
+ *
14
+ * **It reads the bucket, not the service.** That is deliberate on a scale-to-zero
15
+ * deployment: asking the engine would wake an instance and bill for it, and the records are
16
+ * not in the engine anyway. Looking at a year of history should not cost a cold start.
17
+ *
18
+ * The object names are what make this cheap without an index:
19
+ * `YYYY/MM/DD/HH-MM-SS.mmm_job-end_<job>.json` sorts lexicographically into chronological
20
+ * order and carries the job in the name, so the listing narrows by day and by job, and only
21
+ * the handful of records actually being shown are fetched.
22
+ */
23
+
24
+ /** One run, as `job-end` records it. */
25
+ const RECORD_KIND = "job-end";
26
+ const DAY_MS = 86_400_000;
27
+
28
+ /** A year, so a mistyped date cannot turn into a listing of the whole bucket. */
29
+ const MAX_DAYS = 366;
30
+
31
+ function utcMidnight(date) {
32
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
33
+ }
34
+
35
+ function parseDay(value, flag) {
36
+ const at = Date.parse(`${value.trim()}T00:00:00Z`);
37
+ if (Number.isNaN(at)) {
38
+ throw new UserError(`${flag} takes a date like 2026-08-01 (got "${value}").`);
39
+ }
40
+ return at;
41
+ }
42
+
43
+ /**
44
+ * The window to look in, as UTC midnights. `--since` is where to start — a date, or `7d` /
45
+ * `36h` counted back from the end — and `--until` is where to stop, so a closed range like
46
+ * `--since 2026-08-01 --until 2026-08-07` asks about that week and nothing outside it.
47
+ *
48
+ * Both ends are inclusive whole days, because the audit trail is partitioned by day and a
49
+ * half-day window would be a promise the listing cannot keep.
50
+ */
51
+ export function resolveWindow({ since, until } = {}, now = new Date()) {
52
+ const end = until ? parseDay(until, "--until") : utcMidnight(now);
53
+ let start;
54
+ if (!since) {
55
+ start = end - 6 * DAY_MS;
56
+ } else {
57
+ const relative = /^(\d+)([dh])$/.exec(since.trim());
58
+ if (relative) {
59
+ const count = Number(relative[1]);
60
+ const days = relative[2] === "d" ? Math.max(count, 1) : Math.max(Math.ceil(count / 24), 1);
61
+ start = end - (days - 1) * DAY_MS;
62
+ } else {
63
+ start = parseDay(since, "--since");
64
+ }
65
+ }
66
+ if (start > end) {
67
+ throw new UserError(
68
+ until
69
+ ? `--since ${since} is after --until ${until}.`
70
+ : `--since ${since} is in the future — there is no history there yet.`,
71
+ );
72
+ }
73
+ const days = (end - start) / DAY_MS + 1;
74
+ if (days > MAX_DAYS) {
75
+ throw new UserError(`That is ${days} days. Ask for at most ${MAX_DAYS} at a time.`);
76
+ }
77
+ return { start, end, days };
78
+ }
79
+
80
+ /** The UTC day prefixes a window covers, oldest first — the audit trail's own partitioning. */
81
+ export function dayPrefixes(window) {
82
+ const prefixes = [];
83
+ for (let at = window.start; at <= window.end; at += DAY_MS) {
84
+ const day = new Date(at);
85
+ prefixes.push(
86
+ `${day.getUTCFullYear()}/${String(day.getUTCMonth() + 1).padStart(2, "0")}/${String(day.getUTCDate()).padStart(2, "0")}`,
87
+ );
88
+ }
89
+ return prefixes;
90
+ }
91
+
92
+ /**
93
+ * A job name as the audit trail spells it: `recordAudit` replaces everything outside
94
+ * `[A-Za-z0-9._-]` with `-`, so a filter has to ask the same question the writer answered.
95
+ */
96
+ export function qualifier(job) {
97
+ return job.replace(/[^a-zA-Z0-9._-]/g, "-");
98
+ }
99
+
100
+ /** Where this deployment writes its audit trail, read off the service. */
101
+ function auditBucket(d) {
102
+ const svc = JSON.parse(
103
+ capture(
104
+ "gcloud",
105
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
106
+ ),
107
+ );
108
+ const bucket = (svc.spec?.template?.spec?.containers?.[0]?.env ?? []).find((e) => e.name === "AUDIT_BUCKET")?.value;
109
+ if (!bucket) {
110
+ throw new UserError(
111
+ "This deployment has no AUDIT_BUCKET set, so nothing records what its runs did.\n\n" +
112
+ " meffecta-agent setup-infra\n\nprovisions the bucket and sets it.",
113
+ );
114
+ }
115
+ return bucket;
116
+ }
117
+
118
+ /** When the run ended, in the reader's own timezone. The record carries its own UTC stamp. */
119
+ export function localTime(record) {
120
+ const at = record.at ? new Date(record.at) : undefined;
121
+ return at && !Number.isNaN(at.getTime())
122
+ ? at.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })
123
+ : "—";
124
+ }
125
+
126
+ export function formatDuration(ms) {
127
+ if (!Number.isFinite(ms)) {
128
+ return "—";
129
+ }
130
+ const seconds = Math.round(ms / 1000);
131
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
132
+ }
133
+
134
+ function formatCost(record) {
135
+ const usd = record.cli?.total_cost_usd;
136
+ return typeof usd === "number" ? `$${usd.toFixed(2)}` : "—";
137
+ }
138
+
139
+ export function describeTrigger(trigger) {
140
+ if (!trigger) {
141
+ return "—";
142
+ }
143
+ if (trigger.kind === "spawn") {
144
+ return `spawn:${trigger.label ?? "?"}`;
145
+ }
146
+ return String(trigger.kind ?? "—");
147
+ }
148
+
149
+ /** The first eight characters of a run id — what the table shows, and what `<id>` matches. */
150
+ export function shortId(runId) {
151
+ return typeof runId === "string" ? runId.slice(0, 8) : "—";
152
+ }
153
+
154
+ /** Newest-first object names, already narrowed to what will be fetched. */
155
+ export function selectRecords(names, { limit, job }) {
156
+ const wanted = job ? `_${RECORD_KIND}_${qualifier(job)}.json` : `_${RECORD_KIND}_`;
157
+ return names
158
+ .filter((name) => name.includes(wanted))
159
+ .sort()
160
+ .slice(-limit)
161
+ .reverse();
162
+ }
163
+
164
+ /** Indent a block so a multi-line result reads as one field rather than as more output. */
165
+ function indent(text, prefix = " ") {
166
+ return String(text)
167
+ .split("\n")
168
+ .map((line) => prefix + line)
169
+ .join("\n");
170
+ }
171
+
172
+ /**
173
+ * One run in full. The table answers "what has it been doing"; this answers "what happened
174
+ * in that one" — what it was told, what it could reach, what it produced, how it ended.
175
+ */
176
+ export function renderRun(record) {
177
+ const lines = [];
178
+ const status = record.status === "error" ? "FAILED" : (record.status ?? "—");
179
+ lines.push(`${record.jobName ?? "—"} · ${status} · ${localTime(record)}`, "");
180
+
181
+ const field = (label, value) => {
182
+ if (value !== undefined && value !== null && value !== "") {
183
+ lines.push(`${label.padEnd(11)}${value}`);
184
+ }
185
+ };
186
+ field("Run id", record.runId);
187
+ field("Trigger", describeTrigger(record.trigger));
188
+ field("Duration", formatDuration(record.durationMs));
189
+ field("Cost", formatCost(record));
190
+ const settings = record.jobSettings;
191
+ if (settings) {
192
+ field(
193
+ "Settings",
194
+ [
195
+ settings.model && `model ${settings.model}`,
196
+ settings.effort && `effort ${settings.effort}`,
197
+ settings.timeoutMs && `timeout ${Math.round(settings.timeoutMs / 1000)}s`,
198
+ settings.allowedTools && `tools ${settings.allowedTools}`,
199
+ ]
200
+ .filter(Boolean)
201
+ .join(", "),
202
+ );
203
+ }
204
+ if (record.scope) {
205
+ const { systems, variables, unknown } = record.scope;
206
+ field(
207
+ "Systems",
208
+ Array.isArray(systems)
209
+ ? `${systems.join(", ") || "none"}${typeof variables === "number" ? ` (${variables} variables)` : ""}${unknown?.length ? ` — unmatched: ${unknown.join(", ")}` : ""}`
210
+ : String(systems),
211
+ );
212
+ }
213
+ if (record.modelFallback) {
214
+ field("Fell back", `${record.modelFallback.from} → ${record.modelFallback.to} (${record.modelFallback.reason})`);
215
+ }
216
+
217
+ if (record.prompt) {
218
+ lines.push("", "Prompt", indent(record.prompt));
219
+ }
220
+ if (record.result) {
221
+ const truncated =
222
+ record.resultChars > record.result.length ? ` (first part of ${record.resultChars} characters)` : "";
223
+ lines.push("", `Result${truncated}`, indent(record.result));
224
+ }
225
+ if (record.error) {
226
+ lines.push("", "Error", indent(record.error));
227
+ }
228
+ if (record.stderr) {
229
+ lines.push("", "stderr", indent(record.stderr));
230
+ }
231
+ return lines.join("\n");
232
+ }
233
+
234
+ export async function history(args) {
235
+ requireCommand("gcloud", "run records are read from the audit bucket");
236
+ const { flags, positional, help } = parseFlags(
237
+ args,
238
+ {
239
+ job: { type: "string" },
240
+ since: { type: "string" },
241
+ until: { type: "string" },
242
+ limit: { type: "int", min: 1, max: 200 },
243
+ failed: { type: "boolean" },
244
+ json: { type: "boolean" },
245
+ },
246
+ {
247
+ usage:
248
+ "meffecta-agent history [<run id>] [options]\n\n" +
249
+ " history the last week of runs\n" +
250
+ " history --job morning-brief --since 30d one job, further back\n" +
251
+ " history --since 2026-08-01 --until 2026-08-07 a closed range\n" +
252
+ " history --failed only the ones that broke\n" +
253
+ " history 8b2811f7 one run in full, by the id in column one",
254
+ },
255
+ );
256
+ if (help) {
257
+ console.log(help);
258
+ return 0;
259
+ }
260
+ const wantedId = positional[0];
261
+ const d = requireDeployment();
262
+ const bucket = auditBucket(d);
263
+ const window = resolveWindow(flags);
264
+ const prefixes = dayPrefixes(window);
265
+ const span = prefixes.length === 1 ? prefixes[0] : `${prefixes[0]} to ${prefixes.at(-1)}`;
266
+ // Looking for one run means reading until it is found, so the cap is a search depth rather
267
+ // than a page size — and a run named by id is worth looking further back for.
268
+ const limit = flags.limit ?? (wantedId ? 200 : 20);
269
+
270
+ // One listing per day rather than one recursive listing of the bucket: a year of audit
271
+ // records is a large object count, and the window is almost always a few days of it. A
272
+ // day with no runs simply is not there, which `probe` reports as nothing, not a failure.
273
+ const listings = await Promise.all(
274
+ prefixes.map((prefix) => probe("gcloud", gcloudArgs(d, ["storage", "ls", `gs://${bucket}/${prefix}/**`]))),
275
+ );
276
+ const names = listings
277
+ .flatMap((out) => (out ?? "").split("\n"))
278
+ .map((line) => line.trim())
279
+ .filter((line) => line.startsWith("gs://"));
280
+
281
+ const chosen = selectRecords(names, { limit, job: flags.job });
282
+ if (chosen.length === 0) {
283
+ console.log(
284
+ `No runs recorded ${span}${flags.job ? ` for ${flags.job}` : ""}.\n\n` +
285
+ "The audit trail starts when a run ends, so a deployment that has not run yet is empty.\n" +
286
+ "Widen the window with --since 30d, or ask for a range: --since 2026-08-01 --until 2026-08-07.",
287
+ );
288
+ return 0;
289
+ }
290
+
291
+ // Fetched only for the records being shown — the listing above is names, the cheap half,
292
+ // and this is the one that costs a read per run.
293
+ const records = [];
294
+ for (const name of chosen) {
295
+ const body = await probe("gcloud", gcloudArgs(d, ["storage", "cat", name]));
296
+ if (!body) {
297
+ continue;
298
+ }
299
+ try {
300
+ const record = JSON.parse(body);
301
+ records.push(record);
302
+ // Newest first, so the wanted run is usually in the first few objects. Stopping there
303
+ // beats fetching the rest of the window to throw it away.
304
+ if (wantedId && String(record.runId ?? "").startsWith(wantedId)) {
305
+ break;
306
+ }
307
+ } catch {
308
+ // A truncated or half-written object is one run missing from a listing, not a reason
309
+ // to fail the whole read.
310
+ console.error(`(skipped an unreadable record: ${name.split("/").pop()})`);
311
+ }
312
+ }
313
+
314
+ if (wantedId) {
315
+ const match = records.find((r) => String(r.runId ?? "").startsWith(wantedId));
316
+ if (!match) {
317
+ throw new UserError(
318
+ `No run whose id starts with "${wantedId}" ${span}.\n\n` +
319
+ "Ids are the first column of `meffecta-agent history`. Look further back with --since.",
320
+ );
321
+ }
322
+ console.log(flags.json ? JSON.stringify(match, null, 2) : renderRun(match));
323
+ return 0;
324
+ }
325
+
326
+ const rows = flags.failed ? records.filter((r) => r.status === "error") : records;
327
+ if (flags.json) {
328
+ console.log(JSON.stringify(rows, null, 2));
329
+ return 0;
330
+ }
331
+ if (rows.length === 0) {
332
+ console.log(`No failed runs ${span}.`);
333
+ return 0;
334
+ }
335
+
336
+ const cells = rows.map((r) => [
337
+ shortId(r.runId),
338
+ localTime(r),
339
+ r.jobName ?? "—",
340
+ describeTrigger(r.trigger),
341
+ r.status ?? "—",
342
+ formatDuration(r.durationMs),
343
+ formatCost(r),
344
+ ]);
345
+ const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
346
+ for (const row of cells) {
347
+ console.log(row.map((cell, i) => (i >= 5 ? cell.padStart(widths[i]) : cell.padEnd(widths[i]))).join(" "));
348
+ }
349
+
350
+ const failed = records.filter((r) => r.status === "error").length;
351
+ const spend = records.reduce((sum, r) => sum + (r.cli?.total_cost_usd ?? 0), 0);
352
+ console.log(
353
+ `\n${rows.length} run(s), ${span}` +
354
+ `${failed ? `, ${failed} failed` : ""}` +
355
+ `${spend > 0 ? `, $${spend.toFixed(2)} of model usage` : ""}` +
356
+ `${records.length === limit ? ` — newest ${limit}, raise with --limit` : ""}`,
357
+ );
358
+ console.log("One run in full: meffecta-agent history <id from the first column>");
359
+ return 0;
360
+ }
@@ -219,8 +219,9 @@ const INTEGRATIONS = {
219
219
  "",
220
220
  "and `deploy` to register it — both fields are read when the service boots.",
221
221
  "",
222
- "The system is required: `gmail:<address>`, or `agentmail:<address>` for an inbox the",
223
- "agent owns. A bare address is refused rather than guessed at, because an address",
222
+ "The system is required: `gmail:<address>`, `outlook:<address>` for a Microsoft 365",
223
+ "mailbox, or `agentmail:<address>` for an inbox the agent owns. A bare address is",
224
+ "refused rather than guessed at, because an address",
224
225
  "polled against the wrong system finds nothing for ever and looks healthy doing it.",
225
226
  "",
226
227
  "allowFrom is required and enforced in code: mail from anyone not listed is dropped",
@@ -105,12 +105,20 @@ export function buildVerifyPrompt() {
105
105
  */
106
106
  export function verifyCredentials(ask) {
107
107
  return async (args) => {
108
- const { flags, positional } = parseFlags(args, {
109
- quick: { type: "boolean" },
110
- model: {},
111
- effort: {},
112
- timeoutSeconds: { type: "int", min: 1, max: 3600 },
113
- });
108
+ const { flags, positional, help } = parseFlags(
109
+ args,
110
+ {
111
+ quick: { type: "boolean" },
112
+ model: {},
113
+ effort: {},
114
+ timeoutSeconds: { type: "int", min: 1, max: 3600 },
115
+ },
116
+ { usage: "meffecta-agent verify-credentials [--quick]" },
117
+ );
118
+ if (help) {
119
+ console.log(help);
120
+ return 0;
121
+ }
114
122
  if (positional.length) {
115
123
  throw new UserError(`verify-credentials takes no arguments (got "${positional.join(" ")}").`);
116
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meffecta/agent",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Set up and operate a Meffecta Agent deployment — a self-hosted Claude Code job runner.",
5
5
  "type": "module",
6
6
  "bin": {