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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ For a multiline task, save the specification in `task.md` and run
coding task; see [prompt input rules](COMMANDS.md#aether-code-task--autonomous-coding-agent)
for the size limit and flag conflicts.

To inspect the current checkout and receive a plan without changing it, run
`aether agent --planning "outline the migration"`, or use `/plan <topic>` in a
local interactive session. Planning permits only file reading, directory
listing, and repository search; it skips worktree creation and verification.
Save a plan or execute a phase later through the explicit goal controls.
The cloud chat route refuses `/plan` because its tools execute on the server.

A completed check records its exit code. Changing the repository makes that verification stale until you run it again.

The npm CLI is published as **v4.20.0**. Run `aether --version` to check the
Expand Down
6 changes: 5 additions & 1 deletion docs/generated/commands.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- GENERATED FILE: run `npm run docs:generate`; do not edit by hand. -->
<!-- manifest-digest: sha256:d73ec80b1137dba5cf588b3aa1e57ce0e3e67a48476480f8989685829c4db825 -->
<!-- manifest-digest: sha256:f047dc15eff1c35784add44adb5ab9699d07d9e865d06fcc757b2f9b9718d721 -->
# Generated command reference

This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling.
Expand All @@ -24,6 +24,10 @@ run the coding agent or manage account agents and shared conversations

Permission: `local-write` · Availability: `runtime-dependent` · Telemetry: `shell.agent` · Aliases: `aether code` · Requires: `aether.hosted-or-local`

Command flags:

- `--planning`

#### `aether chat [prompt]`

start chat or send one prompt
Expand Down
23 changes: 17 additions & 6 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
type ToolFailureOrigin,
} from "../core/tool_failure_budget.js";
import type { SkillRefusal } from "../core/skills/skill_errors.js";
import { refuseRunCapability, type RunCapability } from "../core/run_capability.js";
import { renderHud, timerLive } from "../core/hud.js";
import {
createViewerState,
Expand All @@ -92,7 +93,7 @@ import {
} from "../ui/workflow_viewer.js";
import type { WorkflowViewerState } from "../ui/workflow_viewer.js";
import type { StreamFrame } from "../core/stream.js";
import type { BrainEvent } from "../core/brain_protocol.js";
import type { BrainEvent, ToolName } from "../core/brain_protocol.js";
import { ConsoleTaskContinuation, accountFingerprint, consoleWorkspaceState, observedWorkspaceChanges, type ModelTarget, type ObservedTool } from "./model_continuation.js";

// Key decoding lives in ui/keys.ts (shared with pickers/viewers); re-exported
Expand All @@ -112,6 +113,7 @@ interface ChatJsonResponse {
* failure to the one-shot `cmdChat` path. */
/** Session-level skill selection for REPL/one-shot chat turns (`--skill`, `--no-skills`). */
export interface TurnSkillOptions {
capability?: RunCapability;
explicitSkill?: string;
noSkills?: boolean;
/** Local console authority, never serialized to Cloud. */
Expand Down Expand Up @@ -478,13 +480,17 @@ export async function runTurn(
preflightPulse.start();
try {
const backend = await resolveBackend(ctx);
if (skillOpts.capability === "planning" && backend === "cloud") {
throw new ChatTurnError("planning requires host-executed tools; this cloud chat route runs tools on the server. Use `aether agent --planning` or `aether agent --local --planning`.", undefined, false);
}
// The same seam `aether agent` uses (commands/code.ts). Opened per turn, not
// per session, because automatic skill selection reads THIS prompt — a turn
// that says "the CI is failing" should pull the CI skill and the next one
// should not inherit it.
const opened = openRunSession({
projectRoot: ctx.flags.cwd,
prompt,
...(skillOpts.capability ? { capability: skillOpts.capability } : {}),
allowIncompleteInstructionDiscovery: backend === "cloud",
...(skillOpts.explicitSkill ? { explicitSkill: skillOpts.explicitSkill } : {}),
...(skillOpts.noSkills ? { noSkills: true } : {}),
Expand Down Expand Up @@ -529,7 +535,7 @@ export async function runTurn(
getRegistry().markLocalUnmetered();
// The signal used to be dropped here, so the REPL Ctrl+C controller could
// not reach a local turn at all: the abort fired and nothing observed it.
return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt, ...(skillOpts.exec ? { exec: skillOpts.exec } : {}), ...(skillOpts.onToolResult ? { onToolResult: skillOpts.onToolResult } : {}), ...(skillOpts.steer ? { steer: skillOpts.steer } : {}) }, run.guard);
return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt, capability: skillOpts.capability, advertisedTools: run.effectiveTools as readonly ToolName[], ...(skillOpts.exec ? { exec: skillOpts.exec } : {}), ...(skillOpts.onToolResult ? { onToolResult: skillOpts.onToolResult } : {}), ...(skillOpts.steer ? { steer: skillOpts.steer } : {}) }, run.guard);
}
// /agent/chat/stream exposes no control acknowledgement, so a steer typed
// during this turn is kept for the next one rather than reported as live.
Expand Down Expand Up @@ -796,6 +802,8 @@ async function runCloudTurn(
* draws every event. Identical UX to cloud, just an offline brain.
*/
export interface LocalTurnDeps {
capability?: RunCapability;
advertisedTools?: readonly ToolName[];
brain?: Brain;
exec?: {
executeAsync(name: string, args: Record<string, unknown>, options?: RunOptions): Promise<ToolResult>;
Expand Down Expand Up @@ -842,7 +850,7 @@ export async function runLocalTurn(
const model = resolveLocalModel(ctx.flags.model, ctx.cfg.localModel ?? "", {
allowBareExplicit: ctx.flags.local === true,
});
const brain = deps.brain ?? new OllamaBrain({ model });
const brain = deps.brain ?? new OllamaBrain({ model, ...(deps.advertisedTools ? { tools: deps.advertisedTools } : {}) });
const exec = deps.exec ?? new ToolExecutor(cwd);
const renderer = new HostRenderer({ poolGb: 5, json: ctx.flags.json });
const pulse = new ThinkingPulse({
Expand All @@ -869,6 +877,7 @@ export async function runLocalTurn(
};
const task: TaskCommand = {
type: "task",
capability: deps.capability ?? "coding",
text: prompt,
cwd,
poolGb: 5,
Expand Down Expand Up @@ -943,7 +952,7 @@ export async function runLocalTurn(
// checked first and refuses without executing or prompting; the
// operator gate then decides about whatever survived. A skill can only
// subtract here — it is never consulted again after this line.
const refusal = skillGuard ? skillGuard(ev.name) : null;
const refusal = refuseRunCapability(ev.name, task.capability ?? "coding") ?? (skillGuard ? skillGuard(ev.name) : null);
const prepared = refusal ? null : prepareToolApproval(ev.name, ev.args, exec.configuredTestCommand);
const call = { name: ev.name, args: ev.args };
const key = operationKey(call, { policy: Boolean(refusal), ...(prepared?.ok ? { binding: prepared.binding } : {}) });
Expand Down Expand Up @@ -1559,7 +1568,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
viewerOpen = false;
break;
}
}, redrawInput, { ...skillOpts, onToolResult: tool => toolResults.push(tool), steer: steerChannel });
}, redrawInput, { ...skillOpts, capability: input.capability ?? "coding", onToolResult: tool => toolResults.push(tool), steer: steerChannel });
const afterChanges = observedWorkspaceChanges(ctx.flags.cwd);
continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation);
if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n");
Expand Down Expand Up @@ -1915,10 +1924,12 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
}
// ── stateless prompt-rewrite modes (/recon, /plan, /research, …) ──
const mode = applyPromptMode(t);
let capability: RunCapability | undefined;
if (mode.handled) {
if (mode.error) { process.stdout.write(mode.error + "\n"); repaint(); return; }
process.stdout.write(mode.notice + "\n");
t = mode.prompt!;
capability = mode.capability;
}
busy = true;
if (t.startsWith("/")) {
Expand Down Expand Up @@ -1999,7 +2010,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
repaint();
return;
}
await runAndDrain(queue.allocate({ kind: "chat", text: t }));
await runAndDrain(queue.allocate({ kind: "chat", text: t, ...(capability ? { capability } : {}) }));
renderHudLine();
repaint();
};
Expand Down
55 changes: 42 additions & 13 deletions src/commands/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { AppContext } from "../core/context.js";
import { completeMemory, pinMemory } from "../core/project_memory/run.js";
import type { Brain, TaskCommand } from "../core/brain.js";
import type { BrainEvent } from "../core/brain_protocol.js";
import type { BrainEvent, ToolName } from "../core/brain_protocol.js";
import type { RunOptions, ToolResult } from "../core/tool_executor.js";
import { LocalBrain } from "../core/brain_local.js";
import { OllamaBrain } from "../core/brain_ollama.js";
Expand Down Expand Up @@ -89,6 +89,7 @@ import { turnOutcomeRecord } from "./chat.js";
import { openRcCodingObserver, type RcCodingObserver } from "./rc_observation.js";
import { publishCodingVerification } from "./rc_verification.js";
import { promptInputLabel, type PromptInput } from "./prompt_file.js";
import { refuseRunCapability, type RunCapability } from "../core/run_capability.js";
import {
TRANSIENT_READ_AUTO_RETRIES,
checkpointDoneEvent,
Expand Down Expand Up @@ -178,6 +179,8 @@ export interface CodeRunFinished extends CodeRunStarted {
}

export interface CodeOpts {
/** Authority for this one invocation. */
capability?: RunCapability;
/** Use the local Python/Ollama brain instead of the cloud API. */
local: boolean;
/** Pool size in GB (sets the status-bar denominator: pool x 233M). */
Expand Down Expand Up @@ -341,8 +344,10 @@ export class CodeTurnLifecycle {
private fatal: Extract<BrainEvent, { type: "routing_drift" }> | null = null;
private final: { report: CodeRunReport; verification: VerifyOutcome | null } | null = null;
private checkpoint: ToolFailureCheckpoint | null = null;
private readonly capability: RunCapability;

constructor(prompt: string, opts: TurnLifecycleOptions = {}) {
constructor(prompt: string, opts: TurnLifecycleOptions = {}, capability: RunCapability = "coding") {
this.capability = capability;
this.lifecycle = new TurnLifecycle(prompt, opts);
this.lifecycle.transition("submitted");
this.lifecycle.transition("connecting");
Expand Down Expand Up @@ -554,6 +559,11 @@ export class CodeTurnLifecycle {
const described = describeStreamFailure({ message: this.brainError });
return byTurn("failed", { message: this.brainError, hint: described.hint, retryable: described.retryable });
}
if (this.capability === "planning" && !verification) {
return this.done?.ok
? byTurn("succeeded", { message: "plan returned; verification intentionally skipped" })
: byTurn("failed", { message: "planning brain did not complete the plan" });
}
if (!verification) return byTurn("failed", { message: "host final verification did not complete" });
if (verification.check.state === "launch_failed") {
return byCheck("failed", {
Expand All @@ -579,6 +589,7 @@ export class CodeTurnLifecycle {

/** Why no check ran, for a run that settled without one. */
private notRunReason(): string {
if (this.capability === "planning") return "planning does not run host verification";
if (this.fatal) return "the coding transport was refused before local execution";
if (isAbortError(this.thrown)) return "the coding turn was cancelled before host verification";
if (this.thrown instanceof StreamTimeoutError) return "the model stream timed out before host verification";
Expand Down Expand Up @@ -776,6 +787,10 @@ export async function cmdCode(
opts: CodeOpts,
workspaceRun: Runner = defaultRunner(),
): Promise<number> {
if (opts.capability === "planning" && (opts.repo || opts.worktree || opts.resume)) {
process.stderr.write("✗ planning uses the current workspace; --repo, --worktree, and --resume are incompatible\n");
return 2;
}
// --resume carries the prior session's context forward, so it is also a task
// of its own: with no new instruction the run continues the ORIGINAL task.
// Resolved ONCE — the handoff the brain reads and the lines the human sees
Expand Down Expand Up @@ -884,7 +899,7 @@ export async function cmdCode(
return 1;
}
cwd = worktree.dir;
} else if (opts.workspaceMode === "current") {
} else if (opts.workspaceMode === "current" || opts.capability === "planning") {
cwd = ctx.flags.cwd;
} else {
const ws = await prepareWorkspace(ctx, label, io, workspaceRun);
Expand All @@ -906,6 +921,7 @@ export async function cmdCode(
const opened = openRunSession({
projectRoot: cwd,
prompt: task || label,
...(opts.capability ? { capability: opts.capability } : {}),
...(opts.skill ? { explicitSkill: opts.skill } : {}),
...(opts.noSkills ? { noSkills: true } : {}),
});
Expand Down Expand Up @@ -972,14 +988,14 @@ export async function cmdCode(
const brain: Brain = goLocal
? chooseLocalBrain(process.env["AETHER_LOCAL_BRAIN"]) === "python"
? new LocalBrain()
: new OllamaBrain()
: new OllamaBrain({ tools: run.effectiveTools as readonly ToolName[] })
: // `aether agent` is a coding session over THIS checkout, so it may not
// silently accept the one-way chat transport, whose tools run
// server-side against the cloud vault (brain_cloud CloudBrainOptions).
new CloudBrain(ctx.api, undefined, { requireLocalAuthority: true });
new CloudBrain(ctx.api, undefined, { requireLocalAuthority: true, localToolCapabilities: run.effectiveTools });
// A worktree gets its own fresh shell; launch-project state never follows it.
const shellSession = process.platform === "linux" || process.platform === "darwin" ? new ShellSession(cwd) : undefined;
const exec = new ToolExecutor(cwd, opts.testCmd, { mode: "coding", ...(shellSession ? { shellSession } : {}) });
const exec = new ToolExecutor(cwd, opts.capability === "planning" ? undefined : opts.testCmd, { mode: "coding", ...(shellSession ? { shellSession } : {}) });
// Scope the session manifest to the ORIGINAL launch directory (ctx.flags.cwd),
// not the possibly-substituted `cwd` (an auto-created worktree, or a manually
// redirected directory from the repo gate) — resume always compares against
Expand All @@ -990,6 +1006,7 @@ export async function cmdCode(
: new SessionLog(
{
task: label,
capability: opts.capability ?? "coding",
...(opts.promptInput ? { promptInput: opts.promptInput } : {}),
model: resolvedModel,
poolGb,
Expand All @@ -1001,7 +1018,7 @@ export async function cmdCode(
// is in, and so the branch it reports is the branch the commits
// landed on rather than the launch directory's. (Lane AA-CONT-04.)
...(cwd && ctx.flags.cwd && !isCurrentWorkspace(cwd, ctx.flags.cwd) ? { worktree: cwd } : {}),
...(opts.testCmd ? { testCmd: opts.testCmd } : {}),
...(opts.testCmd && opts.capability !== "planning" ? { testCmd: opts.testCmd } : {}),
// Digests and paths, never content: enough for the next run (or the
// next machine) to tell that the rules moved, and nothing more.
context: {
Expand Down Expand Up @@ -1061,6 +1078,7 @@ export async function cmdCode(
// task unchanged, so an unskilled run is byte-identical to one without this
// seam at all.
text: run.brief(handoff ? continuationTask(handoff, task) : task),
capability: opts.capability ?? "coding",
// The typed channel, for a brain that reads the NDJSON command frame.
// Additive and optional (brain_protocol.AgentContextPacket): a brain that
// predates it sees no key. The brief above is what reaches the Ollama and
Expand All @@ -1072,18 +1090,18 @@ export async function cmdCode(
// (same backend: TaskCommand.effort reaches the cloud brain unchanged).
effort: opts.effort ?? (ctx.cfg.defaultEffort || undefined),
model: localSelection?.tag ?? (resolvedHostedModel || undefined),
testCmd: opts.testCmd,
testCmd: opts.capability === "planning" ? undefined : opts.testCmd,
};

// One correlation identity owns the production run. A brain `done` event is
// advisory; the lifecycle remains completing until host verification below.
const turn = new CodeTurnLifecycle(task || label);
const turn = new CodeTurnLifecycle(task || label, {}, opts.capability ?? "coding");
const correlation: CodeRunStarted = {
sessionId: log?.sessionId ?? "",
turnId: turn.turnId,
workspace: cwd,
model: resolvedModel,
checkCommand: opts.testCmd ?? null,
checkCommand: opts.capability === "planning" ? null : opts.testCmd ?? null,
};
await opts.runObserver?.started(correlation);
const progressTimeoutMs = codeMeaningfulProgressTimeoutMs();
Expand Down Expand Up @@ -1220,7 +1238,7 @@ export async function cmdCode(
// " : write_file …" line; the animated kaomoji status line keeps pulsing
// below, so the diff and the live state stay in sync.
const diff =
ev.type === "tool_call" && ev.name === "write_file" ? writeDiffLines(exec, ev.args, true) : null;
opts.capability !== "planning" && ev.type === "tool_call" && ev.name === "write_file" ? writeDiffLines(exec, ev.args, true) : null;
if (diff && diff.length) {
for (const line of diff) sr.log(line);
} else {
Expand Down Expand Up @@ -1259,7 +1277,7 @@ export async function cmdCode(
// --quiet). Suppressed under --json so machine consumers still receive the
// raw tool_call event, never the rendered diff.
const diff =
!ctx.flags.json && ev.type === "tool_call" && ev.name === "write_file"
!ctx.flags.json && opts.capability !== "planning" && ev.type === "tool_call" && ev.name === "write_file"
? writeDiffLines(exec, ev.args, false)
: null;
if (diff && diff.length) renderer.writeLines(diff);
Expand Down Expand Up @@ -1308,6 +1326,17 @@ export async function cmdCode(
return EXIT_ROUTING_REFUSED;
}

if (opts.capability === "planning") {
turn.settle(null);
const report = turn.report!;
log?.close(report.outcome.state === "succeeded" ? "ok" : "error", nowIso(), 0, report.check, undefined, hostRefusals);
await opts.runObserver?.finished({ ...correlation, report, verification: null, recordedCheck: null, touchedFiles: [...touched], hostRefusals });
emitCodeTurnOutcome(report, ctx.flags.json);
if (!ctx.flags.json) process.stderr.write(`\n planning · ${report.outcome.state} · verification not run\n`);
if (log) process.stderr.write(` ⤷ log: ${log.dir}\n`);
return report.outcome.exitCode;
}

// ── Final verification gate: ground truth, never the brain's self-report ──
// The host re-runs the test command ITSELF and derives the result from the real
// exit code (verify_gate.ts). The brain's `done` is advisory — it only enriches a
Expand Down Expand Up @@ -1535,7 +1564,7 @@ export async function hostLoop(
// before a byte of it runs. The brain gets a structured refusal as a
// normal failed tool result, so the loop continues and the model
// learns why instead of silently retrying.
const refusal = skillGuard ? skillGuard(ev.name) : null;
const refusal = refuseRunCapability(ev.name, task.capability ?? "coding") ?? (skillGuard ? skillGuard(ev.name) : null);
const prepared = refusal ? null : prepareToolApproval(ev.name, ev.args, exec.configuredTestCommand);
const call = { name: ev.name, args: ev.args };
const key = operationKey(call, { policy: Boolean(refusal), ...(prepared?.ok ? { binding: prepared.binding } : {}) });
Expand Down
Loading
Loading