Skip to content

Commit 55d3d3e

Browse files
Unmount ask_operator on non-TTY exec (#925)
1 parent a156094 commit 55d3d3e

13 files changed

Lines changed: 186 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Changed
17+
18+
- Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not
19+
TTYs instead of advertising a cancel stub. TUI Skywalker still mounts it;
20+
TTY exec still prompts on stdin.
21+
1622
### Removed
1723

1824
- Profile files no longer accept a `workflow` field. Workflows start only from

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ In TUI chat mode there is no completion gate — the session stays open across t
100100
- Product non-TUI agent path that **shares** the TUI stack (session mode, ChatDirector, toolset, permission gate, MCP, plugins, hooks, run-sink) without the OpenTUI shell
101101
- Bootstrap consumes the shared session assembly (`src/session/assemble-runtime.ts`, layered over `src/session/runtime-assembly.ts`) instead of hand-wiring a second copy. Intentional deltas vs TUI:
102102
- No workflow controller (`isWorkflowActive` is always false)
103-
- Non-interactive permission gate by default; optional stdin for `ask_operator`
103+
- Non-interactive permission gate by default
104+
- `ask_operator` is unmounted when stdin/stdout are not TTYs (no cancel stub on the wire); TTY exec still prompts on stdin
104105
- Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"`
105106
- Streams assistant text deltas to stdout; lifecycle errors to stderr
106107
- Shares ChatDirector compaction continuation (`requestContinuation` → content-less deliver after compact) so long runs do not stall post-compact

docs/PRODUCT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ The evidence is in how the product fails today: the personas already produce exc
4040
4. **Safe by default** — Consequential actions (writes, edits, shell) pass a permission gate; secret files and catastrophic commands are denied outright, regardless of intent.
4141
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
4242
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
43-
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI) or via stdin when the product agent runs under `corbits exec`.
43+
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin.
4444
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
4545
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators still collect with `wait_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.
4646

@@ -66,7 +66,7 @@ $ corbits exec "Add JWT auth to the API"
6666
$ corbits run "Add JWT auth to the API"
6767
```
6868

69-
Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set, a persisted `/yolo` default is on, or auto mode covers them. `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `ask_operator` reads a single line from stdin when available.
69+
Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. Bootstrap shares `src/session/assemble-runtime.ts` with the TUI; see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate; `ask_operator` unmounted when non-TTY). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set, a persisted `/yolo` default is on, or auto mode covers them. `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply. `ask_operator` is not advertised on non-TTY exec; TTY exec still reads a single line from stdin.
7070

7171
Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`.
7272

scripts/eval-capability.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ describe("buildEvalDiagnostics", () => {
383383
);
384384
expect(diagnostics.advertisedTools).toContain("read_file");
385385
expect(diagnostics.advertisedTools).toContain("run_shell");
386+
expect(diagnostics.advertisedTools).not.toContain("ask_operator");
386387
expect(diagnostics.reasoningEffort).toBeNull();
387388
});
388389

scripts/eval-capability.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,9 @@ export async function buildEvalDiagnostics(
683683
overlay.advertisedAllow ??
684684
advertisedToolNamesForSessionMode(sessionMode, {
685685
languageServerAvailable: detectLanguageServerAvailable(config.cwd),
686+
// Capability evals run through exec; they are non-TTY, so ask_operator
687+
// is unmounted the same way the runner does when interactive is false.
688+
operatorAvailable: false,
686689
});
687690
return {
688691
advertisedTools,

src/agent/tool-search.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,31 @@ describe("createToolIndex", () => {
165165
).not.toContain("lsp");
166166
});
167167

168-
test("ask_operator is advertised regardless of availability", () => {
168+
test("ask_operator is advertised when the operator is available", () => {
169169
expect(
170170
coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY),
171171
).toContain("ask_operator");
172+
expect(
173+
advertisedToolNamesForSessionMode("orchestrator", {
174+
languageServerAvailable: true,
175+
operatorAvailable: true,
176+
}),
177+
).toContain("ask_operator");
178+
});
179+
180+
test("ask_operator is omitted from the advertised prefix when the operator is unavailable", () => {
181+
expect(
182+
coreToolNamesForSessionMode("orchestrator", {
183+
languageServerAvailable: false,
184+
operatorAvailable: false,
185+
}),
186+
).not.toContain("ask_operator");
187+
expect(
188+
advertisedToolNamesForSessionMode("orchestrator", {
189+
languageServerAvailable: true,
190+
operatorAvailable: false,
191+
}),
192+
).not.toContain("ask_operator");
172193
});
173194

174195
test("the advertised set is deterministic — repeat calls with the same inputs are identical", () => {

src/agent/tool-search.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export interface ToolAvailability {
7171
// Whether a language server was resolvable for this project at startup —
7272
// not whether one currently responds.
7373
languageServerAvailable: boolean;
74+
// Headless/non-TTY exec has no operator to answer. Omit to keep the TUI
75+
// default (mounted). False drops ask_operator from the advertised prefix
76+
// instead of leaving a cancel stub on the wire.
77+
operatorAvailable?: boolean;
7478
}
7579

7680
export function coreToolNamesForSessionMode(
@@ -82,6 +86,8 @@ export function coreToolNamesForSessionMode(
8286
if (!orchestratorEnabled && ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name))
8387
return false;
8488
if (name === "lsp") return availability.languageServerAvailable;
89+
if (name === "ask_operator")
90+
return availability.operatorAvailable !== false;
8591
return true;
8692
});
8793
}

src/agent/tools.ts

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ export type OperatorResult =
146146
export interface AgentToolsetArgs {
147147
cwd: string;
148148
permissionGate: PermissionGate;
149-
onOperatorGate: (
149+
// Interactive operator ask; omit on headless/non-TTY so the tool is unmounted.
150+
onOperatorGate?: (
150151
question: string,
151152
options: string[],
152153
) => Promise<OperatorResult>;
@@ -590,55 +591,59 @@ export async function createAgentToolset(
590591
return result.content;
591592
},
592593
}),
593-
stringTool({
594-
definition: askOperatorDefinition,
595-
handler: async (
596-
rawArgs: Record<string, unknown>,
597-
_signal: AbortSignal,
598-
): Promise<string> => {
599-
const parsed = AskOperatorArgs(rawArgs);
600-
if (parsed instanceof type.errors) {
601-
return "Error: ask_operator requires question (string) and options (array of strings).";
602-
}
603-
const { question, options } = parsed;
604-
if (options.length === 0) {
605-
return "Error: ask_operator requires at least one option.";
606-
}
607-
if (question.length > ASK_OPERATOR_QUESTION_MAX_CHARS) {
608-
return (
609-
`Error: ask_operator question is ${question.length} characters; ` +
610-
`keep it to ${ASK_OPERATOR_QUESTION_MAX_CHARS} or fewer. ` +
611-
"Put the essay in a transcript reply first, then retry with a brief question."
612-
);
613-
}
614-
for (let i = 0; i < options.length; i++) {
615-
const option = options[i] ?? "";
616-
if (option.length > ASK_OPERATOR_OPTION_MAX_CHARS) {
617-
return (
618-
`Error: ask_operator option ${i + 1} is ${option.length} characters; ` +
619-
`keep each label to ${ASK_OPERATOR_OPTION_MAX_CHARS} or fewer. ` +
620-
"Put the essay in a transcript reply first, then retry with short option labels."
621-
);
622-
}
623-
}
624-
const result = await onOperatorGate(question, options);
625-
if (result.kind === "cancel") {
626-
return "The operator dismissed the question without answering. Do not ask it again; proceed with your best judgment or continue with other work.";
627-
}
628-
if (result.kind === "custom") {
629-
return result.text;
630-
}
631-
const { index } = result;
632-
if (index < 0 || index >= options.length) {
633-
return `Error: invalid selection ${index}. Valid range: 0-${options.length - 1}.`;
634-
}
635-
const selected = options[index];
636-
if (selected === undefined) {
637-
return `Error: invalid selection ${index}. Valid range: 0-${options.length - 1}.`;
638-
}
639-
return selected;
640-
},
641-
}),
594+
...(onOperatorGate !== undefined
595+
? [
596+
stringTool({
597+
definition: askOperatorDefinition,
598+
handler: async (
599+
rawArgs: Record<string, unknown>,
600+
_signal: AbortSignal,
601+
): Promise<string> => {
602+
const parsed = AskOperatorArgs(rawArgs);
603+
if (parsed instanceof type.errors) {
604+
return "Error: ask_operator requires question (string) and options (array of strings).";
605+
}
606+
const { question, options } = parsed;
607+
if (options.length === 0) {
608+
return "Error: ask_operator requires at least one option.";
609+
}
610+
if (question.length > ASK_OPERATOR_QUESTION_MAX_CHARS) {
611+
return (
612+
`Error: ask_operator question is ${question.length} characters; ` +
613+
`keep it to ${ASK_OPERATOR_QUESTION_MAX_CHARS} or fewer. ` +
614+
"Put the essay in a transcript reply first, then retry with a brief question."
615+
);
616+
}
617+
for (let i = 0; i < options.length; i++) {
618+
const option = options[i] ?? "";
619+
if (option.length > ASK_OPERATOR_OPTION_MAX_CHARS) {
620+
return (
621+
`Error: ask_operator option ${i + 1} is ${option.length} characters; ` +
622+
`keep each label to ${ASK_OPERATOR_OPTION_MAX_CHARS} or fewer. ` +
623+
"Put the essay in a transcript reply first, then retry with short option labels."
624+
);
625+
}
626+
}
627+
const result = await onOperatorGate(question, options);
628+
if (result.kind === "cancel") {
629+
return "The operator dismissed the question without answering. Do not ask it again; proceed with your best judgment or continue with other work.";
630+
}
631+
if (result.kind === "custom") {
632+
return result.text;
633+
}
634+
const { index } = result;
635+
if (index < 0 || index >= options.length) {
636+
return `Error: invalid selection ${index}. Valid range: 0-${options.length - 1}.`;
637+
}
638+
const selected = options[index];
639+
if (selected === undefined) {
640+
return `Error: invalid selection ${index}. Valid range: 0-${options.length - 1}.`;
641+
}
642+
return selected;
643+
},
644+
}),
645+
]
646+
: []),
642647
stringTool({
643648
definition: presentDefinition,
644649
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {

src/exec/runner.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
588588
const toolWatchdog = toolWatchdogFromSettings(config.settings);
589589
const toolAvailability: ToolAvailability = {
590590
languageServerAvailable: detectLanguageServerAvailable(config.cwd),
591+
operatorAvailable: interactive,
591592
};
592593

593594
let currentAgent: Agent | null = null;
@@ -628,8 +629,12 @@ export async function runExec(config: Config): Promise<ExecResult> {
628629
isWorkflowActive: () => workflowHostHolder.instance?.isActive() === true,
629630
completeWorkflowStep: (stepId) =>
630631
workflowHostHolder.instance?.complete(stepId) ?? "not-current",
631-
onOperatorGate: (question, options) =>
632-
promptOperator(question, options, interactive),
632+
...(interactive
633+
? {
634+
onOperatorGate: (question: string, options: string[]) =>
635+
promptOperator(question, options, true),
636+
}
637+
: {}),
633638
sessionMode,
634639
toolAvailability,
635640
...(config.mcpServers !== undefined

src/prompts.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,21 @@ test("sub-agent prompt carries the report-back contract and harness facts", () =
396396
});
397397

398398
test("when ask_director is in toolNames, the worker prompt mentions ask_director", () => {
399-
const prompt = buildSubAgentSystemPrompt(undefined, undefined, undefined, {
400-
toolNames: ["read_file", "ask_director"],
401-
});
399+
const prompt = buildSubAgentSystemPrompt(
400+
undefined,
401+
{
402+
cwd: "/repo/root",
403+
platform: "Darwin 25.4.0",
404+
arch: "arm64",
405+
runtime: "Bun 1.2.0",
406+
date: new Date(2026, 5, 5),
407+
isGitRepo: false,
408+
},
409+
undefined,
410+
{
411+
toolNames: ["read_file", "ask_director"],
412+
},
413+
);
402414
expect(prompt).toContain("ask_director");
403415
expect(prompt).toContain("cannot reach the operator");
404416
expect(prompt).not.toContain("ask_operator");

0 commit comments

Comments
 (0)