Skip to content
Closed
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
12 changes: 12 additions & 0 deletions src/cli/oneshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { buildGitStatusSnapshot } from '../context/git-status-snapshot.js';
import { acceptanceShell } from '../core/task/acceptance-command.js';
import { runStopHooks, type StopHookResult } from './hooks.js';
import { runProcess } from '../utils/run-process.js';
import { createRunProgressTracker } from './run-progress.js';

/** Format the oneshot exit warning when background work outlives the wait window. */
export function formatOneshotStillRunningBackgroundNotice(
Expand Down Expand Up @@ -405,6 +406,15 @@ export async function runOneShot(
const stdout = options.stdout ?? process.stdout;
const workspaceDir = options.cwd ?? process.cwd();
const renderer = outputFormat === 'text' ? createCliRunRenderer({ workspaceDir }) : null;
// O99: json / stream-json have no text renderer — still emit progress on stderr.
const jsonProgress =
outputFormat === 'text'
? null
: createRunProgressTracker({
sink: { write: (line) => process.stderr.write(`${line}\n`) },
zh: isZhLocale(),
emitToolBoundaries: true,
});

let pricingOverrides: ReturnType<typeof pricingOverridesFromConfig> | undefined;
try {
Expand Down Expand Up @@ -534,6 +544,7 @@ export async function runOneShot(
renderer?.handle(event);
rememberStructuredResult(structuredEvents);
} else {
jsonProgress?.onEvent(event);
writeStructured(structuredEvents);
}
}
Expand Down Expand Up @@ -620,6 +631,7 @@ export async function runOneShot(
} finally {
try {
renderer?.dispose?.();
jsonProgress?.dispose();
} catch {
/* ignore */
}
Expand Down
75 changes: 65 additions & 10 deletions src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ import {
tui,
} from './tui/copy.js';
import { noteToolForVerifyHint } from './verify-hint.js';
import {
createRunProgressTracker,
formatToolRunningLine,
formatUnfinishedLine,
unfinishedReasonFromStop,
type RunProgressTracker,
} from './run-progress.js';

const CODE_EDIT_TOOLS = new Set([
'write_file',
Expand Down Expand Up @@ -453,6 +460,9 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
const detailMode = options.detailMode ?? resolveCliDetailMode();
const interactive = options.interactive ?? Boolean((stderr as NodeJS.WriteStream).isTTY);
const spinner = interactive ? new CliSpinner(stderr) : null;
// O99: heartbeat + long-tool progress on stderr. Tool start/end stay with this
// renderer so we do not double-print; empty "working…" slogans are not used.
let progress: RunProgressTracker | undefined;
const state: RendererState = {
answerOpen: false,
answerStarted: false,
Expand Down Expand Up @@ -561,7 +571,27 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
}
});

function ensureProgress(): RunProgressTracker {
if (!progress) {
progress = createRunProgressTracker({
sink: {
write(line: string) {
if (isQuiet) return;
breakAnswerForStatus();
spinner?.stop();
stderrLine(`${mark()} ${line}`);
},
},
zh: isZhLocale(),
emitToolBoundaries: false,
});
}
return progress;
}

function handle(event: MossAgentEvent): void {
// O99 progress tracker (thinking wait / tool_progress). Quiet mode skips it.
if (!isQuiet) ensureProgress().onEvent(event);
switch (event.type) {
case 'turn_start':
if (!isQuiet) {
Expand All @@ -570,13 +600,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
if (isVerbose) {
stderrLine(`${mark()} thinking${ui.dim(` (turn ${event.turn})`)}`);
} else if (interactive) {
spinner?.start(`Working${event.turn > 1 ? `… (turn ${event.turn})` : ''}`);
} else if (event.turn > 2) {
// Oneshot / headless: suppress the first two turns (normal single-tool
// calls don't need a "working" message). Only signal turn 3+ which
// indicates a genuine multi-step tool loop where the user needs feedback.
stderrLine(ui.dim(`working…`));
// Real label comes from tool_start / thinking-wait; keep spinner alive
// without empty rotating slogans.
spinner?.start(isZhLocale() ? '处理中' : 'Working');
}
// Non-interactive: no empty "working…" — O99 heartbeat covers silence.
state.thinkingNoted = true;
}
break;
Expand Down Expand Up @@ -624,6 +652,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
spinner?.stop();
state.toolStartTimes.set(event.toolCallId, Date.now());
state.toolInputs.set(event.toolCallId, event.input);
if (!isQuiet && interactive) {
const target = extractToolTarget(event.toolName, event.input);
const label = target ? `${event.toolName}(${target})` : event.toolName;
spinner?.start(isZhLocale() ? `正在调用 ${label}` : `Calling ${label}`);
}
noteToolForVerifyHint(
state,
event.toolName,
Expand Down Expand Up @@ -652,6 +685,15 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
}
break;
}
case 'tool_progress': {
if (!isQuiet && interactive) {
const input = state.toolInputs.get(event.toolCallId) ?? {};
const line = formatToolRunningLine(event.toolName, input, event.elapsedSec, isZhLocale());
spinner?.stop();
spinner?.start(line);
}
break;
}
case 'tool_end': {
if (!isQuiet) {
const startedAt = state.toolStartTimes.get(event.toolCallId);
Expand Down Expand Up @@ -993,10 +1035,18 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
state.answerStarted = false;

const stopReason = event.result?.stopReason;
if (stopReason === 'max_turns_reached' || stopReason === 'tool_followup_cap_reached') {
stderrLine(
`${mark('fail')} stopped at the turn limit before finishing — the task is paused, not complete. Continue with ${ui.bold('moss resume --last')} (or ${ui.bold('moss --continue')}).`
);
const unfinished = unfinishedReasonFromStop(stopReason, isZhLocale());
if (unfinished) {
// O99 / O98 alignment: never end on a half-done progress line with
// success wording. Clear last-line reason; exit code is set by oneshot.
stderrLine(`${mark('fail')} ${formatUnfinishedLine(unfinished, isZhLocale())}`);
if (stopReason === 'max_turns_reached' || stopReason === 'tool_followup_cap_reached') {
stderrLine(
isZhLocale()
? `${mark('fail')} 可用 ${ui.bold('moss resume --last')}(或 ${ui.bold('moss --continue')})继续。`
: `${mark('fail')} Continue with ${ui.bold('moss resume --last')} (or ${ui.bold('moss --continue')}).`
);
}
} else if (!isQuiet && state.editedJsTs && !state.ranTests) {
// Only suggest npm test when JS/TS files were edited — avoid false
// positives when the agent only writes Python, docs, or other files.
Expand Down Expand Up @@ -1026,6 +1076,11 @@ export function createCliRunRenderer(options: CliRunRendererOptions = {}) {
} catch {
/* ignore */
}
try {
progress?.dispose();
} catch {
/* ignore */
}
try {
unsubscribeBackground();
} catch {
Expand Down
3 changes: 3 additions & 0 deletions src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,9 @@ export function formatHeadlessStreamEvent(
if (event.keptToolNames !== undefined) compaction.kept_tool_names = event.keptToolNames;
return [compaction];
}
case 'tool_progress':
// O99: progress is stderr-only; never appear on stdout/JSON stream.
return [];
case 'microcompact':
case 'retry':
return [];
Expand Down
Loading
Loading