@workflow-manager/runner 0.6.0 → 0.8.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/dist/index.js CHANGED
@@ -7,13 +7,16 @@ import { spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import matter from "gray-matter";
9
9
  import { CliRunRenderer } from "./cliRunRenderer.js";
10
+ import { TuiRunRenderer } from "./tui/tuiRunRenderer.js";
10
11
  import { startRunnerApiServer } from "./runnerApi.js";
11
12
  import { RunnerSessionStore } from "./runnerSession.js";
12
13
  import { parseWorkflowFile, validateWorkflow } from "./parser.js";
13
14
  import { MAN_PAGE_SOURCE } from "./manPage.js";
14
15
  import { promptForApprovalDecision, runWorkflow } from "./engine.js";
15
16
  import { cmdAuth, cmdPublish, cmdPull, cmdRemoteInfo, cmdSearch } from "./remote/commands.js";
17
+ import { readSessionFile, writeSessionFile } from "./sessionFile.js";
16
18
  import { emitRunTelemetryBestEffort } from "./remote/telemetry.js";
19
+ import { BUNDLED_SKILLS } from "./generated/bundledSkills.js";
17
20
  import { adapterImplementationStatuses, adapterMockFallbackWarnings, runtimeDoctorChecks, validateRuntimeRequirements, } from "./runtimePreflight.js";
18
21
  function resolveVersion() {
19
22
  if (typeof WFM_VERSION === "string" && WFM_VERSION) {
@@ -52,15 +55,20 @@ function usage() {
52
55
  `Usage: ${cli} <command> [options]`,
53
56
  "",
54
57
  "Author and run workflows",
55
- row("scaffold [path]", "Write a starter workflow file"),
58
+ row("scaffold [path]", "Write a starter workflow file (--template agent-validated for validator example)"),
56
59
  row("validate <file>", "Check a workflow for errors"),
57
60
  row("doctor [file]", "Check host setup; with a file, preflight it"),
58
- row("run <file>", "Run a workflow with live progress"),
61
+ row("run <file>", "Run a workflow with live progress (--ui for full-screen)"),
59
62
  "",
60
- "Control a running workflow",
63
+ "Control or observe a running workflow",
61
64
  row("approve", "Approve a step waiting for review"),
62
65
  row("resume", "Resume a step waiting on external input"),
63
66
  row("cancel", "Cancel a waiting step"),
67
+ row("status [--step <key>]", "Print the run (or step) snapshot as JSON"),
68
+ row("logs [--step <key>]", "Print buffered agent logs as JSON"),
69
+ row("events [--since <seq>]", "Print run events as JSON (one-shot poll)"),
70
+ " Connect with --url/--token, --session-file <path> (written by run --session-file),",
71
+ " or WFM_RUNNER_URL/WFM_RUNNER_TOKEN.",
64
72
  "",
65
73
  "Share workflows (remote registry)",
66
74
  row("auth <login|whoami|logout>", "Sign in, check, or sign out"),
@@ -313,6 +321,159 @@ steps:
313
321
 
314
322
  Edit frontmatter to configure orchestration behavior.
315
323
  `;
324
+ const WORKFLOW_SCAFFOLD_AGENT_VALIDATED_JSON = {
325
+ key: "agent-validated-pipeline",
326
+ title: "Agent-Validated Pipeline",
327
+ description: "Repeatable task pipeline where a second agent checks the implementation against explicit criteria",
328
+ objectives: ["ship a change that meets the stated acceptance criteria"],
329
+ defaultRetryPolicy: { maxAttempts: 2 },
330
+ steps: [
331
+ {
332
+ key: "implement",
333
+ kind: "task",
334
+ objective: "Implement the requested change",
335
+ dependsOn: [],
336
+ retryPolicy: { maxAttempts: 2 },
337
+ validation: {
338
+ mode: "agent",
339
+ required: true,
340
+ autoConfirm: false,
341
+ agent: {
342
+ criteria: "The change satisfies the workflow objective, builds cleanly, and includes tests for new behavior.",
343
+ init: {
344
+ model: "openrouter/anthropic/claude-sonnet-4",
345
+ systemPrompts: ["Check the diff against the criteria; be specific about any gaps found"],
346
+ },
347
+ },
348
+ },
349
+ taskSpec: {
350
+ init: {
351
+ context: { repo: "example/repo" },
352
+ skills: ["coding", "testing"],
353
+ systemPrompts: ["Implement the change described in the workflow objective"],
354
+ },
355
+ payload: { mockResult: "success" },
356
+ },
357
+ },
358
+ {
359
+ key: "review-gate",
360
+ kind: "approval",
361
+ objective: "Human sign-off before finalizing",
362
+ dependsOn: ["implement"],
363
+ validation: { mode: "human", required: true, autoConfirm: false },
364
+ approvalSpec: {
365
+ autoApprove: false,
366
+ validation: { mode: "human", required: true, autoConfirm: false },
367
+ },
368
+ },
369
+ {
370
+ key: "finalize",
371
+ kind: "task",
372
+ objective: "Finalize the change (for example, open a PR)",
373
+ dependsOn: ["review-gate"],
374
+ validation: { mode: "none", required: false, autoConfirm: true },
375
+ taskSpec: {
376
+ init: {
377
+ systemPrompts: ["Open a PR summarizing the change and its validation history"],
378
+ },
379
+ payload: { mockResult: "success" },
380
+ },
381
+ },
382
+ ],
383
+ };
384
+ const WORKFLOW_SCAFFOLD_AGENT_VALIDATED_MARKDOWN = `---
385
+ key: agent-validated-pipeline
386
+ title: Agent-Validated Pipeline
387
+ description: Repeatable task pipeline where a second agent checks the implementation against explicit criteria
388
+ objectives:
389
+ - ship a change that meets the stated acceptance criteria
390
+ defaultRetryPolicy:
391
+ maxAttempts: 2
392
+ steps:
393
+ # "implement" omits taskSpec.adapterKey, so it runs on the default pi-agent adapter.
394
+ - key: implement
395
+ kind: task
396
+ objective: Implement the requested change
397
+ dependsOn: []
398
+ retryPolicy:
399
+ maxAttempts: 2
400
+ validation:
401
+ # mode: agent routes this step through a second, independent agent call after
402
+ # implement finishes. That validator agent reads validation.agent.criteria,
403
+ # returns a verdict (SUCCESS/QA_REJECTED/...), and the engine maps it to a QA
404
+ # action: PROCEED keeps going, RETRY_CURRENT reruns this step, ROLLBACK_PREVIOUS
405
+ # reruns an earlier step, RESTART_ALL restarts the run. Sharpen criteria to
406
+ # control what the validator accepts.
407
+ mode: agent
408
+ required: true
409
+ autoConfirm: false
410
+ agent:
411
+ criteria: >-
412
+ The change satisfies the workflow objective, builds cleanly, and
413
+ includes tests for new behavior.
414
+ init:
415
+ model: openrouter/anthropic/claude-sonnet-4
416
+ systemPrompts:
417
+ - Check the diff against the criteria; be specific about any gaps found
418
+ taskSpec:
419
+ init:
420
+ context:
421
+ repo: example/repo
422
+ skills: [coding, testing]
423
+ systemPrompts: [Implement the change described in the workflow objective]
424
+ payload:
425
+ # mockResult drives the mock adapter for local dry runs; real adapters ignore it.
426
+ mockResult: success
427
+ - key: review-gate
428
+ kind: approval
429
+ objective: Human sign-off before finalizing
430
+ dependsOn: [implement]
431
+ # Top-level validation must match approvalSpec.validation: without it, the
432
+ # parser's default (autoConfirm: true) wins and the gate auto-approves.
433
+ validation:
434
+ mode: human
435
+ required: true
436
+ autoConfirm: false
437
+ approvalSpec:
438
+ autoApprove: false
439
+ validation:
440
+ mode: human
441
+ required: true
442
+ autoConfirm: false
443
+ - key: finalize
444
+ kind: task
445
+ objective: "Finalize the change (for example, open a PR)"
446
+ dependsOn: [review-gate]
447
+ validation:
448
+ mode: none
449
+ required: false
450
+ autoConfirm: true
451
+ taskSpec:
452
+ init:
453
+ systemPrompts: [Open a PR summarizing the change and its validation history]
454
+ payload:
455
+ mockResult: success
456
+ ---
457
+
458
+ # Agent-Validated Pipeline
459
+
460
+ This workflow shows first-class agent validation: instead of (or in addition to) a
461
+ human or external check, a step's own output can be graded by a second agent call.
462
+
463
+ - \`implement\` runs, then its \`validation.agent\` config sends the result to a
464
+ validator agent along with \`criteria\` — plain-language acceptance criteria the
465
+ validator checks the work against.
466
+ - The validator's verdict becomes a QA action: \`PROCEED\` lets the run continue,
467
+ \`RETRY_CURRENT\` re-runs \`implement\` with the validator's feedback,
468
+ \`ROLLBACK_PREVIOUS\` re-runs an earlier step, and \`RESTART_ALL\` restarts the run.
469
+ Retries are bounded by \`retryPolicy.maxAttempts\`.
470
+ - \`review-gate\` is a human approval step — agent validation cannot be used on
471
+ approval steps, so high-stakes changes still get a human in the loop before
472
+ \`finalize\` runs.
473
+
474
+ Tighten \`validation.agent.criteria\` to describe exactly what "done" means for this
475
+ step; the validator only knows what criteria tells it.
476
+ `;
316
477
  function resolveScaffoldFormat(targetPath, explicitFormat) {
317
478
  if (explicitFormat === "markdown" || explicitFormat === "json") {
318
479
  return explicitFormat;
@@ -322,6 +483,7 @@ function resolveScaffoldFormat(targetPath, explicitFormat) {
322
483
  function parseScaffoldArgs(args) {
323
484
  let targetPath;
324
485
  let format;
486
+ let template;
325
487
  for (let i = 0; i < args.length; i += 1) {
326
488
  const arg = args[i];
327
489
  if (arg === "--format") {
@@ -329,11 +491,16 @@ function parseScaffoldArgs(args) {
329
491
  i += 1;
330
492
  continue;
331
493
  }
494
+ if (arg === "--template") {
495
+ template = args[i + 1];
496
+ i += 1;
497
+ continue;
498
+ }
332
499
  if (!arg.startsWith("-") && !targetPath) {
333
500
  targetPath = arg;
334
501
  }
335
502
  }
336
- return { targetPath, format };
503
+ return { targetPath, format, template };
337
504
  }
338
505
  const DEFAULT_INSTALL_SKILL = "workflow-manager-cli";
339
506
  const SKILL_INSTALL_TARGETS = {
@@ -346,11 +513,22 @@ const SKILL_INSTALL_TARGETS = {
346
513
  globalDir: path.join(os.homedir(), ".config", "opencode", "skill"),
347
514
  },
348
515
  };
349
- function packagedSkillsDir() {
516
+ export function packagedSkillsDir() {
350
517
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
351
518
  }
352
- function listPackagedSkills() {
353
- const root = packagedSkillsDir();
519
+ export function parseSkillDescription(skillMarkdown) {
520
+ try {
521
+ const parsed = matter(skillMarkdown);
522
+ if (typeof parsed.data.description === "string") {
523
+ return parsed.data.description.replace(/\s+/g, " ").trim();
524
+ }
525
+ }
526
+ catch {
527
+ // skills without parseable frontmatter are still installable
528
+ }
529
+ return "";
530
+ }
531
+ export function listPackagedSkillsFromDisk(root) {
354
532
  if (!fs.existsSync(root)) {
355
533
  return [];
356
534
  }
@@ -362,20 +540,44 @@ function listPackagedSkills() {
362
540
  const skillFile = path.join(dir, "SKILL.md");
363
541
  if (!fs.existsSync(skillFile))
364
542
  continue;
365
- let description = "";
366
- try {
367
- const parsed = matter(fs.readFileSync(skillFile, "utf-8"));
368
- if (typeof parsed.data.description === "string") {
369
- description = parsed.data.description.replace(/\s+/g, " ").trim();
370
- }
371
- }
372
- catch {
373
- // skills without parseable frontmatter are still installable
374
- }
375
- skills.push({ name: entry.name, dir, description });
543
+ const description = parseSkillDescription(fs.readFileSync(skillFile, "utf-8"));
544
+ skills.push({ name: entry.name, description, source: { kind: "disk", dir } });
376
545
  }
377
546
  return skills;
378
547
  }
548
+ export function listPackagedSkillsFromBundle(bundle = BUNDLED_SKILLS) {
549
+ const skills = [];
550
+ for (const name of Object.keys(bundle).sort()) {
551
+ const files = bundle[name];
552
+ const skillFile = files.find((file) => file.name === "SKILL.md");
553
+ if (!skillFile)
554
+ continue;
555
+ skills.push({ name, description: parseSkillDescription(skillFile.content), source: { kind: "bundled", files } });
556
+ }
557
+ return skills;
558
+ }
559
+ export function listPackagedSkills(root = packagedSkillsDir()) {
560
+ const onDisk = listPackagedSkillsFromDisk(root);
561
+ if (onDisk.length > 0) {
562
+ return onDisk;
563
+ }
564
+ return listPackagedSkillsFromBundle();
565
+ }
566
+ export function materializeSkillFiles(skill, destDir) {
567
+ if (skill.source.kind === "disk") {
568
+ for (const entry of fs.readdirSync(skill.source.dir, { withFileTypes: true })) {
569
+ if (!entry.isFile() || entry.name === "README.md")
570
+ continue;
571
+ fs.copyFileSync(path.join(skill.source.dir, entry.name), path.join(destDir, entry.name));
572
+ }
573
+ return;
574
+ }
575
+ for (const file of skill.source.files) {
576
+ if (file.name === "README.md")
577
+ continue;
578
+ fs.writeFileSync(path.join(destDir, file.name), file.content, "utf-8");
579
+ }
580
+ }
379
581
  function cmdSkillList() {
380
582
  const skills = listPackagedSkills();
381
583
  if (skills.length === 0) {
@@ -460,16 +662,12 @@ function cmdSkillInstall(args) {
460
662
  return 1;
461
663
  }
462
664
  fs.mkdirSync(destDir, { recursive: true });
463
- for (const entry of fs.readdirSync(skill.dir, { withFileTypes: true })) {
464
- if (!entry.isFile() || entry.name === "README.md")
465
- continue;
466
- fs.copyFileSync(path.join(skill.dir, entry.name), path.join(destDir, entry.name));
467
- }
665
+ materializeSkillFiles(skill, destDir);
468
666
  console.log(`Installed skill ${name} -> ${destDir}`);
469
667
  }
470
668
  return 0;
471
669
  }
472
- function cmdScaffold(targetPath, format) {
670
+ function cmdScaffold(targetPath, format, template) {
473
671
  const resolvedPath = targetPath ? path.resolve(targetPath) : path.resolve("./example-workflow.md");
474
672
  const normalizedFormat = format?.toLowerCase();
475
673
  const resolvedFormat = resolveScaffoldFormat(resolvedPath, normalizedFormat);
@@ -477,10 +675,19 @@ function cmdScaffold(targetPath, format) {
477
675
  console.error(`Invalid --format value: ${format}. Use markdown or json.`);
478
676
  return 1;
479
677
  }
480
- const template = resolvedFormat === "json"
481
- ? `${JSON.stringify(WORKFLOW_SCAFFOLD_JSON, null, 2)}\n`
482
- : WORKFLOW_SCAFFOLD_MARKDOWN;
483
- fs.writeFileSync(resolvedPath, template, "utf-8");
678
+ const normalizedTemplate = template?.toLowerCase() ?? "default";
679
+ if (normalizedTemplate !== "default" && normalizedTemplate !== "agent-validated") {
680
+ console.error(`Invalid --template value: ${template}. Use default or agent-validated.`);
681
+ return 1;
682
+ }
683
+ const content = normalizedTemplate === "agent-validated"
684
+ ? resolvedFormat === "json"
685
+ ? `${JSON.stringify(WORKFLOW_SCAFFOLD_AGENT_VALIDATED_JSON, null, 2)}\n`
686
+ : WORKFLOW_SCAFFOLD_AGENT_VALIDATED_MARKDOWN
687
+ : resolvedFormat === "json"
688
+ ? `${JSON.stringify(WORKFLOW_SCAFFOLD_JSON, null, 2)}\n`
689
+ : WORKFLOW_SCAFFOLD_MARKDOWN;
690
+ fs.writeFileSync(resolvedPath, content, "utf-8");
484
691
  console.log(`Scaffolded ${resolvedFormat} workflow: ${resolvedPath}`);
485
692
  return 0;
486
693
  }
@@ -623,40 +830,66 @@ function cmdDoctor(args) {
623
830
  }
624
831
  return exitCode;
625
832
  }
626
- async function runnerControlRequest(action, args) {
627
- const baseUrl = getFlagFromArgs(args, "--url") ?? process.env.WFM_RUNNER_URL;
628
- const token = getFlagFromArgs(args, "--token") ?? process.env.WFM_RUNNER_TOKEN;
629
- const stepKey = getFlagFromArgs(args, "--step");
630
- const actor = getFlagFromArgs(args, "--actor");
631
- const note = getFlagFromArgs(args, "--note");
632
- const source = getFlagFromArgs(args, "--source") ?? "cli";
833
+ function resolveRunnerConnection(args) {
834
+ let baseUrl = getFlagFromArgs(args, "--url");
835
+ let token = getFlagFromArgs(args, "--token");
836
+ let runId = getFlagFromArgs(args, "--run-id");
837
+ const sessionFilePath = getFlagFromArgs(args, "--session-file");
838
+ if (sessionFilePath && (!baseUrl || !token || !runId)) {
839
+ const session = readSessionFile(sessionFilePath);
840
+ if (typeof session === "string") {
841
+ return session;
842
+ }
843
+ baseUrl = baseUrl ?? session.baseUrl;
844
+ token = token ?? session.attachToken;
845
+ runId = runId ?? session.runId;
846
+ }
847
+ baseUrl = baseUrl ?? process.env.WFM_RUNNER_URL;
848
+ token = token ?? process.env.WFM_RUNNER_TOKEN;
633
849
  if (!baseUrl) {
634
- console.error(`Missing --url. You can also set WFM_RUNNER_URL.`);
635
- return 1;
850
+ return `Missing --url. You can also pass --session-file or set WFM_RUNNER_URL.`;
636
851
  }
637
852
  if (!token) {
638
- console.error(`Missing --token. You can also set WFM_RUNNER_TOKEN.`);
853
+ return `Missing --token. You can also pass --session-file or set WFM_RUNNER_TOKEN.`;
854
+ }
855
+ return { baseUrl, token, runId };
856
+ }
857
+ async function resolveRunnerRunId(connection, headers) {
858
+ if (connection.runId) {
859
+ return { runId: connection.runId };
860
+ }
861
+ const sessionResponse = await fetch(`${connection.baseUrl}/session`, { headers });
862
+ if (!sessionResponse.ok) {
863
+ const message = await sessionResponse.text();
864
+ return { error: `Failed to discover run id: ${message}` };
865
+ }
866
+ const session = (await sessionResponse.json());
867
+ const runId = session.run?.runId;
868
+ if (!runId) {
869
+ return { error: "Could not determine run id. Pass --run-id explicitly." };
870
+ }
871
+ return { runId };
872
+ }
873
+ async function runnerControlRequest(action, args) {
874
+ const connection = resolveRunnerConnection(args);
875
+ if (typeof connection === "string") {
876
+ console.error(connection);
639
877
  return 1;
640
878
  }
879
+ const stepKey = getFlagFromArgs(args, "--step");
880
+ const actor = getFlagFromArgs(args, "--actor");
881
+ const note = getFlagFromArgs(args, "--note");
882
+ const source = getFlagFromArgs(args, "--source") ?? "cli";
641
883
  const headers = {
642
- Authorization: `Bearer ${token}`,
884
+ Authorization: `Bearer ${connection.token}`,
643
885
  };
644
- let runId = getFlagFromArgs(args, "--run-id");
645
- if (!runId) {
646
- const sessionResponse = await fetch(`${baseUrl}/session`, { headers });
647
- if (!sessionResponse.ok) {
648
- const message = await sessionResponse.text();
649
- console.error(`Failed to discover run id: ${message}`);
650
- return 1;
651
- }
652
- const session = (await sessionResponse.json());
653
- runId = session.run?.runId;
654
- }
655
- if (!runId) {
656
- console.error("Could not determine run id. Pass --run-id explicitly.");
886
+ const resolved = await resolveRunnerRunId(connection, headers);
887
+ if (resolved.error || !resolved.runId) {
888
+ console.error(resolved.error ?? "Could not determine run id. Pass --run-id explicitly.");
657
889
  return 1;
658
890
  }
659
- const response = await fetch(`${baseUrl}/runs/${runId}/${action}`, {
891
+ const runId = resolved.runId;
892
+ const response = await fetch(`${connection.baseUrl}/runs/${runId}/${action}`, {
660
893
  method: "POST",
661
894
  headers: {
662
895
  ...headers,
@@ -681,13 +914,84 @@ async function runnerControlRequest(action, args) {
681
914
  console.log(`${decision} ${resolvedStep}`);
682
915
  return 0;
683
916
  }
917
+ async function runnerReadRequest(command, args) {
918
+ const connection = resolveRunnerConnection(args);
919
+ if (typeof connection === "string") {
920
+ console.error(connection);
921
+ return 1;
922
+ }
923
+ const headers = {
924
+ Authorization: `Bearer ${connection.token}`,
925
+ };
926
+ try {
927
+ const resolved = await resolveRunnerRunId(connection, headers);
928
+ if (resolved.error || !resolved.runId) {
929
+ console.error(resolved.error ?? "Could not determine run id. Pass --run-id explicitly.");
930
+ return 1;
931
+ }
932
+ const runId = encodeURIComponent(resolved.runId);
933
+ let requestPath;
934
+ if (command === "status") {
935
+ const stepKey = getFlagFromArgs(args, "--step");
936
+ requestPath = stepKey ? `/runs/${runId}/steps/${encodeURIComponent(stepKey)}` : `/runs/${runId}`;
937
+ }
938
+ else if (command === "logs") {
939
+ const query = new URLSearchParams();
940
+ const stepKey = getFlagFromArgs(args, "--step");
941
+ const limit = getFlagFromArgs(args, "--limit");
942
+ const cursor = getFlagFromArgs(args, "--cursor");
943
+ if (stepKey)
944
+ query.set("stepKey", stepKey);
945
+ if (limit)
946
+ query.set("limit", limit);
947
+ if (cursor)
948
+ query.set("cursor", cursor);
949
+ const queryString = query.toString();
950
+ requestPath = `/runs/${runId}/logs${queryString ? `?${queryString}` : ""}`;
951
+ }
952
+ else {
953
+ const query = new URLSearchParams();
954
+ const since = getFlagFromArgs(args, "--since");
955
+ if (since)
956
+ query.set("sinceSequence", since);
957
+ query.set("includeLogs", args.includes("--include-logs") ? "true" : "false");
958
+ requestPath = `/runs/${runId}/events/list?${query.toString()}`;
959
+ }
960
+ const response = await fetch(`${connection.baseUrl}${requestPath}`, { headers });
961
+ const payloadText = await response.text();
962
+ if (!response.ok) {
963
+ let message = `Request failed with status ${response.status}`;
964
+ try {
965
+ const payload = JSON.parse(payloadText);
966
+ if (typeof payload.message === "string") {
967
+ message = payload.message;
968
+ }
969
+ }
970
+ catch {
971
+ // keep the fallback message
972
+ }
973
+ console.error(`${command} failed: ${message}`);
974
+ return 1;
975
+ }
976
+ console.log(payloadText);
977
+ return 0;
978
+ }
979
+ catch (error) {
980
+ console.error(`${command} failed: ${error.message}`);
981
+ return 1;
982
+ }
983
+ }
684
984
  async function cmdRun(filePath) {
685
985
  const resolvedPath = path.resolve(filePath);
686
986
  const startedAt = Date.now();
987
+ const sessionFilePath = getFlag("--session-file");
988
+ let sessionFileState;
989
+ let finalRunStatus;
687
990
  let workflow;
688
991
  let runnerServer;
689
992
  let sessionStore;
690
993
  let liveRenderer;
994
+ let tuiRenderer;
691
995
  try {
692
996
  workflow = parseWorkflowFile(resolvedPath);
693
997
  const errors = validateWorkflow(workflow);
@@ -729,57 +1033,89 @@ async function cmdRun(filePath) {
729
1033
  console.error("--port must be an integer between 0 and 65535");
730
1034
  return 1;
731
1035
  }
1036
+ const wantUi = hasFlag("--ui");
1037
+ const useTui = wantUi && process.stdout.isTTY === true && process.stdin.isTTY === true;
1038
+ if (wantUi && !useTui) {
1039
+ process.stderr.write("⚠ --ui requires an interactive terminal; falling back to standard output\n");
1040
+ }
732
1041
  sessionStore = new RunnerSessionStore({
733
1042
  runId,
734
1043
  workflow,
735
1044
  objective: objective ?? workflow.title,
736
1045
  objectives: workflow.objectives ?? [],
737
1046
  });
738
- liveRenderer = new CliRunRenderer({
739
- workflow,
740
- verbose: hasFlag("--verbose"),
741
- });
1047
+ if (!useTui) {
1048
+ liveRenderer = new CliRunRenderer({
1049
+ workflow,
1050
+ verbose: hasFlag("--verbose"),
1051
+ });
1052
+ }
742
1053
  runnerServer = await startRunnerApiServer(sessionStore, requestedPort);
743
1054
  const session = sessionStore.sessionInfo();
744
- process.stderr.write(`Attach API: ${session.baseUrl} (token ${session.attachToken})\n`);
1055
+ if (sessionFilePath) {
1056
+ sessionFileState = {
1057
+ baseUrl: session.baseUrl,
1058
+ attachToken: session.attachToken,
1059
+ runId,
1060
+ pid: process.pid,
1061
+ startedAt: session.startedAt,
1062
+ };
1063
+ writeSessionFile(sessionFilePath, sessionFileState);
1064
+ }
1065
+ if (!useTui) {
1066
+ process.stderr.write(`Attach API: ${session.baseUrl} (token ${session.attachToken})\n`);
1067
+ }
1068
+ if (useTui) {
1069
+ tuiRenderer = new TuiRunRenderer({
1070
+ workflow,
1071
+ session: sessionStore,
1072
+ attachUrl: session.baseUrl,
1073
+ attachToken: session.attachToken,
1074
+ });
1075
+ tuiRenderer.start();
1076
+ }
745
1077
  const result = await runWorkflow(workflow, {
746
1078
  runId,
747
1079
  objective,
748
1080
  input,
749
1081
  confirmations,
750
1082
  autoConfirmAll: hasFlag("--auto-confirm-all"),
751
- interactive: process.stdin.isTTY,
1083
+ interactive: useTui ? false : process.stdin.isTTY,
752
1084
  workflowFilePath: resolvedPath,
753
- approvalPrompt: async (request) => {
754
- liveRenderer?.pauseHeartbeat();
755
- try {
756
- const decision = await promptForApprovalDecision(request.stepKey, request.reason, request.validation ?? "external", request.preview ?? null, "cli", request.signal);
757
- if (!decision) {
1085
+ approvalPrompt: useTui
1086
+ ? undefined
1087
+ : async (request) => {
1088
+ liveRenderer?.pauseHeartbeat();
1089
+ try {
1090
+ const decision = await promptForApprovalDecision(request.stepKey, request.reason, request.validation ?? "external", request.preview ?? null, "cli", request.signal);
1091
+ if (!decision) {
1092
+ return null;
1093
+ }
1094
+ const metadata = {
1095
+ actor: decision.actor,
1096
+ note: decision.note,
1097
+ source: decision.source,
1098
+ };
1099
+ const outcome = decision.decision === "cancelled"
1100
+ ? sessionStore?.cancel(request.stepKey, metadata)
1101
+ : request.validation === "external"
1102
+ ? sessionStore?.resume(request.stepKey, metadata)
1103
+ : sessionStore?.approve(request.stepKey, metadata);
1104
+ if (outcome && !outcome.ok) {
1105
+ process.stderr.write(`Could not apply terminal decision for ${request.stepKey}: ${outcome.reason ?? "unknown error"}\n`);
1106
+ }
758
1107
  return null;
759
1108
  }
760
- const metadata = {
761
- actor: decision.actor,
762
- note: decision.note,
763
- source: decision.source,
764
- };
765
- const outcome = decision.decision === "cancelled"
766
- ? sessionStore?.cancel(request.stepKey, metadata)
767
- : request.validation === "external"
768
- ? sessionStore?.resume(request.stepKey, metadata)
769
- : sessionStore?.approve(request.stepKey, metadata);
770
- if (outcome && !outcome.ok) {
771
- process.stderr.write(`Could not apply terminal decision for ${request.stepKey}: ${outcome.reason ?? "unknown error"}\n`);
1109
+ finally {
1110
+ liveRenderer?.resumeHeartbeat();
772
1111
  }
773
- return null;
774
- }
775
- finally {
776
- liveRenderer?.resumeHeartbeat();
777
- }
778
- },
779
- observer: combineRunObservers(sessionStore, liveRenderer),
1112
+ },
1113
+ observer: combineRunObservers(sessionStore, useTui ? tuiRenderer : liveRenderer),
780
1114
  controller: sessionStore,
781
1115
  });
782
- liveRenderer.close();
1116
+ tuiRenderer?.stop();
1117
+ liveRenderer?.close();
1118
+ finalRunStatus = result.status;
783
1119
  if (hasFlag("--json")) {
784
1120
  console.log(JSON.stringify({ session: sessionStore.sessionInfo(), ...result }, null, 2));
785
1121
  }
@@ -807,6 +1143,7 @@ async function cmdRun(filePath) {
807
1143
  return result.status === "succeeded" ? 0 : 2;
808
1144
  }
809
1145
  catch (err) {
1146
+ tuiRenderer?.stop();
810
1147
  liveRenderer?.close();
811
1148
  console.error(`Run error: ${err.message}`);
812
1149
  if (workflow) {
@@ -820,7 +1157,20 @@ async function cmdRun(filePath) {
820
1157
  return 1;
821
1158
  }
822
1159
  finally {
1160
+ tuiRenderer?.stop();
823
1161
  liveRenderer?.close();
1162
+ if (sessionFilePath && sessionFileState) {
1163
+ try {
1164
+ writeSessionFile(sessionFilePath, {
1165
+ ...sessionFileState,
1166
+ endedAt: new Date().toISOString(),
1167
+ status: finalRunStatus ?? "failed",
1168
+ });
1169
+ }
1170
+ catch {
1171
+ // session-file finalization is best effort; the run result is authoritative
1172
+ }
1173
+ }
824
1174
  await runnerServer?.close().catch(() => undefined);
825
1175
  }
826
1176
  }
@@ -849,8 +1199,8 @@ async function main() {
849
1199
  process.exit(1);
850
1200
  }
851
1201
  if (cmd === "scaffold") {
852
- const { targetPath, format } = parseScaffoldArgs(process.argv.slice(3));
853
- process.exit(cmdScaffold(targetPath, format));
1202
+ const { targetPath, format, template } = parseScaffoldArgs(process.argv.slice(3));
1203
+ process.exit(cmdScaffold(targetPath, format, template));
854
1204
  }
855
1205
  if (cmd === "man") {
856
1206
  process.exit(cmdMan());
@@ -874,6 +1224,9 @@ async function main() {
874
1224
  if (cmd === "approve" || cmd === "resume" || cmd === "cancel") {
875
1225
  process.exit(await runnerControlRequest(cmd, process.argv.slice(3)));
876
1226
  }
1227
+ if (cmd === "status" || cmd === "logs" || cmd === "events") {
1228
+ process.exit(await runnerReadRequest(cmd, process.argv.slice(3)));
1229
+ }
877
1230
  if (cmd === "auth") {
878
1231
  process.exit(await cmdAuth(process.argv.slice(3)));
879
1232
  }
@@ -907,4 +1260,30 @@ async function main() {
907
1260
  usage();
908
1261
  process.exit(1);
909
1262
  }
910
- void main();
1263
+ // Run main() only when this module is the process entrypoint (direct `bun
1264
+ // run`/`node` invocation, an npm-installed symlinked bin, or a `bun build
1265
+ // --compile` standalone binary) — never when imported as a module, e.g. by
1266
+ // tests exercising the exported skill-catalog helpers below.
1267
+ function isEntryModule() {
1268
+ if (!process.argv[1]) {
1269
+ return false;
1270
+ }
1271
+ const self = fileURLToPath(import.meta.url);
1272
+ let invoked = process.argv[1];
1273
+ try {
1274
+ invoked = fs.realpathSync(invoked);
1275
+ }
1276
+ catch {
1277
+ // compiled/virtual filesystem paths (e.g. bun's $bunfs) can't be
1278
+ // realpath'd; fall back to the raw invoked path.
1279
+ }
1280
+ try {
1281
+ return path.resolve(invoked) === self;
1282
+ }
1283
+ catch {
1284
+ return false;
1285
+ }
1286
+ }
1287
+ if (isEntryModule()) {
1288
+ void main();
1289
+ }