Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/desktop/src/cli/commands/ask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe("renderAskCommandHelp", () => {
expect(help).toContain("--reply-mode notify");
expect(help).toContain("--label <label>");
expect(help).toContain("--project <path> -> ask by repo/workspace path");
expect(help).toContain("--harness <runtime> with no target");
expect(help).toContain("scout ask --harness codex");
expect(help).toContain("Use --project when you know the project path but do not want to look up or pin an agent id first.");
expect(help).toContain("scout ask '>> project:../talkie compare auth against this branch'");
});
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/cli/commands/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const DEFAULT_ASK_ACK_TIMEOUT_SECONDS = 30;

export function renderAskCommandHelp(): string {
return [
"Usage: scout ask (--to <agent> | --ref <ref> | --project <path>) [--as <sender>] [--channel <name>] [--label <label>] [--timeout <seconds>] [--reply-mode inline|notify|none] [--no-wait] [--harness <runtime>] [--prompt-file <path> | <message>]",
"Usage: scout ask [(--to <agent> | --ref <ref> | --project <path>)] [--as <sender>] [--channel <name>] [--label <label>] [--timeout <seconds>] [--reply-mode inline|notify|none] [--no-wait] [--harness <runtime>] [--new] [--prompt-file <path> | <message>]",
"",
"Ask one agent to do work or return a concrete answer.",
"",
Expand All @@ -34,13 +34,15 @@ export function renderAskCommandHelp(): string {
" --channel <name> -> named group thread",
" short @name -> must resolve to exactly one routable agent",
" --project <path> -> ask by repo/workspace path; Scout resolves the concrete agent/session",
" --harness <runtime> with no target -> ask a compatible worker for the current project",
" '>> project:<path> ...' -> composer route form for the same project-path ask",
"",
"Use ask when the meaning is \"do this and get back to me.\"",
"The command creates durable broker work; the target should acknowledge quickly in the same DM or channel.",
`Default inline mode returns once the target has acknowledged, completed immediately, or stays unacknowledged for ${DEFAULT_ASK_ACK_TIMEOUT_SECONDS}s.`,
"Use the flight id, conversation, notify mode, or an explicit wait to follow the final completion.",
"Use --project when you know the project path but do not want to look up or pin an agent id first.",
"Use --harness without a target when the current project should be handled by that runtime.",
"",
"Input:",
" inline message -> primary prompt body",
Expand All @@ -51,7 +53,8 @@ export function renderAskCommandHelp(): string {
' scout ask --to hudson "review the parser"',
' scout ask --ref 7f3a9c21 "continue from that result"',
' scout ask --project ../talkie "how did you handle auth?"',
' scout ask --project ../talkie --harness codex "review this from a fresh Codex session"',
' scout ask --harness codex "review this from a fresh Codex session"',
' scout ask --project ../talkie --harness codex "review Talkie from Codex"',
" scout ask '>> project:../talkie compare auth against this branch'",
" scout ask --to hudson --prompt-file ./handoff.md",
' scout ask --as premotion.master.mini --to hudson "build the editor"',
Expand Down Expand Up @@ -233,6 +236,7 @@ export async function runAskWithOptions(
body,
channel: options.channel,
harness: parseScoutHarness(options.harness),
session: options.session,
labels: options.labels,
currentDirectory,
source: "scout-cli",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/cli/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe("renderScoutHelp", () => {
expect(help).toContain("--message-file");
expect(help).toContain("Project targets:");
expect(help).toContain("scout ask --project ../talkie");
expect(help).toContain("scout ask --harness codex");
expect(help).toContain("no agent id needed");
expect(help).toContain("quote >> in shells");
expect(help).toContain("MCP parity:");
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function renderScoutHelp(version = "0.2.19"): string {
"Fast path:",
' scout send --to hudson "ready for review" # tell / update in a DM',
' scout ask --to hudson "review the parser" # owned work; wait for ack',
' scout ask --harness codex "review this" # current project; fresh compatible worker',
' scout ask --project ../talkie "compare auth" # no agent id needed; route by project path',
" scout ask --to hudson --prompt-file ./handoff.md",
" scout wait inv_123 --timeout 600 # follow an existing ask",
Expand Down Expand Up @@ -72,6 +73,7 @@ export function renderScoutHelp(version = "0.2.19"): string {
" scout ask --to hudson --prompt-file ./handoff.md",
"",
"Project targets:",
' scout ask --harness codex "review this" # current project; broker chooses the worker',
' scout ask --project ../talkie "compare auth" # broker resolves the concrete agent/session',
' scout ask --project ../talkie --harness codex "review this from Codex"',
" scout ask '>> project:../talkie compare auth' # composer route form; quote >> in shells",
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,37 @@ describe("parseAskCommandOptions", () => {
expect(options.message).toBe("compare auth");
});

test("infers the current project when only a harness is provided", () => {
const options = parseAskCommandOptions(
["--harness", "codex", "review", "this"],
"/tmp/workspace",
);

expect(options.projectPath).toBe("/tmp/workspace");
expect(options.targetLabel).toBeUndefined();
expect(options.harness).toBe("codex");
expect(options.session).toBe("new");
expect(options.message).toBe("review this");
});

test("parses ask session preference flags", () => {
const options = parseAskCommandOptions(
["--new", "--harness", "codex", "review", "this"],
"/tmp/workspace",
);

expect(options.projectPath).toBe("/tmp/workspace");
expect(options.session).toBe("new");
});

test("does not expose reuse as a CLI ask session preference", () => {
expect(() =>
parseAskCommandOptions(
["--session", "reuse", "--harness", "codex", "review", "this"],
"/tmp/workspace",
)).toThrow("invalid session: reuse");
});

test("accepts a composer project path target", () => {
const options = parseAskCommandOptions(
[">>", "project:../talkie", "compare", "auth"],
Expand Down Expand Up @@ -225,6 +256,19 @@ describe("parseImplicitAskCommandOptions", () => {
expect(noWait.replyMode).toBe("none");
});

test("infers the current project for implicit harness-only asks", () => {
const options = parseImplicitAskCommandOptions(
["--harness", "codex", "review", "this"],
"/tmp/workspace",
);

expect(options.projectPath).toBe("/tmp/workspace");
expect(options.targetLabel).toBeUndefined();
expect(options.harness).toBe("codex");
expect(options.session).toBe("new");
expect(options.message).toBe("review this");
});

test("extracts a target agent from the composer route operator", () => {
const options = parseImplicitAskCommandOptions(
["hey", ">>", "dewey", "can", "you", "review", "our", "docs?"],
Expand Down
87 changes: 86 additions & 1 deletion apps/desktop/src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ScoutAskCommandOptions = ContextRootOptions & {
projectPath?: string;
channel?: string;
harness?: string;
session?: "new";
timeoutSeconds?: number;
replyMode?: "inline" | "notify" | "none";
labels?: string[];
Expand Down Expand Up @@ -181,6 +182,36 @@ function rejectMixedBodySources(kind: "message" | "question"): never {
);
}

function parseAskSessionPreference(
value: string,
): NonNullable<ScoutAskCommandOptions["session"]> {
if (value === "new") {
return value;
}
throw new ScoutCliError(`invalid session: ${value}`);
}

function mergeAskSessionPreference(
current: ScoutAskCommandOptions["session"],
next: NonNullable<ScoutAskCommandOptions["session"]>,
): NonNullable<ScoutAskCommandOptions["session"]> {
if (current && current !== next) {
throw new ScoutCliError("provide only one ask session preference");
}
return next;
}

function shouldInferCurrentProjectAskTarget(input: {
targetLabel?: string | null;
projectPath?: string;
harness?: string;
session?: ScoutAskCommandOptions["session"];
}): boolean {
return !input.targetLabel
&& !input.projectPath
&& Boolean(input.harness || input.session);
}

function parseContextRootPrefix(
args: string[],
defaultCurrentDirectory: string,
Expand Down Expand Up @@ -473,6 +504,7 @@ export function parseAskCommandOptions(
let projectPath: string | undefined;
let channel: string | undefined;
let harness: string | undefined;
let session: ScoutAskCommandOptions["session"];
let timeoutSeconds: number | undefined;
let replyMode: ScoutAskCommandOptions["replyMode"];
let promptFile: string | undefined;
Expand Down Expand Up @@ -518,6 +550,19 @@ export function parseAskCommandOptions(
index = value.nextIndex;
continue;
}
if (current === "--session" || current.startsWith("--session=")) {
const value = parseFlagValue(parsed.args, index, "--session");
session = mergeAskSessionPreference(
session,
parseAskSessionPreference(value.value),
);
index = value.nextIndex;
continue;
}
if (current === "--new") {
session = mergeAskSessionPreference(session, "new");
continue;
}
if (current === "--timeout" || current.startsWith("--timeout=")) {
const value = parseFlagValue(parsed.args, index, "--timeout");
const parsedTimeout = Number.parseInt(value.value, 10);
Expand Down Expand Up @@ -576,6 +621,12 @@ export function parseAskCommandOptions(
message = routed.message;
}
}
if (shouldInferCurrentProjectAskTarget({ targetLabel, projectPath, harness, session })) {
projectPath = parsed.currentDirectory;
if (harness && !session) {
session = "new";
}
}
if (targetLabel && projectPath) {
throw new ScoutCliError("provide either --to/--ref or --project, not both");
}
Expand All @@ -598,6 +649,7 @@ export function parseAskCommandOptions(
projectPath,
channel,
harness,
session,
timeoutSeconds,
replyMode,
...(labels.length ? { labels } : {}),
Expand All @@ -614,6 +666,7 @@ export function parseImplicitAskCommandOptions(
let agentName: string | null = null;
let channel: string | undefined;
let harness: string | undefined;
let session: ScoutAskCommandOptions["session"];
let timeoutSeconds: number | undefined;
let replyMode: ScoutAskCommandOptions["replyMode"];
let promptFile: string | undefined;
Expand All @@ -640,6 +693,19 @@ export function parseImplicitAskCommandOptions(
index = value.nextIndex;
continue;
}
if (current === "--session" || current.startsWith("--session=")) {
const value = parseFlagValue(parsed.args, index, "--session");
session = mergeAskSessionPreference(
session,
parseAskSessionPreference(value.value),
);
index = value.nextIndex;
continue;
}
if (current === "--new") {
session = mergeAskSessionPreference(session, "new");
continue;
}
if (current === "--timeout" || current.startsWith("--timeout=")) {
const value = parseFlagValue(parsed.args, index, "--timeout");
const parsedTimeout = Number.parseInt(value.value, 10);
Expand Down Expand Up @@ -688,7 +754,7 @@ export function parseImplicitAskCommandOptions(
}

const input = messageParts.join(" ").trim();
if (!input) {
if (!input && !promptFile) {
throw new ScoutCliError("no question provided");
}

Expand All @@ -711,6 +777,7 @@ export function parseImplicitAskCommandOptions(
projectPath: routed.projectPath,
channel: mergeComposerChannel(channel, routed.channel),
harness,
session,
timeoutSeconds,
replyMode,
...(labels.length ? { labels } : {}),
Expand All @@ -721,6 +788,23 @@ export function parseImplicitAskCommandOptions(

const mentions = extractMentionTargets(input);
if (mentions.length === 0) {
if (shouldInferCurrentProjectAskTarget({ harness, session })) {
const inferredSession = harness && !session ? "new" : session;
return {
currentDirectory: parsed.currentDirectory,
args: parsed.args,
agentName,
projectPath: parsed.currentDirectory,
channel,
harness,
session: inferredSession,
timeoutSeconds,
replyMode,
...(labels.length ? { labels } : {}),
message: input,
promptFile,
};
}
throw new ScoutCliError("implicit ask requires >> target or an @agent mention");
}
if (mentions.length > 1) {
Expand All @@ -743,6 +827,7 @@ export function parseImplicitAskCommandOptions(
targetLabel: target.label,
channel,
harness,
session,
timeoutSeconds,
replyMode,
...(labels.length ? { labels } : {}),
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/core/broker/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ function resolveAskProjectPath(
return trimmed ? resolve(currentDirectory, trimmed) : undefined;
}

function shouldInferCurrentProjectAskTarget(
command: ScoutAskCommand,
): boolean {
return Boolean(command.harness || command.workspace || command.session);
}

function isProjectRouteTarget(to: string): boolean {
const parsed = parseScoutComposerRouteTarget(to);
return parsed?.kind === "project_path";
Expand Down Expand Up @@ -259,6 +265,10 @@ export const scoutAskHandler: ScoutAskHandler = async (command) => {
currentDirectory,
);
const requestedTo = command.to?.trim() || "";
const inferredProjectPath =
!requestedTo && !commandProjectPath && shouldInferCurrentProjectAskTarget(command)
? currentDirectory
: undefined;
if (requestedTo && commandProjectPath) {
return {
ok: false,
Expand All @@ -283,7 +293,7 @@ export const scoutAskHandler: ScoutAskHandler = async (command) => {
}
const resolvedTarget = askResolvedTargetFor({
to: requestedTo,
projectPath: commandProjectPath,
projectPath: commandProjectPath ?? inferredProjectPath,
});
if (!resolvedTarget) {
return {
Expand Down
Loading