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
19 changes: 19 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from
| Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests |
| Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task |

## Local Bash execution

When the current turn includes native `task_output`, foreground Bash waits up to
60 seconds before returning the same command's background task ID. Its total
command timeout defaults to 600 seconds and is capped at 600 seconds; a shorter
requested timeout applies. Backgrounding and output reads preserve the original
deadline. Without native `task_output`, Bash stays in the foreground with a
120-second default and a 300-second cap, and its schema omits `run_in_background`.
Explicit background commands use the requested timeout; when omitted, the
existing 30-minute runtime watchdog applies.

Only exit code zero is success. Results retain available exit, signal, timeout,
cancellation, and partial-output facts. Large output keeps its original beginning
and end within a 24 KiB first-response text budget, with a full-log reference when
persistence succeeds. `task_output` reads use byte offsets; a successful read can
report a failed command. Stop failures and incomplete logs are reported separately.
An optional `description` supplies the TUI summary while execution and permission
checks continue to use the original command.

## Skill directory links

Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support
Expand Down
35 changes: 10 additions & 25 deletions packages/agent-tools/src/desktop/builtin-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ToolDefinition } from '@mavis/agent-core/tools';

import { createMavisOperationClassifier } from '../shared/mavis-operation-classifier.js';
import { LOCAL_MAVIS_COMMANDS } from './local-mavis-commands.js';
import { BashDescriptionSchema } from './local-bash-input.js';
import { prepareAskUserArguments } from './prepare-ask-user-arguments.js';

import {
Expand Down Expand Up @@ -102,33 +103,17 @@ export const LocalEditToolDef = {
} as const satisfies ToolDefinition;
export type LocalEditToolInput = Static<typeof LocalEditToolDef.schema>;

const _isWindows = process.platform === 'win32';
const MAX_BACKGROUND_BASH_TIMEOUT_SECONDS = 2_147_483;

export const LocalBashToolDef = {
name: 'bash',
executionMode: 'sequential',
description: [
'Executes a command in a fresh local shell and returns its output.',
'Executes a shell command and returns its output.',
'',
'- Each call starts in the session workspace. `cd` and shell state do not persist between calls; use absolute paths or change directories within the same command.',
'- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and necessary pipelines.',
'- The shell is non-interactive: no TTY or stdin prompts. Run non-interactive commands yourself; check `--help` for suitable flags before asking the user to run one. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.',
'- Use `run_in_background` for long commands. Do not increase timeouts to mask hung commands. A returned task id refers to the original process; do not rerun it. Use `task_query` or `task_output` to inspect progress and `task_stop` to cancel.',
'- Each call starts in the session workspace. Changes to the working directory and shell state (variables and functions) do not persist between calls. If a command requires a different working directory, change directories within the same call.',
'- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and pipelines.',
'- The shell is non-interactive: no TTY or stdin prompts. For commands that require interaction, check `--help` for non-interactive options before asking the user to run them. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.',
'- For file or directory deletion, use one top-level `rm -- <path> ...`; the local runtime routes it through recoverable deletion.',
'- Do not bypass recoverable deletion with absolute paths to deletion commands or inline scripts. If it fails, report the failure instead of falling back to permanent deletion. Permission checks still apply.',
...(_isWindows
? [
'',
'Windows shell:',
'- PowerShell is preferred; Bash is a fallback when PowerShell is unavailable. Match the selected shell syntax.',
'- For PowerShell, use `$env:VAR`; do not use `export`, `/dev/null`, heredocs, `sed -i`, or `&&`. Do not wrap commands in `powershell -Command`.',
"- Start multi-statement PowerShell scripts with `$ErrorActionPreference = 'Stop'`. Use single quotes for literals and regex patterns. Do NOT use Bash-style backslash escapes.",
'- For native program failures in PowerShell, check `$LASTEXITCODE` or enable `$PSNativeCommandUseErrorActionPreference` when available.',
'- Avoid `Get-Content | ... | Set-Content` file-editing pipelines. Prefer file tools or a script with explicit UTF-8 encoding for batch changes. If Get-Content/Set-Content are needed, specify `-Encoding UTF8`; PowerShell 5.1 writes a BOM.',
'- If a CLI is missing, check `.cmd` or `.ps1` wrappers. If `bash` opens WSL or produces garbled output, switch to direct `node`/`python` execution or a verified Git Bash path. After two failures with the same approach, change strategy.',
]
: []),
'',
'# Git',
'- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.',
Expand All @@ -137,19 +122,19 @@ export const LocalBashToolDef = {
].join('\n'),
schema: Type.Object({
command: Type.String({
description: 'Shell command line to execute locally.',
description: 'The command to execute',
}),
description: BashDescriptionSchema,
timeout: Type.Optional(
Type.Number({
maximum: MAX_BACKGROUND_BASH_TIMEOUT_SECONDS,
exclusiveMinimum: 0,
description:
'Timeout in seconds. Foreground: default 120s, max 300s. Use run_in_background for longer commands. Background tasks use the requested timeout, subject to a runtime watchdog.',
'Total command timeout in seconds. Foreground-only: default 120s, max 300s. Foreground with automatic backgrounding: default/max 600s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.',
}),
),
run_in_background: Type.Optional(
Type.Boolean({
description:
'Start in the background and return a task id immediately. Foreground commands may also return a task id after 15s without restarting the process.',
description: 'Set to true to run this command in the background.',
}),
),
}),
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-tools/src/desktop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export * from './local-ask-user.js';
export * from './local-feature-enable.js';
export * from './local-browser.js';
export * from './local-pi-tools.js';
export * from './local-bash-result.js';
export * from './local-bash-timing.js';
export * from './local-bash-input.js';
export * from './local-bash-contract.js';
export { executeLocalHostTrashIfRequested } from './host-trash-executor.js';
export type { LocalHostTrashRuntime } from './host-trash-executor.js';
export * from './local-glob.js';
Expand Down Expand Up @@ -198,3 +202,4 @@ export {
CloudSessionReadError,
type CloudSessionReaderOptions,
} from './cloud-session-reader.js';
export { DESKTOP_BASH_PREVIEW_BYTES } from './output-limit.js';
83 changes: 83 additions & 0 deletions packages/agent-tools/src/desktop/local-bash-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { getShellConfig } from '@earendil-works/pi-coding-agent/shell';
import type { ToolDefinition } from '@mavis/agent-core/tools';
import { Clone, Type } from '@sinclair/typebox';

import { LocalBashToolDef } from './builtin-defs.js';
import {
DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS,
DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS,
MAX_FOREGROUND_BASH_TIMEOUT_SECONDS,
MAX_MANAGED_BASH_TIMEOUT_SECONDS,
} from './local-bash-timing.js';

export interface LocalBashTurnCapabilities {
readonly background: boolean;
readonly shell: ReturnType<typeof getShellConfig>['type'] | 'unavailable';
}

export function resolveLocalBashShell(): LocalBashTurnCapabilities['shell'] {
try {
return getShellConfig().type;
} catch {
return 'unavailable';
}
}

/** A fresh definition for the final admitted Turn inventory. */
export function createLocalBashToolDefinition(
capabilities: LocalBashTurnCapabilities,
): ToolDefinition {
const base = Clone(LocalBashToolDef.schema);
const schema = capabilities.background ? base : Type.Omit(base, ['run_in_background']);
schema.properties.timeout.description = capabilities.background
? `Total command timeout in seconds. Foreground with automatic backgrounding: default/max ${MAX_MANAGED_BASH_TIMEOUT_SECONDS}s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.`
: `Timeout in seconds. Foreground: default ${DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS}s; values above ${MAX_FOREGROUND_BASH_TIMEOUT_SECONDS}s are capped.`;
return {
...LocalBashToolDef,
schema,
description: `${LocalBashToolDef.description}\n\n${renderBashUsage(capabilities)}`,
};
}

function renderBashUsage(capabilities: LocalBashTurnCapabilities): string {
return [
...(capabilities.background
? [
`- Foreground calls wait up to ${DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS / 1000}s, then return a task_id if unfinished. The same command continues in the background; do not start it again.`,
'- Set run_in_background=true to return a task_id without waiting for completion.',
'- Background completion notifies you automatically. Continue independent work; use task_output for current output and task_stop, when available, to cancel.',
'- Backgrounding and reading output do not reset the command timeout.',
]
: ['- This Turn supports foreground execution only.']),
"- Large output preserves the beginning and end. Follow the result's instructions to read omitted output.",
...shellRules(capabilities.shell),
].join('\n');
}

function shellRules(shell: LocalBashTurnCapabilities['shell']): string[] {
if (shell === 'unavailable')
return [
'- No local shell was resolved. Do not assume Bash or PowerShell syntax is executable.',
];
if (shell === 'bash')
return [
'- Selected shell: Bash. Use Bash syntax, including on Windows when Bash is the selected fallback.',
];
if (shell === 'sh')
return [
'- Selected shell: POSIX sh. Use POSIX shell syntax; Bash arrays, [[ ... ]] and process substitution are unavailable.',
];
return [
shell === 'pwsh'
? '- Selected shell: PowerShell 7 (pwsh). Pipeline-chain operators && and || are supported; use PowerShell quoting and variable syntax.'
: '- Selected shell: Windows PowerShell 5.1. The && and || operators are unsupported; use separate statements and explicit success checks.',
'- Use $env:VAR and single quotes for literals/regex. Do not use Bash export, /dev/null, heredocs, sed -i, or backslash quoting. Do not wrap ordinary commands in powershell -Command or cmd /c.',
"- Start multi-statement scripts with $ErrorActionPreference = 'Stop'. Check $LASTEXITCODE after native programs, or enable $PSNativeCommandUseErrorActionPreference when available.",
`- Prefer dedicated file tools. If shell file I/O is necessary, specify UTF-8 and avoid Get-Content | ... | Set-Content editing pipelines.${
shell === 'powershell'
? ' PowerShell 5.1 -Encoding UTF8 writes a BOM.'
: ' PowerShell 7 defaults to UTF-8 without BOM.'
}`,
'- If a CLI is missing, check .cmd/.ps1 wrappers. Change strategy after repeated syntax failures.',
];
}
14 changes: 14 additions & 0 deletions packages/agent-tools/src/desktop/local-bash-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Type } from '@sinclair/typebox';

export const BashDescriptionSchema = Type.Optional(
Type.String({
description: 'A short description of what this command does.',
}),
);

export function resolveBashDescription(description: unknown, command: string): string {
if (description !== undefined && typeof description !== 'string') {
throw new Error('Bash description must be a string.');
}
return description || command;
}
99 changes: 99 additions & 0 deletions packages/agent-tools/src/desktop/local-bash-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
BashExecutionError,
type BashExecutionOutcome,
type BashToolDetails,
} from '@earendil-works/pi-coding-agent/tools';
import type { AgentToolResult } from '@earendil-works/pi-agent-core';

import type { LocalBackgroundBashExecutorResult } from './types.js';

export interface LocalBashExecutionOutcome extends Omit<BashExecutionOutcome, 'reason'> {
reason: BashExecutionOutcome['reason'] | 'preflight_failed' | 'watchdog_timeout';
}

export function localBashResultFromPi(
result: AgentToolResult<unknown>,
): LocalBackgroundBashExecutorResult {
const details = (result.details ?? {}) as Record<string, unknown>;
const execution = readBashExecutionOutcome(details);
return {
text: result.content
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
.map((part) => part.text)
.join('\n'),
details,
...(execution && execution.status !== 'succeeded' ? { isError: true } : {}),
};
}

export function localBashResultFromError(
error: unknown,
signal?: AbortSignal,
): LocalBackgroundBashExecutorResult {
const details: BashToolDetails = error instanceof BashExecutionError ? error.details : {};
const cause = error instanceof BashExecutionError ? error.cause : error;
const causeRecord =
cause && typeof cause === 'object' ? (cause as Record<string, unknown>) : undefined;
const errorCode = typeof causeRecord?.code === 'string' ? causeRecord.code : undefined;
const preflight =
errorCode?.startsWith('SANDBOX_') &&
(causeRecord?.stage === undefined ||
['init', 'parse', 'invocation', 'pre-spawn'].includes(String(causeRecord.stage)));
const text = error instanceof Error ? error.message : String(error);
const execution: LocalBashExecutionOutcome = {
status: signal?.aborted ? 'canceled' : 'failed',
reason: signal?.aborted ? 'canceled' : 'unknown',
exitCode: null,
message: text,
...(errorCode ? { errorCode } : {}),
...details.execution,
...(preflight ? { reason: 'preflight_failed' as const } : {}),
};
if (execution.status === 'canceled' && !execution.cancellationReason && signal?.aborted) {
execution.cancellationReason =
signal.reason instanceof Error
? signal.reason.message
: typeof signal.reason === 'string'
? signal.reason
: undefined;
}
return { text, details: { ...details, execution }, isError: true };
}

export function readBashExecutionOutcome(details: unknown): LocalBashExecutionOutcome | undefined {
if (!details || typeof details !== 'object' || !('execution' in details)) return undefined;
const execution = details.execution;
if (!execution || typeof execution !== 'object') return undefined;
const value = execution as Record<string, unknown>;
if (
typeof value.status !== 'string' ||
!['succeeded', 'failed', 'canceled'].includes(value.status) ||
typeof value.reason !== 'string' ||
![
'exited',
'signaled',
'command_timeout',
'canceled',
'spawn_failed',
'unknown',
'preflight_failed',
'watchdog_timeout',
].includes(value.reason) ||
!(
value.exitCode === null ||
(typeof value.exitCode === 'number' && Number.isInteger(value.exitCode))
) ||
[value.signal, value.errorCode, value.message, value.cancellationReason].some(
(field) => field !== undefined && typeof field !== 'string',
)
)
return undefined;
return execution as LocalBashExecutionOutcome;
}

export function formatBashExecutionOutcome(execution: LocalBashExecutionOutcome): string {
if (execution.message) return execution.message;
if (execution.signal) return `Command terminated by signal ${execution.signal}`;
if (execution.exitCode !== null) return `Command exited with code ${execution.exitCode}`;
return `Command ${execution.status} (${execution.reason})`;
}
62 changes: 62 additions & 0 deletions packages/agent-tools/src/desktop/local-bash-timing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ToolResult } from '@mavis/agent-core/tools';

export const DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS = 120;
export const MAX_FOREGROUND_BASH_TIMEOUT_SECONDS = 300;
export const MAX_MANAGED_BASH_TIMEOUT_SECONDS = 600;
export const MAX_BASH_TIMEOUT_SECONDS = 2_147_483;
export const DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS = 60_000;

export interface LocalBashTiming {
commandTimeoutSeconds?: number;
requestedTimeoutSeconds?: number;
commandTimerStartedAt?: number;
commandDeadlineAt?: number;
}

export function resolveLocalBashTiming(
timeout: number | undefined,
mode: 'direct_foreground' | 'managed_foreground' | 'explicit_background',
): LocalBashTiming {
if (
timeout !== undefined &&
(!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_BASH_TIMEOUT_SECONDS)
) {
throw new Error(
`timeout must be a finite positive number of seconds, at most ${MAX_BASH_TIMEOUT_SECONDS}.`,
);
}
const commandTimeoutSeconds =
mode === 'explicit_background'
? timeout
: mode === 'managed_foreground'
? Math.min(timeout ?? MAX_MANAGED_BASH_TIMEOUT_SECONDS, MAX_MANAGED_BASH_TIMEOUT_SECONDS)
: Math.min(
timeout ?? DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS,
MAX_FOREGROUND_BASH_TIMEOUT_SECONDS,
);
return {
commandTimeoutSeconds,
...(timeout !== undefined && timeout !== commandTimeoutSeconds
? { requestedTimeoutSeconds: timeout }
: {}),
};
}

export function validateBashCommand(command: string): void {
if (typeof command !== 'string' || command.trim().length === 0) {
throw new Error('command must be a non-empty shell command.');
}
}

export function withLocalBashTiming(result: ToolResult, timing: LocalBashTiming): ToolResult {
const text =
timing.requestedTimeoutSeconds !== undefined
? `${result.text}\n\n[bash timeout: requested ${timing.requestedTimeoutSeconds}s; effective ${timing.commandTimeoutSeconds}s command limit.]`
: result.text;
return {
...result,
text,
content: [{ type: 'text', text }],
details: { ...result.details, timing: { ...(result.details?.timing as object), ...timing } },
};
}
Loading
Loading