Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions packages/argue-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createOutputFormatter } from "./output.js";
import { loadRunInput } from "./run-input.js";
import { defaultOutputDirTemplate, resolveRunPlan } from "./run-plan.js";
import { createTaskDelegate } from "./runtime/delegate.js";
import { createSpinner } from "./spinner.js";
import { MAX_ENCODED_BYTES, openReportInViewer, resolveLatestRequestId } from "./view.js";
import { VENDOR_PRESETS, getVendorNames } from "./vendors.js";
export type { CliSdkProviderAdapter, CreateCliSdkProviderAdapter, ProviderTaskRunnerArgs } from "./runtime/types.js";
Expand Down Expand Up @@ -168,7 +169,9 @@ async function runHeadless(args: string[], io: Pick<typeof console, "log" | "err
const out = createOutputFormatter(io, {
verbose: options.value.verbose,
noColor: options.value.noColor,
isTTY: process.stdout.isTTY
isTTY: process.stdout.isTTY,
spinnerStream: process.stderr,
spinnerIsTTY: process.stderr.isTTY
});

out.planResolved({
Expand Down Expand Up @@ -469,9 +472,16 @@ async function runAction(args: string[], io: Pick<typeof console, "log" | "error
fullResult: options.value.includeFullResult ? JSON.parse(JSON.stringify(argueResult)) : undefined
};

const spinner = createSpinner(process.stderr, `argue act · ${actorId} thinking…`, {
isTTY: process.stderr.isTTY,
noColor: options.value.noColor
});
spinner.start();

try {
const dispatched = await taskDelegate.dispatch(actionTask as AgentTaskInput);
const awaited = await taskDelegate.awaitResult(dispatched.taskId, 20 * 60 * 1_000);
spinner.stop();

if (!awaited.ok || !awaited.output) {
io.error(`Action failed: ${awaited.error ?? "unknown error"}`);
Expand All @@ -487,8 +497,13 @@ async function runAction(args: string[], io: Pick<typeof console, "log" | "error
io.log(actionResult.data.output.fullResponse);
return { ok: true, code: 0 };
} catch (error) {
// Stop the spinner before printing so the error line is not interleaved
// with an in-flight animation frame or written under a hidden cursor.
spinner.stop();
io.error(`Action execution failed: ${String(error)}`);
return { ok: false, code: 1 };
} finally {
spinner.stop();
}
}

Expand Down Expand Up @@ -649,10 +664,24 @@ async function resolveConfiguredViewerUrl(explicitConfigPath?: string): Promise<
function parseActOptions(args: string[]):
| {
ok: true;
value: { resultPath: string; task: string; agent?: string; configPath?: string; includeFullResult: boolean };
value: {
resultPath: string;
task: string;
agent?: string;
configPath?: string;
includeFullResult: boolean;
noColor?: boolean;
};
}
| { ok: false; error: string } {
const out: { resultPath?: string; task?: string; agent?: string; configPath?: string; includeFullResult: boolean } = {
const out: {
resultPath?: string;
task?: string;
agent?: string;
configPath?: string;
includeFullResult: boolean;
noColor?: boolean;
} = {
includeFullResult: true
};

Expand Down Expand Up @@ -696,6 +725,11 @@ function parseActOptions(args: string[]):
continue;
}

if (arg === "--no-color") {
out.noColor = true;
continue;
}

return { ok: false, error: `Unknown option for act: ${arg}` };
}

Expand All @@ -710,7 +744,8 @@ function parseActOptions(args: string[]):
task: out.task,
agent: out.agent,
configPath: out.configPath,
includeFullResult: out.includeFullResult
includeFullResult: out.includeFullResult,
noColor: out.noColor
}
};
}
Expand Down Expand Up @@ -1401,7 +1436,9 @@ function printHelp(io: Pick<typeof console, "log">): void {
io.log("Usage:");
io.log(" argue run|exec [options] # run a debate session");
io.log(" argue view [request-id] # open a completed run in the hosted viewer");
io.log(" argue act --result <path> --task <prompt> [--agent <id>] [--config <path>] [--no-action-full-result]");
io.log(
" argue act --result <path> --task <prompt> [--agent <id>] [--config <path>] [--no-action-full-result] [--no-color]"
);
io.log(" argue config init # create empty config file");
io.log(" argue config add-provider ... # append provider to config");
io.log(" argue config add-agent ... # append agent to config");
Expand Down
37 changes: 37 additions & 0 deletions packages/argue-cli/src/output.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import pc from "picocolors";
import type { ArgueEvent, ArgueResult } from "@onevcat/argue";
import { formatMs } from "./artifacts.js";
import { createSpinner, type SpinnerStream } from "./spinner.js";

export type OutputOptions = {
verbose?: boolean;
noColor?: boolean;
isTTY?: boolean;
spinnerStream?: SpinnerStream;
spinnerIsTTY?: boolean;
};

export type OutputIO = Pick<typeof console, "log" | "error">;
Expand Down Expand Up @@ -36,6 +39,15 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
const tag = c.cyan("[argue]");
const verbose = options.verbose ?? false;

const spinnerStream = options.spinnerStream ?? null;
const spinner = spinnerStream
? createSpinner(spinnerStream, "", {
isTTY: options.spinnerIsTTY ?? spinnerStream.isTTY ?? false,
noColor: options.noColor
})
: null;
let waitingFor: Set<string> = new Set();

function stanceIcon(stance: string): string {
if (stance === "agree") return c.green("✓");
if (stance === "disagree") return c.red("✗");
Expand Down Expand Up @@ -80,9 +92,18 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
const round = readNumber(payload.round);
const roundTag = formatRoundTag(phase, round);

// Any event arriving means there is news to print. Stop the spinner
// first so log lines start at column 0; the spinner is rearmed below
// only when we are about to wait again.
spinner?.stop();

if (event.type === "RoundDispatched") {
const participants = readStringArray(payload.participants);
io.log(`${tag} ${c.bold(roundTag)} dispatched ${c.dim("-> " + participants.join(", "))}`);
waitingFor = new Set(participants);
if (waitingFor.size > 0) {
spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`);
}
return;
}

Expand Down Expand Up @@ -112,6 +133,10 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
if (verbose) {
printVerboseResponse(payload);
}
waitingFor.delete(participantId);
if (waitingFor.size > 0) {
spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`);
}
return;
}

Expand All @@ -126,13 +151,20 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
}

io.log(`${tag} ${c.bold(roundTag)} ${c.red(`${participantId} eliminated`)} ${c.dim(suffix)}`);
waitingFor.delete(participantId);
if (waitingFor.size > 0) {
spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`);
}
return;
}

if (event.type === "ClaimsMerged") {
const source = readString(payload.sourceClaimId) ?? "?";
const mergedInto = readString(payload.mergedInto) ?? "?";
io.log(`${tag} ${c.bold(roundTag)} ${c.yellow(`claim merged ${source} -> ${mergedInto}`)}`);
if (waitingFor.size > 0) {
spinner?.start(`${roundTag} waiting on ${[...waitingFor].join(", ")}…`);
}
return;
}

Expand All @@ -148,6 +180,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
`${tag} ${roundTag} completed: done=${completed} timeout=${timedOut} failed=${failed} claims=${claimCatalogSize} (+${newClaims}, -${mergeCount})`
)
);
waitingFor.clear();
return;
}

Expand All @@ -164,6 +197,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
if (event.type === "ReportDispatched") {
const reporterId = readString(payload.reporterId) ?? "unknown";
io.log(`${tag} ${c.magenta(`report dispatched -> ${reporterId}`)}`);
spinner?.start(`composing report via ${reporterId}…`);
return;
}

Expand All @@ -174,6 +208,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
if (verbose && prompt) {
io.log(c.dim(` prompt: ${singleLine(prompt)}`));
}
spinner?.start(`${actorId} executing action…`);
return;
}

Expand Down Expand Up @@ -210,6 +245,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
},

runCompleted(result: ArgueResult, paths: { resultPath: string; summaryPath: string }) {
spinner?.stop();
io.log("");
io.log(c.dim("─".repeat(60)));
io.log("");
Expand Down Expand Up @@ -237,6 +273,7 @@ export function createOutputFormatter(io: OutputIO, options: OutputOptions = {})
},

runFailed(error: unknown, errorPath: string) {
spinner?.stop();
io.log("");
io.log(c.dim("─".repeat(60)));
io.log("");
Expand Down
6 changes: 5 additions & 1 deletion packages/argue-cli/src/runtime/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,12 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<
}

return new Promise<T>((resolve, reject) => {
// Intentionally not unref'd: if every other handle is unref'd (e.g. mock
// providers that unref their own setTimeout), unref'ing this one too lets
// the event loop exit before `promise` ever settles, leaving the top-level
// await hung. Keeping it ref'd is safe because clearTimeout is called on
// both resolve and reject paths.
const timer = setTimeout(() => reject(new TimeoutError()), timeoutMs);
timer.unref?.();

promise.then(
(value) => {
Expand Down
77 changes: 77 additions & 0 deletions packages/argue-cli/src/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const BRAILLE_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];

export type SpinnerStream = {
write(chunk: string): boolean | void;
isTTY?: boolean;
};

export type SpinnerOptions = {
intervalMs?: number;
isTTY?: boolean;
noColor?: boolean;
};

export type Spinner = {
start(label?: string): void;
stop(finalLine?: string): void;
setLabel(next: string): void;
isActive(): boolean;
};

export function createSpinner(stream: SpinnerStream, label: string, options: SpinnerOptions = {}): Spinner {
const isTTY = options.isTTY ?? stream.isTTY ?? false;
const useColor = !options.noColor && !process.env.NO_COLOR && isTTY;
const intervalMs = options.intervalMs ?? 80;

let currentLabel = label;
let frame = 0;
let timer: ReturnType<typeof setInterval> | null = null;
let active = false;

const dim = useColor ? "\x1b[2m" : "";
const cyan = useColor ? "\x1b[36m" : "";
const reset = useColor ? "\x1b[0m" : "";

function render(): void {
const glyph = BRAILLE_FRAMES[frame % BRAILLE_FRAMES.length];
stream.write(`\r${cyan}${glyph}${reset} ${dim}${currentLabel}${reset}\x1b[K`);
frame += 1;
}

return {
start(nextLabel?: string): void {
if (nextLabel !== undefined) currentLabel = nextLabel;
if (active) return;
active = true;
if (!isTTY) {
// No animation in non-TTY contexts (logs, CI). Still emit one line so
// there is a visible breadcrumb that the command is running.
stream.write(`${currentLabel}\n`);
return;
}
stream.write("\x1b[?25l");
render();
timer = setInterval(render, intervalMs);
},
stop(finalLine?: string): void {
if (!active) return;
active = false;
if (timer) {
clearInterval(timer);
timer = null;
}
if (isTTY) {
stream.write("\r\x1b[K\x1b[?25h");
}
if (finalLine) {
stream.write(`${finalLine}\n`);
}
},
setLabel(next: string): void {
currentLabel = next;
},
isActive(): boolean {
return active;
}
};
}
55 changes: 55 additions & 0 deletions packages/argue-cli/test/index-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,61 @@ describe("runCli command branches", () => {
expect(io2.errors.some((x) => x.includes("--task"))).toBe(true);
});

it("accepts --no-color for argue act", async () => {
const root = await mkdtemp(join(tmpdir(), "argue-cli-act-no-color-"));
const configPath = join(root, "argue.config.json");

await writeFile(
configPath,
JSON.stringify({
schemaVersion: 1,
output: {
resultPath: "./out/{requestId}.result.json",
jsonlPath: "./out/{requestId}.events.jsonl",
summaryPath: "./out/{requestId}.summary.md"
},
defaults: {
defaultAgents: ["a1", "a2"],
minRounds: 1,
maxRounds: 1,
composer: "builtin"
},
providers: {
mock: {
type: "mock",
models: {
fake: {}
}
}
},
agents: [
{ id: "a1", provider: "mock", model: "fake" },
{ id: "a2", provider: "mock", model: "fake" }
]
}),
"utf8"
);

const runIO = createIO();
const runResult = await runCli(
["run", "--config", configPath, "--request-id", "act-no-color", "--task", "t"],
runIO
);
expect(runResult).toEqual({ ok: true, code: 0 });

const resultPath = join(root, "out", "act-no-color.result.json");

const actIO = createIO();
const actResult = await runCli(
["act", "--config", configPath, "--result", resultPath, "--task", "do stuff", "--no-color"],
actIO
);

expect(actResult).toEqual({ ok: true, code: 0 });
// Sanity: no "Unknown option for act: --no-color" error reached the IO.
expect(actIO.errors.some((x) => x.includes("Unknown option for act"))).toBe(false);
});

it("accepts action flags including no-action-full-result", async () => {
const root = await mkdtemp(join(tmpdir(), "argue-cli-run-action-flags-"));
const configPath = join(root, "argue.config.json");
Expand Down
Loading
Loading