@runuai/host 0.9.167 → 0.9.169

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/lib/agent-cli.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { TASK_PROJECT_ADD_WAIT_MS } from "../src/protocol";
2
+ import { validateShareablePath, ENGINE_ACCOUNT_PATH_RULES } from "../src/workspace-path-policy";
1
3
  /**
2
4
  * In-container `uai` CLI materialisation (ADR-048).
3
5
  *
@@ -126,7 +128,7 @@ export async function writeAgentCli(
126
128
  ): Promise<boolean> {
127
129
  if (
128
130
  !apiUrl ||
129
- (rosterPermissions(roster).length === 0 && !allowPermissionless)
131
+ (roster.length === 0 && !allowPermissionless)
130
132
  ) {
131
133
  return false;
132
134
  }
@@ -161,7 +163,7 @@ export async function writeAgentCliViaEnvironment(
161
163
  ): Promise<boolean> {
162
164
  if (
163
165
  !apiUrl ||
164
- (rosterPermissions(roster).length === 0 && !allowPermissionless)
166
+ (roster.length === 0 && !allowPermissionless)
165
167
  ) {
166
168
  return false;
167
169
  }
@@ -199,6 +201,7 @@ export function agentCliEnv(
199
201
  ownerUserId: string | null,
200
202
  apiUrl: string | null,
201
203
  cliSecret: string | null,
204
+ leadRunId?: string | null,
202
205
  ): Record<string, string> {
203
206
  const runtimeAuthority = { ...AGENT_RUNTIME_AUTHORITY_ENV };
204
207
  const permissions = agent.permissions ?? [];
@@ -213,6 +216,7 @@ export function agentCliEnv(
213
216
  agentId: agent.id,
214
217
  // ADR-157 §3: binds the token to this agent, not merely its handle.
215
218
  ...(agent.tokenGeneration ? { generation: agent.tokenGeneration } : {}),
219
+ ...(leadRunId ? { leadRunId } : {}),
216
220
  },
217
221
  cliSecret,
218
222
  ),
@@ -227,9 +231,11 @@ export function agentCliEnv(
227
231
 
228
232
  export const CLI_SOURCE = `#!/usr/bin/env node
229
233
  // uai — in-task agent CLI (ADR-048). Talks to the cloud agent API as the task.
230
- import { readFileSync } from "node:fs";
231
- import { dirname, join } from "node:path";
234
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
235
+ import { open, realpath } from "node:fs/promises";
236
+ import { dirname, isAbsolute, join, relative, resolve, sep, basename } from "node:path";
232
237
  import { fileURLToPath } from "node:url";
238
+ import { randomUUID, createHash } from "node:crypto";
233
239
 
234
240
  const here = dirname(fileURLToPath(import.meta.url));
235
241
  let cfg = {};
@@ -285,28 +291,548 @@ async function api(method, path, body) {
285
291
  return json;
286
292
  }
287
293
  function out(v) { console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2)); }
294
+ const NL = String.fromCharCode(10);
295
+ function flagValue(v) { return typeof v === "string" && v !== "true" && v.length > 0; }
296
+ // ADR-161 §3.2: setup fields read from files, so a brief or a .tool-versions
297
+ // keeps its newlines and quotes without shell escaping.
298
+ function readFlagFile(path) {
299
+ try { return path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8"); }
300
+ catch (error) { console.error("uai: cannot read " + path + ": " + error.message); process.exit(1); }
301
+ }
302
+ function projectSetupFields(flags) {
303
+ const fields = {};
304
+ if (flagValue(flags.slug)) fields.slug = flags.slug;
305
+ if (flagValue(flags["prompt-file"])) fields.defaultPrompt = readFlagFile(flags["prompt-file"]);
306
+ else if (flagValue(flags.prompt)) fields.defaultPrompt = flags.prompt;
307
+ if (flagValue(flags["previews-file"])) {
308
+ try { fields.previewPorts = JSON.parse(readFlagFile(flags["previews-file"])); }
309
+ catch { console.error("uai: --previews-file must hold a JSON array of preview declarations"); process.exit(1); }
310
+ }
311
+ if (flagValue(flags["tool-versions-file"])) fields.toolVersions = readFlagFile(flags["tool-versions-file"]);
312
+ return fields;
313
+ }
314
+
315
+ // ADR-161 §2: add a repository to the running task this agent is on. The add
316
+ // is a durable operation; by default the CLI waits for its outcome.
317
+ function opKey() { return "cli-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12); }
318
+ function opLine(op) {
319
+ if (op.state === "failed") return "could not add " + op.slug + ": " + (op.error ? op.error.code + " — " + op.error.message : "failed");
320
+ if (op.state === "ready" || op.state === "ready_with_warning") {
321
+ return "added " + op.slug + " at " + op.path + (op.warning ? " (warning: " + op.warning + ")" : "");
322
+ }
323
+ return "adding " + op.slug + " (" + op.state + "); check again with: uai task project status " + op.operationId;
324
+ }
325
+ function opProgress(op) {
326
+ const attempts = Number.isInteger(op.attempts) ? "; attempts: " + op.attempts : "";
327
+ const lastError = op.lastError || (op.error && op.error.message);
328
+ return "[" + op.operationId + "] " + opLine(op) + attempts +
329
+ (lastError && op.state !== "failed" ? "; last error: " + lastError : "");
330
+ }
331
+ async function waitForProjectOp(op) {
332
+ const deadline = Date.now() + ${TASK_PROJECT_ADD_WAIT_MS};
333
+ let progress = opProgress(op);
334
+ let reportedAt = Date.now();
335
+ console.error(progress);
336
+ while (!op.done && Date.now() < deadline) {
337
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
338
+ op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(op.operationId))).operation;
339
+ const next = opProgress(op);
340
+ if (!op.done && (next !== progress || Date.now() - reportedAt >= 30_000)) {
341
+ console.error(next);
342
+ progress = next;
343
+ reportedAt = Date.now();
344
+ }
345
+ }
346
+ return op;
347
+ }
348
+ async function addProjectToTask(project, wait) {
349
+ const started = await api("POST", "/api/agent/task/projects", { projectId: project, idempotencyKey: opKey() });
350
+ const op = wait ? await waitForProjectOp(started.operation) : started.operation;
351
+ if (op.state === "failed") { console.error("uai: " + opLine(op)); process.exit(1); }
352
+ if (wait && !op.done) {
353
+ console.error("uai: timed out waiting; the add is still in progress. " + opProgress(op));
354
+ process.exit(1);
355
+ }
356
+ out(wait ? opLine(op) : opProgress(op));
357
+ }
358
+
359
+ // ADR-161 §1.3. UAI_FILES_ROOT and UAI_FILES_STATE_FILE exist so tests can
360
+ // point at a temp dir; a task leaves them unset and uses the defaults.
361
+ const FILES_CAP_COUNT = 500;
362
+ const FILES_CAP_BYTES = 1024 * 1024 * 1024;
363
+ const FILES_ROOT = process.env.UAI_FILES_ROOT || "/workspace/files";
364
+ const FILES_STATE = process.env.UAI_FILES_STATE_FILE || "/workspace/.uai/files-state.json";
365
+
366
+ function filesTarget(raw) {
367
+ if (raw === "org" || raw === "me") return { scope: raw, path: "" };
368
+ if (typeof raw === "string" && raw.startsWith("org/")) return { scope: "org", path: raw.slice(4) };
369
+ if (typeof raw === "string" && raw.startsWith("me/")) return { scope: "me", path: raw.slice(3) };
370
+ return null;
371
+ }
372
+ function filesQuery(scope, path, cursor) {
373
+ let q = "/api/agent/files?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path || "");
374
+ if (cursor) q += "&cursor=" + encodeURIComponent(cursor);
375
+ return q;
376
+ }
377
+ async function filesListFolder(scope, path, bounded) {
378
+ const entries = [];
379
+ let cursor = "";
380
+ let pages = 0;
381
+ do {
382
+ if (bounded && pages >= FILES_CAP_COUNT) {
383
+ console.error("uai: files get -r stops at 500 files");
384
+ process.exit(1);
385
+ }
386
+ pages += 1;
387
+ const page = await api("GET", filesQuery(scope, path, cursor));
388
+ for (const entry of page.entries || []) entries.push(entry);
389
+ cursor = page.cursor || "";
390
+ } while (cursor);
391
+ return entries;
392
+ }
393
+ function defaultFilesDest(scope, path) {
394
+ return FILES_ROOT + "/" + scope + (path ? "/" + path : "");
395
+ }
396
+ function filesRefuse(message) {
397
+ console.error("uai: " + message);
398
+ process.exit(1);
399
+ }
400
+ function legacyFilesMount(root) {
401
+ try {
402
+ lstatSync(join(dirname(root), ".uai", "files-mounted"));
403
+ return true;
404
+ } catch (err) {
405
+ if (err && err.code === "ENOENT") return false;
406
+ throw err;
407
+ }
408
+ }
409
+ // Default destinations stay under FILES_ROOT. The directories are opened
410
+ // with O_DIRECTORY|O_NOFOLLOW and held across the download; the temp file
411
+ // is created and renamed relative to those descriptors. An explicit <dest>
412
+ // uses prepareExplicitDest and is not contained.
413
+ function closeFds(fds) {
414
+ for (const fd of fds) {
415
+ try { closeSync(fd); } catch (err) {}
416
+ }
417
+ }
418
+ function refuseHeld(fds, message) {
419
+ closeFds(fds);
420
+ filesRefuse(message);
421
+ }
422
+ function lstatChild(dirFd, name) {
423
+ try {
424
+ return lstatSync("/proc/self/fd/" + dirFd + "/" + name);
425
+ } catch (err) {
426
+ if (err && err.code === "ENOENT") return null;
427
+ throw err;
428
+ }
429
+ }
430
+ function openDirAt(parentFd, name) {
431
+ const target = parentFd < 0 ? name : "/proc/self/fd/" + parentFd + "/" + name;
432
+ let fd = -1;
433
+ try {
434
+ fd = openSync(target, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
435
+ } catch (err) {
436
+ if (err && (err.code === "ELOOP" || err.code === "ENOTDIR" || err.code === "EPERM")) {
437
+ filesRefuse("files destination follows a symlink");
438
+ }
439
+ throw err;
440
+ }
441
+ const opened = fstatSync(fd);
442
+ const listed = lstatSync(target);
443
+ if (
444
+ listed.isSymbolicLink() ||
445
+ !listed.isDirectory() ||
446
+ opened.dev !== listed.dev ||
447
+ opened.ino !== listed.ino
448
+ ) {
449
+ closeSync(fd);
450
+ filesRefuse("files destination follows a symlink");
451
+ }
452
+ return fd;
453
+ }
454
+ function staysUnder(rootReal, fileReal) {
455
+ return fileReal === rootReal || fileReal.indexOf(rootReal + sep) === 0;
456
+ }
457
+ function openContainedDest(dest) {
458
+ const fds = [];
459
+ const root = resolve(FILES_ROOT);
460
+ let rootStat = null;
461
+ try {
462
+ rootStat = lstatSync(root);
463
+ } catch (err) {
464
+ if (!err || err.code !== "ENOENT") throw err;
465
+ }
466
+ if (rootStat && rootStat.isSymbolicLink()) filesRefuse("refusing a legacy shared-files mount");
467
+ if (legacyFilesMount(root)) filesRefuse("refusing a legacy shared-files mount");
468
+ const target = resolve(dest);
469
+ const rel = relative(root, target);
470
+ if (rel === "" || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
471
+ filesRefuse("files destination must stay under /workspace/files");
472
+ }
473
+ if (!rootStat) {
474
+ const parent = dirname(root);
475
+ let parentStat = null;
476
+ try { parentStat = lstatSync(parent); } catch (err) {
477
+ if (!err || err.code !== "ENOENT") throw err;
478
+ }
479
+ if (!parentStat || parentStat.isSymbolicLink() || !parentStat.isDirectory()) {
480
+ filesRefuse("files destination follows a symlink");
481
+ }
482
+ mkdirSync(root);
483
+ } else if (!rootStat.isDirectory()) {
484
+ filesRefuse("files destination is not a directory");
485
+ }
486
+ const rootFd = openDirAt(-1, root);
487
+ fds.push(rootFd);
488
+ // The allowed root is the pathname at open time, not wherever the held
489
+ // directory descriptor points later. realOf(rootFd) follows a rename of
490
+ // /workspace/files itself, so both sides of the comparison would move and
491
+ // a download would land outside. Pin realpath once, before any await.
492
+ let rootReal = "";
493
+ try {
494
+ rootReal = realpathSync(root);
495
+ } catch (err) {
496
+ refuseHeld(fds, "files destination left /workspace/files");
497
+ }
498
+ const pinned = lstatSync(rootReal);
499
+ const openedRoot = fstatSync(rootFd);
500
+ if (
501
+ pinned.isSymbolicLink() ||
502
+ !pinned.isDirectory() ||
503
+ pinned.dev !== openedRoot.dev ||
504
+ pinned.ino !== openedRoot.ino
505
+ ) {
506
+ refuseHeld(fds, "files destination left /workspace/files");
507
+ }
508
+ const parts = rel.split(sep);
509
+ let dirFd = rootFd;
510
+ for (let i = 0; i < parts.length; i++) {
511
+ const name = parts[i];
512
+ if (!name || name === "." || name === "..") {
513
+ refuseHeld(fds, "files destination must stay under /workspace/files");
514
+ }
515
+ const last = i === parts.length - 1;
516
+ const st = lstatChild(dirFd, name);
517
+ if (st && st.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
518
+ if (last) {
519
+ if (st && !st.isFile()) refuseHeld(fds, "files destination is not a file");
520
+ return { rootFd: rootFd, dirFd: dirFd, name: name, fds: fds, rootReal: rootReal };
521
+ }
522
+ if (!st) {
523
+ try {
524
+ mkdirSync("/proc/self/fd/" + dirFd + "/" + name);
525
+ } catch (err) {
526
+ if (!err || err.code !== "EEXIST") {
527
+ closeFds(fds);
528
+ throw err;
529
+ }
530
+ }
531
+ const created = lstatChild(dirFd, name);
532
+ if (!created || created.isSymbolicLink()) refuseHeld(fds, "files destination follows a symlink");
533
+ if (!created.isDirectory()) refuseHeld(fds, "files destination is not a directory");
534
+ } else if (!st.isDirectory()) {
535
+ refuseHeld(fds, "files destination is not a directory");
536
+ }
537
+ try {
538
+ dirFd = openDirAt(dirFd, name);
539
+ } catch (err) {
540
+ closeFds(fds);
541
+ throw err;
542
+ }
543
+ fds.push(dirFd);
544
+ }
545
+ refuseHeld(fds, "files destination must stay under /workspace/files");
546
+ }
547
+ function prepareExplicitDest(dest) {
548
+ mkdirSync(dirname(dest), { recursive: true });
549
+ return dest;
550
+ }
551
+ let filesPartSeq = 0;
552
+ function recordFileState(scope, path, etag) {
553
+ mkdirSync(dirname(FILES_STATE), { recursive: true });
554
+ const lock = FILES_STATE + ".lock";
555
+ let fd = -1;
556
+ const started = Date.now();
557
+ while (fd < 0) {
558
+ try {
559
+ fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
560
+ } catch (err) {
561
+ if (!err || err.code !== "EEXIST") throw err;
562
+ if (Date.now() - started > 5000) throw new Error("files state is locked");
563
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
564
+ }
565
+ }
566
+ try {
567
+ let state = {};
568
+ try { state = JSON.parse(readFileSync(FILES_STATE, "utf8")); } catch {}
569
+ if (!state || typeof state !== "object") state = {};
570
+ state[scope + "/" + path] = { etag: etag };
571
+ const tmp = FILES_STATE + ".tmp";
572
+ writeFileSync(tmp, JSON.stringify(state));
573
+ renameSync(tmp, FILES_STATE);
574
+ } finally {
575
+ closeSync(fd);
576
+ try { unlinkSync(lock); } catch {}
577
+ }
578
+ }
579
+ function procChild(dirFd, name) {
580
+ return "/proc/self/fd/" + dirFd + "/" + name;
581
+ }
582
+ function unlinkChild(dirFd, name) {
583
+ try { unlinkSync(procChild(dirFd, name)); } catch (unlinkErr) {}
584
+ }
585
+ function realOf(fd) {
586
+ return realpathSync("/proc/self/fd/" + fd);
587
+ }
588
+ function fileUnderRoot(rootReal, fd) {
589
+ try {
590
+ return staysUnder(rootReal, realOf(fd));
591
+ } catch (err) {
592
+ return false;
593
+ }
594
+ }
595
+ function refuseEscaped(dirFd, name, fd) {
596
+ if (fd >= 0) {
597
+ try { closeSync(fd); } catch (closeErr) {}
598
+ }
599
+ unlinkChild(dirFd, name);
600
+ filesRefuse("files destination left /workspace/files");
601
+ }
602
+ async function streamInto(fd, body, budget) {
603
+ const reader = body.getReader();
604
+ let accepted = 0;
605
+ while (true) {
606
+ const step = await reader.read();
607
+ if (step.done) break;
608
+ const buf = Buffer.from(step.value);
609
+ if (budget && accepted + buf.length > budget.remaining) {
610
+ try { await reader.cancel(); } catch (cancelErr) {}
611
+ const capErr = new Error("cap");
612
+ capErr.code = "FILES_CAP";
613
+ throw capErr;
614
+ }
615
+ writeSync(fd, buf);
616
+ accepted += buf.length;
617
+ }
618
+ return accepted;
619
+ }
620
+ function throwFilesCap(err) {
621
+ if (err && err.code === "FILES_CAP") {
622
+ console.error("uai: files get -r stops at 1 GB");
623
+ process.exit(1);
624
+ }
625
+ throw err;
626
+ }
627
+ // Explicit destinations are the caller's path. Default destinations are
628
+ // created and renamed through the directory descriptors opened before the
629
+ // link request, then checked to still sit under the files root.
630
+ async function writePath(dest, res, budget) {
631
+ filesPartSeq += 1;
632
+ const tmp = dest + ".part-" + process.pid + "-" + filesPartSeq;
633
+ let fd = -1;
634
+ try {
635
+ fd = openSync(tmp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
636
+ const accepted = await streamInto(fd, res.body, budget);
637
+ closeSync(fd);
638
+ fd = -1;
639
+ renameSync(tmp, dest);
640
+ if (budget) budget.remaining -= accepted;
641
+ } catch (err) {
642
+ if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
643
+ try { unlinkSync(tmp); } catch (unlinkErr) {}
644
+ throwFilesCap(err);
645
+ }
646
+ }
647
+ async function writeHeld(held, res, budget) {
648
+ const rootReal = held.rootReal;
649
+ if (typeof rootReal !== "string" || !rootReal || !staysUnder(rootReal, realOf(held.dirFd))) {
650
+ filesRefuse("files destination left /workspace/files");
651
+ }
652
+ const existing = lstatChild(held.dirFd, held.name);
653
+ if (existing && existing.isSymbolicLink()) filesRefuse("files destination follows a symlink");
654
+ if (existing && !existing.isFile()) filesRefuse("files destination is not a file");
655
+ filesPartSeq += 1;
656
+ const tmpName = ".part-" + process.pid + "-" + filesPartSeq;
657
+ const tmpPath = procChild(held.dirFd, tmpName);
658
+ const finalPath = procChild(held.dirFd, held.name);
659
+ let fd = -1;
660
+ try {
661
+ try {
662
+ fd = openSync(tmpPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW);
663
+ } catch (err) {
664
+ if (err && err.code === "ELOOP") filesRefuse("files destination follows a symlink");
665
+ throw err;
666
+ }
667
+ if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
668
+ const accepted = await streamInto(fd, res.body, budget);
669
+ if (!fileUnderRoot(rootReal, fd)) refuseEscaped(held.dirFd, tmpName, fd);
670
+ closeSync(fd);
671
+ fd = -1;
672
+ if (!staysUnder(rootReal, realOf(held.dirFd))) {
673
+ unlinkChild(held.dirFd, tmpName);
674
+ filesRefuse("files destination left /workspace/files");
675
+ }
676
+ renameSync(tmpPath, finalPath);
677
+ const placed = lstatChild(held.dirFd, held.name);
678
+ let placedReal = "";
679
+ try { placedReal = realpathSync(finalPath); } catch (err) { placedReal = ""; }
680
+ if (!placed || placed.isSymbolicLink() || !placed.isFile() || !staysUnder(rootReal, placedReal)) {
681
+ unlinkChild(held.dirFd, held.name);
682
+ filesRefuse("files destination left /workspace/files");
683
+ }
684
+ if (budget) budget.remaining -= accepted;
685
+ } catch (err) {
686
+ if (fd >= 0) { try { closeSync(fd); } catch (closeErr) {} }
687
+ unlinkChild(held.dirFd, tmpName);
688
+ throwFilesCap(err);
689
+ }
690
+ }
691
+ async function downloadFile(scope, path, dest, contained, budget) {
692
+ const held = contained ? openContainedDest(dest) : null;
693
+ try {
694
+ if (!contained) prepareExplicitDest(dest);
695
+ const link = await api(
696
+ "GET",
697
+ "/api/agent/files/link?scope=" + encodeURIComponent(scope) + "&path=" + encodeURIComponent(path),
698
+ );
699
+ let res;
700
+ try {
701
+ res = await fetch(link.url);
702
+ } catch {
703
+ console.error("uai: download failed");
704
+ process.exit(1);
705
+ }
706
+ if (!res.ok) {
707
+ console.error("uai: download failed (" + res.status + ")");
708
+ process.exit(1);
709
+ }
710
+ const etag = res.headers.get("etag");
711
+ if (!etag || (link.etag && etag !== link.etag)) {
712
+ if (res.body && res.body.cancel) {
713
+ try { await res.body.cancel(); } catch (cancelErr) {}
714
+ }
715
+ console.error(etag ? "uai: download etag does not match the inspected file" : "uai: download did not name an etag");
716
+ process.exit(1);
717
+ }
718
+ if (!res.body || !res.body.getReader) {
719
+ console.error("uai: download failed");
720
+ process.exit(1);
721
+ }
722
+ if (held) await writeHeld(held, res, budget);
723
+ else await writePath(dest, res, budget);
724
+ recordFileState(scope, path, etag);
725
+ } finally {
726
+ if (held) closeFds(held.fds);
727
+ }
728
+ }
729
+
730
+ // Explicit '-' only: omitted argv text never blocks waiting for a terminal.
731
+ // Existing cloud schemas enforce the same instruction/prompt length limits.
732
+ function stdinText(label) {
733
+ if (process.stdin.isTTY) {
734
+ console.error("uai: " + label + " stdin is a terminal; use a pipe or a quoted heredoc with - <<'EOF' (see uai --help)");
735
+ process.exit(1);
736
+ }
737
+ const text = readFileSync(0, "utf8").trim();
738
+ if (!text) { console.error("uai: " + label + " stdin is empty"); process.exit(1); }
739
+ return text;
740
+ }
288
741
 
289
742
  const argv = process.argv.slice(2);
290
743
  const [group, action, ...rest] = argv;
291
744
  const { flags, rest: pos } = parseFlags(rest);
292
745
 
746
+ const validateShareablePath = ${validateShareablePath.toString()};
747
+ const assertShareablePath = (path) => validateShareablePath(path, ${JSON.stringify(ENGINE_ACCOUNT_PATH_RULES)});
293
748
  async function main() {
749
+ if (group === "secret") {
750
+ if (action !== "request" || pos.length !== 1 || !flags.why || Object.keys(flags).some(key => !["why", "proxy-domain", "no-wait"].includes(key))) { console.error('uai: secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]'); process.exit(1); }
751
+ // Repeated domains retain order from argv; the generic flag parser intentionally keeps one value.
752
+ const domains = [];
753
+ for (let i = 0; i < rest.length; i++) if (rest[i] === "--proxy-domain" && rest[i + 1]) domains.push(rest[++i]);
754
+ const created = await api("POST", "/api/agent/secrets/requests", { name: pos[0], why: flags.why, domains });
755
+ let request = created.request;
756
+ out({ request });
757
+ if (flags["no-wait"] === "true") return;
758
+ while (request.status === "pending") {
759
+ await new Promise(resolve => setTimeout(resolve, 2000));
760
+ request = (await api("GET", "/api/agent/secrets/requests/" + encodeURIComponent(request.id))).request;
761
+ }
762
+ out({ request });
763
+ if (request.status !== "provided") process.exit(1);
764
+ return;
765
+ }
766
+ if (group === "artifacts") {
767
+ const rows = (await api("GET", "/api/agent/artifacts")).artifacts || [];
768
+ out(rows.length ? rows.map((a) => a.id + " " + a.name + " (" + a.size + " bytes) " + API_URL + a.open).join("\\n") : "no shared files");
769
+ return;
770
+ }
771
+ if (group === "share") {
772
+ const parsed = parseFlags([action, ...rest].filter(Boolean));
773
+ if (parsed.rest.length !== 1 || Object.keys(parsed.flags).some((k) => k !== "title")) throw new Error("share <path> [--title <text>]");
774
+ const requestedPath = isAbsolute(parsed.rest[0]) ? parsed.rest[0] : process.cwd() + "/" + parsed.rest[0];
775
+ const lexical = assertShareablePath(requestedPath);
776
+ const sourcePath = assertShareablePath(await realpath(lexical));
777
+ const file = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
778
+ try {
779
+ const stat = await file.stat();
780
+ if (!stat.isFile() || stat.size > 100 * 1024 * 1024) throw new Error("share needs a regular file of at most 100 MiB");
781
+ const hash = createHash("sha256");
782
+ for await (const chunk of file.createReadStream({ autoClose: false })) hash.update(chunk);
783
+ const created = await api("POST", "/api/agent/artifacts", { name: basename(sourcePath), size: stat.size, sha256: hash.digest("hex"), sourcePath, requestedPath, title: parsed.flags.title });
784
+ const uploaded = await fetch(created.upload.url, { method: "PUT", headers: created.upload.headers, body: file.createReadStream({ start: 0, autoClose: false }), duplex: "half" });
785
+ if (!uploaded.ok) throw new Error("staging upload refused (" + uploaded.status + "); retry share to make a new receipt");
786
+ let done;
787
+ for (let attempt = 0; attempt < 30; attempt++) {
788
+ done = await api("POST", "/api/agent/artifacts/" + encodeURIComponent(created.artifact.id) + "/complete");
789
+ if (done.result !== "pending") break;
790
+ await new Promise((resolve) => setTimeout(resolve, 1000));
791
+ }
792
+ if (done?.result !== "published" || done.artifact.state !== "ready") throw new Error("artifact " + created.artifact.id + " is " + (done?.result || "recovering") + "; check uai artifacts shortly");
793
+ out(done.artifact.id + " " + API_URL + done.artifact.open);
794
+ } finally { await file.close(); }
795
+ return;
796
+ }
294
797
  if (group === "dispatch") {
295
798
  const args = [action, ...rest].filter((value) => typeof value === "string");
296
799
  const recipientArgs = [];
297
800
  while (args.length && /^@[A-Za-z0-9_-]+$/.test(args[0])) {
298
801
  recipientArgs.push(args.shift().slice(1));
299
802
  }
300
- const instruction = args.join(" ").trim();
301
- if (!recipientArgs.length || !instruction) {
302
- console.error('uai: dispatch needs one or more @agent ids and a quoted instruction');
803
+ if (!recipientArgs.length) {
804
+ console.error('uai: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
805
+ process.exit(1);
806
+ }
807
+ const tags = {};
808
+ while (args[0] && ["--kind", "--size", "--pr", "--request-id"].includes(args[0])) {
809
+ const key = args.shift().slice(2);
810
+ const value = args.shift();
811
+ if (!value || value === "-" || value.startsWith("--")) {
812
+ console.error("uai: dispatch --" + key + " needs a value"); process.exit(1);
813
+ }
814
+ tags[key === "request-id" ? "requestId" : key] = value;
815
+ }
816
+ if (tags.kind && !["build", "fix", "review", "research", "design", "docs", "ops"].includes(tags.kind)) {
817
+ console.error("uai: dispatch --kind must be build, fix, review, research, design, docs or ops"); process.exit(1);
818
+ }
819
+ if (tags.size && !["s", "m", "l"].includes(tags.size)) {
820
+ console.error("uai: dispatch --size must be s, m or l"); process.exit(1);
821
+ }
822
+ const instruction = args.length === 1 && args[0] === "-" ? stdinText("dispatch") : args.join(" ").trim();
823
+ if (!instruction) {
824
+ console.error('uai: dispatch needs one or more @agent ids and a quoted instruction (or - for stdin)');
303
825
  process.exit(1);
304
826
  }
305
827
  const result = await api("POST", "/api/agent/dispatch", {
306
828
  to: recipientArgs,
307
829
  instruction,
830
+ requestId: tags.requestId || randomUUID(),
831
+ ...tags,
308
832
  });
309
- out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
833
+ if (result.recipients.length) out("dispatched to " + result.recipients.map((id) => "@" + id).join(", "));
834
+ if (result.pendingRecipients && result.pendingRecipients.length) out("pending/in flight: " + result.pendingRecipients.map((id) => "@" + id).join(", "));
835
+ if (result.canceledRecipients && result.canceledRecipients.length) out("canceled: " + result.canceledRecipients.map((id) => "@" + id).join(", "));
310
836
  if (Array.isArray(result.failedRecipients) && result.failedRecipients.length) {
311
837
  console.error(
312
838
  "uai: could not wake " +
@@ -315,11 +841,29 @@ async function main() {
315
841
  }
316
842
  return;
317
843
  }
844
+ if (group === "review" && action === "report") {
845
+ const counts = ["must", "should", "nits"];
846
+ if (!flags.pr || !/^[0-9a-f]{40}$/.test(flags.head || "") || !["approve", "changes"].includes(flags.verdict) ||
847
+ counts.some(k => !/^[0-9]+$/.test(flags[k] || "") || Number(flags[k]) > 1000) ||
848
+ (flags.revision && !/^[0-9]+$/.test(flags.revision))) {
849
+ console.error("uai: review report --pr <number|URL> --head <full-sha> --must <0..1000> --should <0..1000> --nits <0..1000> --verdict approve|changes [--summary text] [--assignment id] [--revision n]"); process.exit(1);
850
+ }
851
+ out(await api("POST", "/api/agent/review/report", { pr: flags.pr, head: flags.head,
852
+ must: Number(flags.must), should: Number(flags.should), nits: Number(flags.nits), verdict: flags.verdict,
853
+ summary: flags.summary, assignmentId: flags.assignment, expectedRevision: Number(flags.revision || 0) })); return;
854
+ }
318
855
  if (group === "crew") {
319
856
  // ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
320
857
  // persona) changes its own crew. One agent per call, always with a --why.
321
858
  const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
322
859
  const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
860
+ if (action === "outcome") {
861
+ if (!pos[0] || !["accepted", "reworked", "failed"].includes(pos[1]) || (flags.revision && !/^[0-9]+$/.test(flags.revision))) {
862
+ console.error("uai: crew outcome @agent accepted|reworked|failed [--assignment id] [--note text] [--revision n]"); process.exit(1);
863
+ }
864
+ out(await api("POST", "/api/agent/crew-outcome", { recipient: handleOf(pos[0]), outcome: pos[1],
865
+ assignmentId: flags.assignment, note: flags.note, expectedRevision: Number(flags.revision || 0) })); return;
866
+ }
323
867
  if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
324
868
  if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
325
869
  if (action === "store") { out(await api("GET", "/api/agent/crew-store")); return; }
@@ -393,6 +937,88 @@ async function main() {
393
937
  console.error("uai: crew commands are list, status, store, suggest, add, model and remove (Astronaut only)");
394
938
  process.exit(1);
395
939
  }
940
+ if (group === "files") {
941
+ const args = rest.filter((value) => typeof value === "string");
942
+ const recursive = args.indexOf("-r") !== -1;
943
+ const positional = args.filter((value) => value !== "-r");
944
+ if (action === "ls") {
945
+ if (positional.length > 1) {
946
+ console.error("uai: files ls [org|me[/prefix]]");
947
+ process.exit(1);
948
+ }
949
+ const target = filesTarget(positional[0] || "org");
950
+ if (!target) {
951
+ console.error("uai: files ls [org|me[/prefix]]");
952
+ process.exit(1);
953
+ }
954
+ out({ entries: await filesListFolder(target.scope, target.path) });
955
+ return;
956
+ }
957
+ if (action === "get") {
958
+ if (!positional.length || positional.length > 2) {
959
+ console.error("uai: files get <org|me>/<path> [<dest>] [-r]");
960
+ process.exit(1);
961
+ }
962
+ const target = filesTarget(positional[0]);
963
+ if (!target) {
964
+ console.error("uai: files get <org|me>/<path> [<dest>] [-r]");
965
+ process.exit(1);
966
+ }
967
+ if (!recursive) {
968
+ if (!target.path) {
969
+ console.error("uai: files get needs a file path (or -r for a folder)");
970
+ process.exit(1);
971
+ }
972
+ const dest = positional[1] || defaultFilesDest(target.scope, target.path);
973
+ await downloadFile(target.scope, target.path, dest, !positional[1], null);
974
+ out(dest);
975
+ return;
976
+ }
977
+ const root = target.path;
978
+ const pending = [root];
979
+ const files = [];
980
+ let bytes = 0;
981
+ let folders = 0;
982
+ while (pending.length) {
983
+ if (folders >= FILES_CAP_COUNT) {
984
+ console.error("uai: files get -r stops at 500 files");
985
+ process.exit(1);
986
+ }
987
+ folders += 1;
988
+ const dir = pending.shift();
989
+ const entries = await filesListFolder(target.scope, dir, true);
990
+ for (const entry of entries) {
991
+ const child = dir ? dir + "/" + entry.name : entry.name;
992
+ if (entry.dir) {
993
+ pending.push(child);
994
+ continue;
995
+ }
996
+ const size = entry.size || 0;
997
+ if (files.length >= FILES_CAP_COUNT) {
998
+ console.error("uai: files get -r stops at 500 files");
999
+ process.exit(1);
1000
+ }
1001
+ if (bytes + size > FILES_CAP_BYTES) {
1002
+ console.error("uai: files get -r stops at 1 GB");
1003
+ process.exit(1);
1004
+ }
1005
+ bytes += size;
1006
+ files.push(child);
1007
+ }
1008
+ }
1009
+ const budget = { remaining: FILES_CAP_BYTES };
1010
+ const explicitRoot = positional[1] ? String(positional[1]).replace(/\\/+$/, "") : "";
1011
+ for (const file of files) {
1012
+ const rel = root && file.indexOf(root + "/") === 0 ? file.slice(root.length + 1) : file;
1013
+ const dest = explicitRoot ? explicitRoot + "/" + rel : defaultFilesDest(target.scope, file);
1014
+ await downloadFile(target.scope, file, dest, !explicitRoot, budget);
1015
+ }
1016
+ out({ files: files.length, dest: positional[1] || defaultFilesDest(target.scope, root) });
1017
+ return;
1018
+ }
1019
+ console.error("uai: files commands are ls and get");
1020
+ process.exit(1);
1021
+ }
396
1022
  const key = group + " " + (action || "");
397
1023
  switch (key) {
398
1024
  case "projects list": out((await api("GET", "/api/agent/projects")).projects); break;
@@ -438,23 +1064,79 @@ async function main() {
438
1064
  break;
439
1065
  }
440
1066
  case "task create": {
441
- out((await api("POST", "/api/agent/tasks", {
1067
+ const prompt = flags.prompt === "-" ? stdinText("task create --prompt -") : flags.prompt;
1068
+ let astronaut;
1069
+ if (flags.astronaut) {
1070
+ const parts = String(flags.astronaut).split("/");
1071
+ if (parts.length < 2 || parts.some((part) => !part) || flags.astronaut === "true") {
1072
+ console.error("uai: --astronaut needs engine/model[/effort]"); process.exit(1);
1073
+ }
1074
+ const kind = parts.shift();
1075
+ const effort = parts.length > 1 ? parts.pop() : undefined;
1076
+ const model = parts.join("/");
1077
+ astronaut = { kind, ...(model !== "default" ? { model } : {}), ...(effort && effort !== "default" ? { effort } : {}) };
1078
+ }
1079
+ if (flags.start && flags.start !== "true") {
1080
+ console.error("uai: --start is a flag; it takes no value"); process.exit(1);
1081
+ }
1082
+ if (flags.start && (!flags["request-id"] || flags["request-id"] === "true")) {
1083
+ console.error("uai: --start requires --request-id <logical-run-id>; reuse it after a timeout"); process.exit(1);
1084
+ }
1085
+ const created = (await api("POST", "/api/agent/tasks", {
442
1086
  name: flags.name,
443
- prompt: flags.prompt,
1087
+ prompt,
444
1088
  projects: flags.projects ? String(flags.projects).split(",").map((s) => s.trim()).filter(Boolean) : [],
445
1089
  team: flags.team || null,
446
1090
  agents: flags.agents ? String(flags.agents).split(",").map((s) => s.trim()).filter(Boolean) : [],
447
1091
  parent: flags.parent || null,
448
- })).task);
1092
+ start: flags.start === "true",
1093
+ ...(astronaut ? { astronaut } : {}),
1094
+ requestId: flags["request-id"] || randomUUID(),
1095
+ })).task;
1096
+ if (flags.start && (!created || typeof created.id !== "string" || !created.id || !["queued", "starting", "running"].includes(created.status))) {
1097
+ console.error("uai: the cloud did not start the child" + (created?.id ? " " + created.id : "") + "; it may need updating. Reuse --request-id when retrying."); process.exit(1);
1098
+ }
1099
+ out(created);
1100
+ break;
1101
+ }
1102
+ case "task watch":
1103
+ case "task usage": {
1104
+ if (pos.length !== 1) { console.error("uai: task " + action + " needs a child task id"); process.exit(1); }
1105
+ const query = new URLSearchParams();
1106
+ if (action === "watch") {
1107
+ if (flags.after) query.set("after", String(flags.after));
1108
+ if (flags.limit) query.set("limit", String(flags.limit));
1109
+ }
1110
+ out(await api("GET", "/api/agent/tasks/" + encodeURIComponent(pos[0]) + "/" + action + (query.size ? "?" + query.toString() : "")));
1111
+ break;
1112
+ }
1113
+ case "task send": {
1114
+ // The text is prose, not CLI flags. Preserve literal --options in it.
1115
+ const [childId, ...words] = rest;
1116
+ const text = words.join(" ").trim();
1117
+ if (!childId || !text) { console.error("uai: task send needs a child task id and text"); process.exit(1); }
1118
+ out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/send", { text }));
1119
+ break;
1120
+ }
1121
+ case "task answer": {
1122
+ const [childId, cardId, ...words] = rest;
1123
+ const option = words.join(" ").trim();
1124
+ if (!childId || !cardId || !option) { console.error("uai: task answer needs a child task id, card id and option text"); process.exit(1); }
1125
+ out(await api("POST", "/api/agent/tasks/" + encodeURIComponent(childId) + "/answer", { cardId, option }));
449
1126
  break;
450
1127
  }
451
1128
  case "task close": {
452
- const allowed = new Set(["status", "reason"]);
1129
+ const allowed = new Set(["status", "reason", "child"]);
453
1130
  const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
454
- if (pos.length || unknown.length) {
455
- console.error("uai: task close accepts only --status and --reason");
1131
+ const childMode = Object.prototype.hasOwnProperty.call(flags, "child");
1132
+ if ((childMode && (flags.child === "true" || !String(flags.child).trim())) || (pos.length === 1 && !String(pos[0]).trim())) {
1133
+ console.error("uai: --child requires a child task id"); process.exit(1);
1134
+ }
1135
+ if (pos.length > 1 || unknown.length || (childMode && pos.length)) {
1136
+ console.error("uai: task close accepts --child <childId>, --status and --reason");
456
1137
  process.exit(1);
457
1138
  }
1139
+ const childId = childMode ? flags.child : pos[0];
458
1140
  const status = flags.status;
459
1141
  if (status !== "finished" && status !== "canceled") {
460
1142
  console.error("uai: task close needs --status finished|canceled");
@@ -464,7 +1146,8 @@ async function main() {
464
1146
  console.error("uai: --reason needs text (quote multiword reasons)");
465
1147
  process.exit(1);
466
1148
  }
467
- const closed = await api("POST", "/api/agent/tasks/close", {
1149
+ const path = childId ? "/api/agent/tasks/" + encodeURIComponent(childId) + "/close" : "/api/agent/tasks/close";
1150
+ const closed = await api("POST", path, {
468
1151
  status,
469
1152
  ...(flags.reason ? { reason: String(flags.reason) } : {}),
470
1153
  });
@@ -538,13 +1221,91 @@ async function main() {
538
1221
  if (!r.enabled && r.public) console.error("uai: it's public but off — it serves nothing until: uai preview enable " + r.name);
539
1222
  break;
540
1223
  }
1224
+ case "project list": {
1225
+ const projects = (await api("GET", "/api/agent/projects")).projects || [];
1226
+ out(projects.length ? projects.map((p) => p.id + " " + p.slug + " " + p.name).join(NL) : "no repositories");
1227
+ break;
1228
+ }
1229
+ case "project show": {
1230
+ if (!pos[0]) { console.error("uai: project show <id|slug>"); process.exit(1); }
1231
+ out((await api("GET", "/api/agent/projects/" + encodeURIComponent(pos[0]))).project);
1232
+ break;
1233
+ }
541
1234
  case "project create": {
1235
+ const attach = rest.includes("--attach");
542
1236
  const name = flags.name || pos.join(" ");
543
- if (!name || !flags.repo) { console.error("uai: project create --name <n> --repo <git-url> [--prompt <p>]"); process.exit(1); }
544
- const p = (await api("POST", "/api/agent/projects", { name, repoUrl: flags.repo, defaultPrompt: flags.prompt || undefined })).project;
1237
+ if (!name || !flags.repo || flags.repo === "true") { console.error("uai: project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach]"); process.exit(1); }
1238
+ const body = { name, repoUrl: flags.repo, ...projectSetupFields(flags) };
1239
+ const p = (await api("POST", "/api/agent/projects", body)).project;
545
1240
  out("created project " + p.slug + " (" + p.id + ")");
1241
+ if (attach) await addProjectToTask(p.id, true);
1242
+ break;
1243
+ }
1244
+ case "project update": {
1245
+ if (!pos[0]) { console.error("uai: project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>]"); process.exit(1); }
1246
+ const body = { ...(flagValue(flags.name) ? { name: flags.name } : {}), ...(flagValue(flags.repo) ? { repoUrl: flags.repo } : {}), ...projectSetupFields(flags) };
1247
+ if (Object.keys(body).length === 0) { console.error("uai: project update needs at least one field"); process.exit(1); }
1248
+ const r = await api("PATCH", "/api/agent/projects/" + encodeURIComponent(pos[0]), body);
1249
+ const proposed = r.proposed || [];
1250
+ out("updated " + r.project.slug + " (applies to the next task)");
1251
+ for (const pr of proposed) out("proposed " + pr.field + " (proposal " + pr.id + "): it changes what later tasks clone or read, so a member must approve it in the repository's Settings.");
1252
+ break;
1253
+ }
1254
+ case "project propose-extra": {
1255
+ if (!pos[0] || !flagValue(flags["extra-file"])) { console.error("uai: project propose-extra <id|slug> --extra-file <file>"); process.exit(1); }
1256
+ const r = await api("POST", "/api/agent/projects/" + encodeURIComponent(pos[0]) + "/extra-proposals", { extra: readFlagFile(flags["extra-file"]) });
1257
+ out("proposed Dockerfile additions (proposal " + r.proposal.id + "). They run as root at image build, so a member must approve them in the repository's Settings before any task uses them.");
546
1258
  break;
547
1259
  }
1260
+ case "project env": {
1261
+ const sub = pos[0], project = pos[1], name = pos[2];
1262
+ const base = "/api/agent/projects/" + encodeURIComponent(project || "") + "/env";
1263
+ if (!project || (["add", "remove", "propose-proxy"].includes(sub) && !name)) {
1264
+ console.error("uai: project env list|observed <id> | add <id> NAME --label <l> [--secret] | remove <id> NAME | propose-proxy <id> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>]");
1265
+ process.exit(1);
1266
+ }
1267
+ if (sub === "list") {
1268
+ const keys = (await api("GET", base + "/keys")).keys || [];
1269
+ out(keys.length ? keys.map((k) => k.key + (k.secret ? " (secret)" : "") + " " + k.label).join(NL) : "no env keys declared");
1270
+ } else if (sub === "observed") {
1271
+ out((await api("GET", base + "/declarations")).latest);
1272
+ } else if (sub === "add") {
1273
+ const label = flagValue(flags.label) ? flags.label : undefined;
1274
+ const r = await api("POST", base + "/keys", { key: name, ...(label ? { label } : {}), ...(rest.includes("--secret") ? { secret: true } : {}) });
1275
+ out("declared " + r.key + ". It has no value: a member saves its value in the repository's Settings.");
1276
+ } else if (sub === "remove") {
1277
+ const r = await api("DELETE", base + "/keys", { key: name });
1278
+ out("removed the declaration of " + r.key + ". No value was touched.");
1279
+ } else if (sub === "propose-proxy") {
1280
+ if (!flagValue(flags.domain)) { console.error("uai: project env propose-proxy <id> NAME --domain <d>[,<d>…]"); process.exit(1); }
1281
+ const prefix = flags["placeholder-prefix"], length = flags["placeholder-length"];
1282
+ if ((prefix === undefined) !== (length === undefined)) { console.error("uai: --placeholder-prefix and --placeholder-length go together"); process.exit(1); }
1283
+ const placeholder = prefix === undefined ? null : { prefix: prefix === "true" ? "" : prefix, totalLength: Number(length) };
1284
+ const domains = String(flags.domain).split(",").map((d) => d.trim()).filter(Boolean);
1285
+ const r = await api("POST", base + "/proxy-proposals", { key: name, domains, placeholder });
1286
+ out("proposed sending " + name + " only to " + r.proposal.domains.join(", ") + " (proposal " + r.proposal.id + "). A member must confirm it in the repository's Settings before the proxy uses it.");
1287
+ } else {
1288
+ console.error("uai: project env commands are list, add, remove, propose-proxy and observed");
1289
+ process.exit(1);
1290
+ }
1291
+ break;
1292
+ }
1293
+ case "task project": {
1294
+ const words = rest.filter((word) => word !== "--no-wait");
1295
+ const sub = words[0];
1296
+ if (sub === "add" && words.length === 2 && !words[1].startsWith("--")) {
1297
+ await addProjectToTask(words[1], !rest.includes("--no-wait"));
1298
+ break;
1299
+ }
1300
+ if (sub === "status" && words.length === 2) {
1301
+ const op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(words[1]))).operation;
1302
+ out(opProgress(op));
1303
+ if (op.state === "failed") process.exit(1);
1304
+ break;
1305
+ }
1306
+ console.error("uai: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
1307
+ process.exit(1);
1308
+ }
548
1309
  case "task search": {
549
1310
  const q = pos.join(" ") || flags.q || "";
550
1311
  if (!q) { console.error("uai: task search <query>"); process.exit(1); }
@@ -574,11 +1335,21 @@ async function main() {
574
1335
  default:
575
1336
  console.error([
576
1337
  "uai — agent CLI. Commands:",
1338
+ ' uai share <path> [--title "text"] (share an immutable file with people; Files plan, workspace quota)',
1339
+ ' uai secret request NAME --why "reason" [--proxy-domain host]… [--no-wait]',
1340
+ " Never paste secrets into chat. A person supplies them through the private dialog.",
1341
+ " uai artifacts",
577
1342
  " uai projects list",
1343
+ " uai files ls [org|me[/prefix]] (one folder: names, sizes, mtimes, etags; defaults to org)",
1344
+ " uai files get <org|me>/<path> [<dest>] (download; default /workspace/files/<org|me>/<path>; -r is a folder, capped at 500 files / 1 GB)",
578
1345
  " uai people list",
579
1346
  " uai task list",
580
- " uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
581
- ' uai task close --status finished|canceled [--reason "final report"]',
1347
+ " uai task create --name <n> --prompt <text|-> [--projects id,id] [--team id] [--agents handle,handle] [--astronaut engine/model/effort] [--start --request-id <logical-run-id>]",
1348
+ " uai task watch <childId> [--after <cursor>] [--limit N]",
1349
+ " uai task usage <childId>",
1350
+ ' uai task send <childId> "text"',
1351
+ ' uai task answer <childId> <cardId> "option text"',
1352
+ ' uai task close [--child <childId>] --status finished|canceled [--reason "final report"]',
582
1353
  " uai memory search <query> [--as handle] [--limit N|all]",
583
1354
  " uai memory save <text> [--project id] [--tags a,b]",
584
1355
  " uai memory delete <id>",
@@ -587,14 +1358,33 @@ async function main() {
587
1358
  " uai todo edit <#id> <text>",
588
1359
  " uai todo remove <#id>",
589
1360
  " uai todo claim|done|reopen|unclaim <#id>",
1361
+ " uai review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
1362
+ " uai crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
1363
+ " Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
590
1364
  " uai preview add <name> <containerPort>",
591
1365
  " uai preview enable|disable <name> (turn it on; prints its URL)",
592
1366
  " uai preview public|private <name> (public: a link anyone can open)",
593
- " uai project create --name <n> --repo <git-url> [--prompt <p>]",
1367
+ " uai project list | show <id|slug>",
1368
+ " uai project create --name <n> --repo <git-url> [--slug <s>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] [--attach] (--attach: also add it to this task)",
1369
+ " uai project update <id|slug> [--name <n>] [--slug <s>] [--repo <url>] [--prompt <p> | --prompt-file <f>] [--previews-file <f>] [--tool-versions-file <f>] (applies to the next task; --repo and the brief are proposals unless you created the repository in this task)",
1370
+ " uai project propose-extra <id|slug> --extra-file <f> (Dockerfile additions; a member approves them)",
1371
+ " uai project env list|observed <id|slug>",
1372
+ " uai project env add <id|slug> NAME [--label <l>] [--secret] (declares a key; values are a member's)",
1373
+ " uai project env remove <id|slug> NAME (drops a declaration with no value; never deletes a value; when the workspace allows it)",
1374
+ " uai project env propose-proxy <id|slug> NAME --domain <d>[,<d>…] [--placeholder-prefix <p> --placeholder-length <n>] (a member confirms)",
1375
+ " uai task project add <projectId|slug> [--no-wait] (clone a repository into this running task; waits until ready)",
1376
+ " uai task project status <operationId>",
594
1377
  " uai task search <query> (your own past tasks)",
595
1378
  " uai task history (list your past tasks)",
596
1379
  " uai task read <taskId> (full chat of a past task you were on)",
597
- ' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
1380
+ ' uai dispatch @agent [@agent…] [--kind build|fix|review|research|design|docs|ops] [--size s|m|l] [--pr URL|number] [--request-id key] <instruction|-> (Secretary only)',
1381
+ ' Tags precede the instruction; --pr is a candidate link, not proof of contribution. Reuse --request-id to retry the same logical dispatch.',
1382
+ " Explicit - reads UTF-8 stdin, trimmed; terminal stdin and omitted text are refused.",
1383
+ " For multi-line text or shell syntax use a quoted heredoc (executes nothing):",
1384
+ " uai dispatch @agent - <<'EOF'",
1385
+ " Your literal instruction, including quotes and shell syntax.",
1386
+ " EOF",
1387
+ " uai task create --name <n> --prompt - reads stdin the same way.",
598
1388
  " uai crew list (Astronaut only: personas with summary, skills and permissions, engines, models, limits, roster)",
599
1389
  " uai crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
600
1390
  " uai crew store (Astronaut only: the Store's persona and skill templates, names and types only)",