mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-16 02:39:08 +00:00
z54M (Copilot): runGit returned '' on both successful-empty-output
and silent failure, so a `--name-only` that errored mid-way through
the diff fan-out aliased to a real `--allow-empty` commit. The
empty-commit branch then preserved pending attributions, leaving
the just-committed file's tracked AI edit alive to re-attribute on
the next commit. Switch runGit to `Promise<string | null>`,
distinguishing exit code 0 (any output, including '') from non-zero
(null). The diff-stage fan-out and ancillary probes now treat null
as analysis failure and bail with `return null` instead of falling
into the empty-commit path.
z539 (Copilot): the v3→v4 `shouldMigrate` only fired on
`$version === 3`. A versionless settings file carrying the legacy
`general.gitCoAuthor: false` boolean would skip every migration
(gitCoAuthor isn't in V1_INDICATOR_KEYS — it post-dates V2), get
its `$version` normalized to 4 by the loader, and leave the
boolean in place. The settings dialog then reads the V4
`{commit, pr}` shape, sees missing keys, defaults both to true, and
silently overwrites the user's opt-out on the next save. Also fire
when `$version` is absent AND the value at `general.gitCoAuthor`
is a boolean. Tests cover the new path and confirm the existing
versioned/object-shape paths are untouched.
2817 lines
112 KiB
TypeScript
2817 lines
112 KiB
TypeScript
/**
|
||
* @license
|
||
* Copyright 2025 Google LLC
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import os from 'node:os';
|
||
import crypto from 'node:crypto';
|
||
import * as childProcess from 'node:child_process';
|
||
import type { Config } from '../config/config.js';
|
||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||
import { ToolErrorType } from './tool-error.js';
|
||
import type {
|
||
ToolInvocation,
|
||
ToolResult,
|
||
ToolResultDisplay,
|
||
ToolCallConfirmationDetails,
|
||
ToolExecuteConfirmationDetails,
|
||
ToolConfirmationPayload,
|
||
ToolConfirmationOutcome,
|
||
} from './tools.js';
|
||
import type { PermissionDecision } from '../permissions/types.js';
|
||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||
import { getErrorMessage } from '../utils/errors.js';
|
||
import { truncateToolOutput } from '../utils/truncation.js';
|
||
import {
|
||
CommitAttributionService,
|
||
type StagedFileInfo,
|
||
} from '../services/commitAttribution.js';
|
||
import { buildGitNotesCommand } from '../services/attributionTrailer.js';
|
||
import type {
|
||
ShellExecutionConfig,
|
||
ShellOutputEvent,
|
||
} from '../services/shellExecutionService.js';
|
||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||
import type { BackgroundShellEntry } from '../services/backgroundShellRegistry.js';
|
||
import stripAnsi from 'strip-ansi';
|
||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||
import { isSubpaths } from '../utils/paths.js';
|
||
import {
|
||
getCommandRoot,
|
||
getCommandRoots,
|
||
getShellConfiguration,
|
||
splitCommands,
|
||
stripShellWrapper,
|
||
} from '../utils/shell-utils.js';
|
||
import { parse } from 'shell-quote';
|
||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||
import {
|
||
isShellCommandReadOnlyAST,
|
||
extractCommandRules,
|
||
} from '../utils/shellAstParser.js';
|
||
|
||
const debugLogger = createDebugLogger('SHELL');
|
||
|
||
/**
|
||
* Strip a single bare trailing `&` (bash background operator) from a
|
||
* command string. Returns the input unchanged if the trailing form is
|
||
* `&&` (logical AND), `\&` (escaped literal `&`), or there is no `&`
|
||
* at the end at all. Linear time, no regex backtracking risk.
|
||
*/
|
||
function stripTrailingBackgroundAmp(command: string): string {
|
||
const trimmed = command.trimEnd();
|
||
if (!trimmed.endsWith('&')) return command;
|
||
if (trimmed.endsWith('&&')) return command;
|
||
if (trimmed.endsWith('\\&')) return command;
|
||
return trimmed.slice(0, -1).trimEnd();
|
||
}
|
||
|
||
/**
|
||
* Escape `s` so it is safe to interpolate inside a bash double-quoted
|
||
* string. Inside `"..."`, bash still interprets `$`, backtick, `\`, and
|
||
* `"`; escape those four. Newlines and other characters are literal.
|
||
*/
|
||
function escapeForBashDoubleQuote(s: string): string {
|
||
return s.replace(/[\\"$`]/g, '\\$&');
|
||
}
|
||
|
||
/**
|
||
* Escape `s` so it is safe to interpolate inside a bash single-quoted
|
||
* string. Bash single quotes have no escape mechanism — the standard
|
||
* trick is to close the quote, emit a backslash-escaped `'`, and reopen.
|
||
*/
|
||
function escapeForBashSingleQuote(s: string): string {
|
||
return s.replace(/'/g, "'\\''");
|
||
}
|
||
|
||
/**
|
||
* Return the LAST match from a RegExp.matchAll iterator, or `null` if
|
||
* the iterator is empty. Used to find the final `-m` / `--body` flag
|
||
* in a command segment: git/gh both honour the LAST occurrence when
|
||
* multiple are passed, so the trailer has to land in that match to be
|
||
* picked up by the actual commit / PR body.
|
||
*/
|
||
function lastMatchOf<T extends RegExpMatchArray>(
|
||
matches: IterableIterator<T>,
|
||
): T | null {
|
||
let result: T | null = null;
|
||
for (const m of matches) result = m;
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Tokenise a single shell-command segment via `shell-quote`. Returns
|
||
* the parsed string tokens with leading env-var assignments and a
|
||
* small allowlist of safe wrappers (`sudo`, `command`, with their
|
||
* flag block consumed) stripped. Returns `null` if the segment
|
||
* doesn't parse — the caller should then skip the segment.
|
||
*
|
||
* Using `shell-quote.parse` (rather than a regex scan) is what makes
|
||
* quoted env values (`FOO="a b" cmd`) tokenise correctly and avoids
|
||
* the polynomial regex behaviour CodeQL flagged on the previous
|
||
* `\S*\s+`-based slicing loop.
|
||
*/
|
||
function tokeniseSegment(segment: string): string[] | null {
|
||
let tokens: string[];
|
||
try {
|
||
tokens = parse(segment).filter((t): t is string => typeof t === 'string');
|
||
} catch (e) {
|
||
debugLogger.warn(
|
||
`tokeniseSegment: parse failed for "${segment.slice(0, 80)}": ${
|
||
e instanceof Error ? e.message : String(e)
|
||
}`,
|
||
);
|
||
return null;
|
||
}
|
||
let i = 0;
|
||
// Skip env-var assignments (KEY=value).
|
||
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) {
|
||
i++;
|
||
}
|
||
// Strip a single safe wrapper, then any leading flag tokens it
|
||
// took. Sudo's value-taking flags (`-u user`, `-g group`,
|
||
// `-h host`, `-D path`, `-r role`, `-t type`) consume the next
|
||
// argv slot, so without explicitly knowing which take values we'd
|
||
// leave e.g. `user` standing in for the program in
|
||
// `sudo -u user git commit ...`. `command` doesn't take any flag
|
||
// values. `env` accepts both flags (`-i`, `-S`, `-u name`) AND
|
||
// `KEY=VALUE` argv entries before the program — both need
|
||
// skipping so `env GIT_COMMITTER_DATE=now git commit ...` resolves
|
||
// to `git`.
|
||
if (tokens[i] === 'sudo' || tokens[i] === 'command' || tokens[i] === 'env') {
|
||
const wrapper = tokens[i];
|
||
i++;
|
||
while (i < tokens.length && tokens[i]!.startsWith('-')) {
|
||
const flag = tokens[i]!;
|
||
i++;
|
||
// Value-taking flag tables, per wrapper: `sudo -u user`,
|
||
// `env -u NAME` (unset), `env -S string` (split-string args).
|
||
// `command` has no value-taking options in this allowlist.
|
||
// Without skipping the value, `env -u FOO git commit ...`
|
||
// would leave `FOO` as `tokens[0]` and the parser would treat
|
||
// it as the program — masking the real `git commit`.
|
||
const takesValue =
|
||
(wrapper === 'sudo' && SUDO_FLAGS_WITH_VALUE.has(flag)) ||
|
||
(wrapper === 'env' && ENV_FLAGS_WITH_VALUE.has(flag));
|
||
if (takesValue && i < tokens.length) {
|
||
i++;
|
||
}
|
||
}
|
||
// `env` puts KEY=VALUE pairs between its flags and the real
|
||
// program, so skip those too. Doing this only after the wrapper
|
||
// detection (rather than universally) avoids accidentally
|
||
// consuming what the user actually wrote.
|
||
if (wrapper === 'env') {
|
||
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) {
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
return tokens.slice(i);
|
||
}
|
||
|
||
const SUDO_FLAGS_WITH_VALUE = new Set([
|
||
'-u',
|
||
'-g',
|
||
'-h',
|
||
'-D',
|
||
'-r',
|
||
'-t',
|
||
'-C',
|
||
'--user',
|
||
'--group',
|
||
'--host',
|
||
'--chdir',
|
||
'--role',
|
||
'--type',
|
||
]);
|
||
|
||
// `env`'s value-taking flags. `-u NAME` unsets a variable;
|
||
// `-S "string"` splits a single string into args. Without skipping
|
||
// the value, `env -u FOO git commit ...` would leave `FOO` as the
|
||
// next token and the parser would treat it as the program.
|
||
const ENV_FLAGS_WITH_VALUE = new Set(['-u', '--unset', '-S', '--split-string']);
|
||
|
||
/**
|
||
* Walk a `git ...` token sequence past git's global flags
|
||
* (`-c key=val`, `-C path`, `--no-pager`, `--git-dir`, `--work-tree`,
|
||
* `--namespace`, etc.) to find the actual subcommand. Without this,
|
||
* `git -c k=v commit -m x` and `git --no-pager commit -m x` would
|
||
* silently slip past a fixed-position check at index 1.
|
||
*
|
||
* `changesCwd` is true when any of the consumed flags would relocate
|
||
* the working directory (`-C`, `--git-dir`, `--work-tree`).
|
||
*/
|
||
// Two-token global flags whose second token is consumed as a value.
|
||
const GIT_GLOBAL_FLAGS_TAKES_VALUE = new Set([
|
||
'-c',
|
||
'-C',
|
||
'--git-dir',
|
||
'--work-tree',
|
||
'--namespace',
|
||
'--exec-path',
|
||
'--config-env',
|
||
'--super-prefix',
|
||
'--list-cmds',
|
||
]);
|
||
// Flags whose presence shifts cwd interpretation.
|
||
const GIT_GLOBAL_FLAGS_SHIFTS_CWD = new Set(['-C', '--git-dir', '--work-tree']);
|
||
|
||
function parseGitInvocation(tokens: string[]): {
|
||
subcommand: string | undefined;
|
||
changesCwd: boolean;
|
||
} {
|
||
let i = 1; // skip 'git'
|
||
let changesCwd = false;
|
||
while (i < tokens.length) {
|
||
const t = tokens[i]!;
|
||
if (GIT_GLOBAL_FLAGS_TAKES_VALUE.has(t)) {
|
||
if (GIT_GLOBAL_FLAGS_SHIFTS_CWD.has(t)) changesCwd = true;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
// Attached-value form: `--git-dir=path`, `--work-tree=path`, etc.
|
||
if (t.startsWith('--git-dir=') || t.startsWith('--work-tree=')) {
|
||
changesCwd = true;
|
||
i++;
|
||
continue;
|
||
}
|
||
// Attached-value form for `-C`: `git -C/path commit ...`. Git
|
||
// accepts both `-C path` (handled above by TAKES_VALUE) and the
|
||
// concatenated form. shell-quote tokenises the latter as a single
|
||
// `-Cpath` token.
|
||
if (t.length > 2 && t.startsWith('-C')) {
|
||
changesCwd = true;
|
||
i++;
|
||
continue;
|
||
}
|
||
// Other long/short flag (no separate arg, e.g. --no-pager,
|
||
// --version, --bare, -p).
|
||
if (t.startsWith('-')) {
|
||
i++;
|
||
continue;
|
||
}
|
||
// First non-flag is the subcommand.
|
||
return { subcommand: t, changesCwd };
|
||
}
|
||
return { subcommand: undefined, changesCwd };
|
||
}
|
||
|
||
/**
|
||
* Classify whether a command chain (potentially compound) contains a
|
||
* `git commit` invocation, and whether that invocation lands in the
|
||
* tool's initial cwd.
|
||
*
|
||
* Two flags are returned because the answers feed different decisions:
|
||
* - `hasCommit` is the broader "did the user try to commit anywhere
|
||
* in this chain?" — used to refuse background mode and to gate
|
||
* prompt-counter snapshotting.
|
||
* - `attributableInCwd` is the stricter "is it safe to capture HEAD
|
||
* in our cwd and write a note to that repo?" — used by the actual
|
||
* trailer rewrite and git-notes write.
|
||
*
|
||
* Walks segments in order so a `cd` AFTER an in-cwd commit doesn't
|
||
* invalidate that commit's attribution; only a `cd` (or `git -C` /
|
||
* `--git-dir` / `--work-tree`) BEFORE the commit shifts safety.
|
||
*
|
||
* `cwdShifted` is intentionally a one-way latch — it isn't reset on
|
||
* a subsequent `cd .` or `cd ..`, so harmless cd cycles like
|
||
* `cd src && cd .. && git commit -m x` will conservatively skip
|
||
* attribution. The trade-off matches the wrong-repo guard's intent
|
||
* (better miss than corrupt unrelated repos).
|
||
*/
|
||
function gitCommitContext(command: string): {
|
||
hasCommit: boolean;
|
||
attributableInCwd: boolean;
|
||
} {
|
||
let hasCommit = false;
|
||
let attributable = false;
|
||
let cwdShifted = false;
|
||
|
||
for (const sub of splitCommands(command)) {
|
||
const tokens = tokeniseSegment(sub);
|
||
if (!tokens || tokens.length === 0) continue;
|
||
|
||
const program = tokens[0]!;
|
||
|
||
if (program === 'cd') {
|
||
// A cd before any commit might redirect a later `git commit` into
|
||
// a different repo. A cd AFTER the commit doesn't matter for the
|
||
// commit we already saw.
|
||
//
|
||
// A heuristic relaxation: relative cd targets that don't escape
|
||
// upward (no `..`, no absolute path, no env-var/$home expansion)
|
||
// almost always stay within the same repo. The very common
|
||
// `cd subdir && git commit -m "..."` flow is the motivating case
|
||
// — same repo, same toplevel, attribution is still safe. Only
|
||
// mark as shifted when the target *could* land us in a different
|
||
// repo. We can't be 100% certain without running `git rev-parse
|
||
// --show-toplevel` after the cd, which would require a synchronous
|
||
// fs/exec call that the rest of this walk avoids — the heuristic
|
||
// covers the common case and stays conservative on the rest.
|
||
if (!hasCommit && cdTargetMayChangeRepo(tokens)) cwdShifted = true;
|
||
continue;
|
||
}
|
||
|
||
if (program === 'git') {
|
||
const { subcommand, changesCwd } = parseGitInvocation(tokens);
|
||
if (subcommand === 'commit') {
|
||
hasCommit = true;
|
||
// The commit lands in our cwd only if no preceding cd shifted
|
||
// us and this very invocation didn't redirect via -C/--git-dir.
|
||
if (!cwdShifted && !changesCwd) attributable = true;
|
||
} else if (changesCwd && !hasCommit) {
|
||
// `git -C /path status` and friends signal cwd-elsewhere
|
||
// intent; subsequent in-cwd commits in this chain are unusual
|
||
// enough to be conservative about.
|
||
cwdShifted = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { hasCommit, attributableInCwd: attributable };
|
||
}
|
||
|
||
/**
|
||
* Walk a `gh ...` token sequence past gh's global flags
|
||
* (`--repo owner/repo`, `--hostname host`, `--help`, `--version`) and
|
||
* return the resulting subcommand chain. Same purpose as
|
||
* `parseGitInvocation`: a fixed-position check at index 1 misses
|
||
* `gh --repo owner/repo pr create ...`, which is a common form.
|
||
*/
|
||
const GH_GLOBAL_FLAGS_TAKES_VALUE = new Set(['--repo', '-R', '--hostname']);
|
||
|
||
function parseGhInvocation(tokens: string[]): string[] {
|
||
let i = 1; // skip 'gh'
|
||
while (i < tokens.length) {
|
||
const t = tokens[i]!;
|
||
if (GH_GLOBAL_FLAGS_TAKES_VALUE.has(t)) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (
|
||
t.startsWith('--repo=') ||
|
||
t.startsWith('--hostname=') ||
|
||
t.startsWith('-R=')
|
||
) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (t.startsWith('-')) {
|
||
i++;
|
||
continue;
|
||
}
|
||
return tokens.slice(i);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Heuristic: does this `cd` invocation potentially redirect us into
|
||
* a different repository? Used by `gitCommitContext` to decide
|
||
* whether a subsequent `git commit` in the same chain is still
|
||
* attributable in our cwd.
|
||
*
|
||
* Returns true (conservative — assume shift) when the target is
|
||
* absolute, escapes upward (`..`), goes to `$HOME` / `~`, contains an
|
||
* env-var (we can't resolve it statically), or is missing entirely
|
||
* (`cd` alone goes to `$HOME`). Plain relative paths like `cd src`,
|
||
* `cd ./packages/foo`, or `cd subdir/nested` are treated as in-repo.
|
||
*/
|
||
function cdTargetMayChangeRepo(tokens: string[]): boolean {
|
||
// tokens[0] is 'cd'. The next non-flag token is the target.
|
||
let i = 1;
|
||
while (i < tokens.length && tokens[i]!.startsWith('-')) i++;
|
||
const target = tokens[i];
|
||
// `cd` with no argument goes to $HOME.
|
||
if (target === undefined) return true;
|
||
if (target.startsWith('/')) return true;
|
||
if (target.startsWith('~')) return true;
|
||
// Env-var reference (e.g. `$HOME`, `$REPO`) — can't resolve here.
|
||
if (target.includes('$')) return true;
|
||
// `..`, `../..`, `..\\foo` etc. could escape the repo root.
|
||
if (target === '..') return true;
|
||
if (target.startsWith('../') || target.startsWith('..\\')) return true;
|
||
// Embedded parent-dir traversal can also escape: `foo/../../escape`,
|
||
// `./..`, `nested/..`, etc. Catching `/..` and `\..` anywhere in
|
||
// the path covers both POSIX and Windows separators without
|
||
// false-positiving on legitimate names that happen to contain `..`
|
||
// (which only escape when followed by a separator).
|
||
if (target.includes('/..') || target.includes('\\..')) return true;
|
||
// `-` is bash's "previous directory" — could be anywhere.
|
||
if (target === '-') return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Detect whether the attributable `git commit` invocation in
|
||
* `command` carries the `--amend` flag. Used so attachCommitAttribution
|
||
* can switch the diff range from `HEAD~1..HEAD` (the amended commit
|
||
* vs its parent — too broad for amend) to `HEAD@{1}..HEAD` (the
|
||
* actual amend delta).
|
||
*
|
||
* Only the *first* commit segment that runs in the same cwd as the
|
||
* shell tool counts. `git -C ../other commit --amend && git commit -m x`
|
||
* must not flip the diff range for the second (fresh) commit, since
|
||
* `HEAD@{1}` belongs to the inner repo there, not ours.
|
||
*/
|
||
function isAmendCommit(command: string): boolean {
|
||
let cwdShifted = false;
|
||
for (const sub of splitCommands(command)) {
|
||
const tokens = tokeniseSegment(sub);
|
||
if (!tokens || tokens.length === 0) continue;
|
||
const program = tokens[0]!;
|
||
if (program === 'cd') {
|
||
if (!cwdShifted && cdTargetMayChangeRepo(tokens)) cwdShifted = true;
|
||
continue;
|
||
}
|
||
if (program !== 'git') continue;
|
||
const { subcommand, changesCwd } = parseGitInvocation(tokens);
|
||
if (subcommand === 'commit' && !cwdShifted && !changesCwd) {
|
||
return (
|
||
tokens.includes('--amend') ||
|
||
tokens.some((t) => t.startsWith('--amend='))
|
||
);
|
||
}
|
||
if (changesCwd && !cwdShifted) cwdShifted = true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Locate the character range of the *first* attributable
|
||
* `git commit` invocation in the (potentially compound) command, or
|
||
* `null` if none is attributable in the current cwd. The range
|
||
* covers the segment as `splitCommands` tokenised it — i.e. just
|
||
* the `git commit ...` part, NOT later `&& git tag -m ...` or
|
||
* earlier `git status &&` segments.
|
||
*
|
||
* Used by `addCoAuthorToGitCommit` to scope the `-m` regex rewrite
|
||
* so a later `git tag -m "..."` (different sub-command in the same
|
||
* compound) can't be mistaken for the commit message.
|
||
*/
|
||
function findAttributableCommitSegment(
|
||
command: string,
|
||
): { start: number; end: number } | null {
|
||
let cursor = 0;
|
||
let cwdShifted = false;
|
||
for (const sub of splitCommands(command)) {
|
||
const start = command.indexOf(sub, cursor);
|
||
if (start < 0) {
|
||
// splitCommands strips line continuations (`\<newline>`) and
|
||
// some whitespace, so the trimmed segment text may not appear
|
||
// verbatim in the original command. Log so a multi-line
|
||
// command silently dropping its trailer is at least visible
|
||
// when QWEN_DEBUG_LOG_FILE is set.
|
||
debugLogger.warn(
|
||
`findAttributableCommitSegment: cannot map segment "${sub.slice(0, 60)}" ` +
|
||
`back to the original command (likely line-continuation / whitespace mismatch).`,
|
||
);
|
||
continue;
|
||
}
|
||
const end = start + sub.length;
|
||
cursor = end;
|
||
const tokens = tokeniseSegment(sub);
|
||
if (!tokens || tokens.length === 0) continue;
|
||
const program = tokens[0]!;
|
||
if (program === 'cd') {
|
||
// Mirror gitCommitContext's cd heuristic: relative paths that
|
||
// don't escape upward are treated as in-repo, so
|
||
// `cd subdir && git commit ...` still finds the segment.
|
||
if (!cwdShifted && cdTargetMayChangeRepo(tokens)) cwdShifted = true;
|
||
continue;
|
||
}
|
||
if (program === 'git') {
|
||
const { subcommand, changesCwd } = parseGitInvocation(tokens);
|
||
if (subcommand === 'commit' && !cwdShifted && !changesCwd) {
|
||
return { start, end };
|
||
}
|
||
if (changesCwd && !cwdShifted) cwdShifted = true;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Locate the character range of the `gh pr create` (or alias
|
||
* `gh pr new`) segment in a potentially compound command. Used by
|
||
* `addAttributionToPR` so the `--body`/`-b` rewrite is scoped to
|
||
* just that segment — without scoping, a command like
|
||
* `curl -b "session=abc" && gh pr create --body "summary"` would
|
||
* have the regex match `curl`'s `-b` cookie flag and inject
|
||
* attribution there.
|
||
*/
|
||
function findGhPrCreateSegment(
|
||
command: string,
|
||
): { start: number; end: number } | null {
|
||
let cursor = 0;
|
||
for (const sub of splitCommands(command)) {
|
||
const start = command.indexOf(sub, cursor);
|
||
if (start < 0) {
|
||
debugLogger.warn(
|
||
`findGhPrCreateSegment: cannot map segment "${sub.slice(0, 60)}" ` +
|
||
`back to the original command (likely line-continuation / whitespace mismatch).`,
|
||
);
|
||
continue;
|
||
}
|
||
const end = start + sub.length;
|
||
cursor = end;
|
||
const tokens = tokeniseSegment(sub);
|
||
if (!tokens || tokens[0] !== 'gh') continue;
|
||
const rest = parseGhInvocation(tokens);
|
||
if (rest[0] === 'pr' && (rest[1] === 'create' || rest[1] === 'new')) {
|
||
return { start, end };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Approximate characters per text line for the diff-size estimate. */
|
||
const APPROX_CHARS_PER_LINE = 40;
|
||
/** Fallback char estimate when --numstat reports `-` (binary file). */
|
||
const BINARY_DIFF_SIZE_FALLBACK = 1024;
|
||
|
||
/**
|
||
* Parse `git diff --numstat` output into a `path → approximate change
|
||
* size` map for attribution accounting. The result feeds in as the
|
||
* denominator clamp for `aiChars`, so missing entries would silently
|
||
* drop a file from attribution — every changed file must land in the
|
||
* map.
|
||
*
|
||
* `--numstat` is preferred over `--stat` because the columns are exact
|
||
* integers (no graphical bars to parse). Each line is:
|
||
* `<additions>\t<deletions>\t<path>`
|
||
* For binary files, both counts are `-`; we fall back to a fixed
|
||
* estimate so binary-only changes still get a non-zero entry.
|
||
*
|
||
* The `(adds + dels) * 40` figure remains a heuristic — git diff has no
|
||
* cheap way to surface exact character counts. The clamp in
|
||
* `generateNotePayload` keeps the math consistent (aiChars never
|
||
* exceeds diffSize), so the heuristic drives the precision of the
|
||
* percentage but cannot make `aiChars + humanChars` diverge from
|
||
* `diffSize`.
|
||
*
|
||
* Rename notations (`{old => new}` and bare `old => new`) are
|
||
* normalized to the new path so lookups match `--name-only` output.
|
||
*
|
||
* Exported for unit testing — the function is otherwise an
|
||
* implementation detail of `attachCommitAttribution`.
|
||
*/
|
||
export function parseNumstat(numstatOutput: string): Map<string, number> {
|
||
const sizes = new Map<string, number>();
|
||
const lines = numstatOutput.split('\n').filter(Boolean);
|
||
|
||
const normalizeFilePath = (filePath: string): string => {
|
||
let p = filePath.trim();
|
||
// Brace rename: `{old => new}` or `dir/{old => new}/file`
|
||
p = p.replace(/\{[^}]*?=>\s*([^}]*)\}/g, '$1');
|
||
// Bare rename across directories: `old/path/file => new/path/file`
|
||
if (p.includes('=>')) {
|
||
const m = p.match(/^(.*?)\s=>\s(.*)$/);
|
||
if (m) p = m[2]!.trim();
|
||
}
|
||
return p;
|
||
};
|
||
|
||
for (const line of lines) {
|
||
// Format: "<additions>\t<deletions>\t<path>" — a literal "-" stands
|
||
// in for both counts on binary entries.
|
||
const m = line.match(/^([\d-]+)\t([\d-]+)\t(.+)$/);
|
||
if (!m) continue;
|
||
const filePath = normalizeFilePath(m[3]!);
|
||
if (m[1] === '-' && m[2] === '-') {
|
||
// Binary file: numstat omits exact counts. Fall back to a fixed
|
||
// estimate so the entry isn't missing entirely (which would zero
|
||
// out attribution for the file).
|
||
sizes.set(filePath, BINARY_DIFF_SIZE_FALLBACK);
|
||
continue;
|
||
}
|
||
const adds = parseInt(m[1]!, 10);
|
||
const dels = parseInt(m[2]!, 10);
|
||
if (Number.isNaN(adds) || Number.isNaN(dels)) continue;
|
||
sizes.set(filePath, (adds + dels) * APPROX_CHARS_PER_LINE);
|
||
}
|
||
|
||
return sizes;
|
||
}
|
||
|
||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||
const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000;
|
||
|
||
// Long-run advisory threshold: half the EFFECTIVE foreground timeout
|
||
// (not the default), computed per-invocation by `longRunThresholdFor`.
|
||
// Couples to whichever timeout actually governs THIS command — so a
|
||
// user who sets `timeout: 600_000` (10 min) gets the advisory at 5 min,
|
||
// not at 60s. The 1/2 ratio is chosen so the hint surfaces well before
|
||
// the timeout would hard-kill, but late enough that normal foreground
|
||
// commands (under the 120s default) don't trigger it before ~60s.
|
||
//
|
||
// Floor of 1000ms guards the pathological tiny-positive-timeout edge.
|
||
// `timeout <= 0` is already rejected by `validateToolParamValues` so
|
||
// only positive values reach here, but `timeout: 1` (or any value < 2)
|
||
// would otherwise produce `Math.floor(timeout / 2) = 0` and make
|
||
// `elapsedMs >= 0` fire on every invocation showing "ran for 0s",
|
||
// surfacing the hint before the command had a chance to fail by
|
||
// timing out.
|
||
const MIN_LONG_RUN_THRESHOLD_MS = 1000;
|
||
function longRunThresholdFor(effectiveTimeoutMs: number): number {
|
||
return Math.max(
|
||
MIN_LONG_RUN_THRESHOLD_MS,
|
||
Math.floor(effectiveTimeoutMs / 2),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Format the long-run advisory appended to long foreground commands.
|
||
* Exported so tests and any future consumer (e.g. an alternative
|
||
* renderer) can render the same text without duplicating the threshold
|
||
* logic.
|
||
*
|
||
* Wording deliberately keeps the dialog mention conditional ("when
|
||
* running interactively") so the LLM doesn't relay misleading guidance
|
||
* to non-TTY users (`-p` headless / ACP / SDK consumers, where no
|
||
* dialog or footer pill exists). `/tasks` and the on-disk output file
|
||
* work in every mode.
|
||
*/
|
||
export function buildLongRunningForegroundHint(elapsedMs: number): string {
|
||
const seconds = Math.round(elapsedMs / 1000);
|
||
return (
|
||
`Note: this foreground command ran for ${seconds}s. ` +
|
||
`Next time you run a similar long-running process (build watchers, ` +
|
||
`dev servers, soak tests, polling loops), pass \`is_background: true\` ` +
|
||
`so the agent isn't blocked while the command runs. ` +
|
||
`(This is forward-looking guidance for FUTURE invocations — do NOT ` +
|
||
`re-run the command that just completed; for stateful operations ` +
|
||
`like deploys, migrations, or git push, that would cause double ` +
|
||
`side effects.) The output of background runs stays inspectable ` +
|
||
`via /tasks (text, any mode) or the on-disk output file; in ` +
|
||
`interactive mode the Background tasks dialog also has a per-entry ` +
|
||
`detail view + live updates.`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Detect standalone or leading `sleep N` patterns that should use Monitor
|
||
* instead. Catches `sleep 5`, `sleep 2.5`, `sleep 2s`,
|
||
* `sleep 5 && check`, `sleep 5; check`, `sleep 5 # wait` — but not sleep
|
||
* inside pipelines, subshells, backgrounded commands, or scripts (those are
|
||
* fine).
|
||
*/
|
||
export function detectBlockedSleepPattern(command: string): string | null {
|
||
// Strip trailing shell comments first; otherwise `sleep 5 # wait` would
|
||
// present `# wait` as the suffix, which `getSleepSequentialSeparator`
|
||
// rejects (only &&/||/;/\n are recognized), letting the foreground sleep
|
||
// bypass the guard. Shell ignores top-level trailing comments, so for the
|
||
// purposes of detection they are equivalent to end-of-command.
|
||
const trimmed = trimTrailingShellComment(command).trim();
|
||
if (!trimmed.startsWith('sleep')) return null;
|
||
const afterSleep = trimmed.slice('sleep'.length);
|
||
if (!afterSleep || !/\s/.test(afterSleep[0]!)) return null;
|
||
|
||
let index = 0;
|
||
while (index < afterSleep.length && /\s/.test(afterSleep[index]!)) {
|
||
index++;
|
||
}
|
||
const durationStart = index;
|
||
while (
|
||
index < afterSleep.length &&
|
||
!/\s/.test(afterSleep[index]!) &&
|
||
![';', '&', '|', '\n'].includes(afterSleep[index]!)
|
||
) {
|
||
index++;
|
||
}
|
||
|
||
const durationToken = afterSleep.slice(durationStart, index);
|
||
const secs = parseSleepDurationToSeconds(durationToken);
|
||
if (secs === null || secs < 2) return null;
|
||
|
||
const suffix = afterSleep.slice(index);
|
||
const separator = getSleepSequentialSeparator(suffix);
|
||
if (separator === null) return null;
|
||
|
||
const rest = separator.rest.trim();
|
||
return rest
|
||
? `sleep ${durationToken} followed by: ${rest}`
|
||
: `standalone sleep ${durationToken}`;
|
||
}
|
||
|
||
function parseSleepDurationToSeconds(token: string): number | null {
|
||
if (!token) return null;
|
||
|
||
let index = 0;
|
||
let seenDigit = false;
|
||
let seenDot = false;
|
||
while (index < token.length) {
|
||
const char = token[index]!;
|
||
if (char >= '0' && char <= '9') {
|
||
seenDigit = true;
|
||
index++;
|
||
continue;
|
||
}
|
||
if (char === '.' && !seenDot) {
|
||
seenDot = true;
|
||
index++;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (!seenDigit) return null;
|
||
const value = Number.parseFloat(token.slice(0, index));
|
||
if (!Number.isFinite(value)) return null;
|
||
|
||
const unit = token.slice(index).toLowerCase();
|
||
switch (unit || 's') {
|
||
case 'ms':
|
||
return value / 1000;
|
||
case 's':
|
||
return value;
|
||
case 'm':
|
||
return value * 60;
|
||
case 'h':
|
||
return value * 60 * 60;
|
||
case 'd':
|
||
return value * 60 * 60 * 24;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getSleepSequentialSeparator(suffix: string): { rest: string } | null {
|
||
let index = 0;
|
||
while (
|
||
index < suffix.length &&
|
||
suffix[index] !== '\n' &&
|
||
/\s/.test(suffix[index]!)
|
||
) {
|
||
index++;
|
||
}
|
||
|
||
const restWithSeparator = suffix.slice(index);
|
||
if (!restWithSeparator) return { rest: '' };
|
||
if (
|
||
restWithSeparator.startsWith('&&') ||
|
||
restWithSeparator.startsWith('||')
|
||
) {
|
||
return { rest: restWithSeparator.slice(2) };
|
||
}
|
||
if (restWithSeparator[0] === ';' || restWithSeparator[0] === '\n') {
|
||
return { rest: restWithSeparator.slice(1) };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function trimTrailingShellComment(command: string): string {
|
||
let inSingleQuote = false;
|
||
let inDoubleQuote = false;
|
||
let inBacktick = false;
|
||
let escapeNext = false;
|
||
let commandSubstitutionDepth = 0;
|
||
|
||
for (let i = 0; i < command.length; i++) {
|
||
const ch = command[i]!;
|
||
|
||
if (inSingleQuote) {
|
||
if (ch === "'") inSingleQuote = false;
|
||
continue;
|
||
}
|
||
|
||
if (inBacktick) {
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === '`') inBacktick = false;
|
||
continue;
|
||
}
|
||
|
||
if (inDoubleQuote) {
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inDoubleQuote = false;
|
||
continue;
|
||
}
|
||
if (ch === '$' && command[i + 1] === '(') {
|
||
commandSubstitutionDepth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === ')' && commandSubstitutionDepth > 0) {
|
||
commandSubstitutionDepth--;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
inSingleQuote = true;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inDoubleQuote = true;
|
||
continue;
|
||
}
|
||
if (ch === '`') {
|
||
inBacktick = true;
|
||
continue;
|
||
}
|
||
if (ch === '$' && command[i + 1] === '(') {
|
||
commandSubstitutionDepth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === ')' && commandSubstitutionDepth > 0) {
|
||
commandSubstitutionDepth--;
|
||
continue;
|
||
}
|
||
if (
|
||
ch === '#' &&
|
||
commandSubstitutionDepth === 0 &&
|
||
(i === 0 || /\s/.test(command[i - 1]!))
|
||
) {
|
||
return command.slice(0, i);
|
||
}
|
||
}
|
||
|
||
return command;
|
||
}
|
||
|
||
function hasTopLevelTrailingBackgroundOperator(command: string): boolean {
|
||
const commentTrimmed = trimTrailingShellComment(command);
|
||
const trimmed = commentTrimmed.trimEnd();
|
||
if (!trimmed.endsWith('&')) return false;
|
||
|
||
const trailingAmpIndex = trimmed.length - 1;
|
||
const previousNonWhitespaceIndex = (() => {
|
||
for (let i = trailingAmpIndex - 1; i >= 0; i--) {
|
||
if (!/\s/.test(trimmed[i]!)) return i;
|
||
}
|
||
return -1;
|
||
})();
|
||
|
||
if (previousNonWhitespaceIndex >= 0) {
|
||
const previous = trimmed[previousNonWhitespaceIndex]!;
|
||
if (previous === '&' || previous === '|' || previous === '\\') {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let backslashCount = 0;
|
||
for (let i = trailingAmpIndex - 1; i >= 0 && trimmed[i] === '\\'; i--) {
|
||
backslashCount++;
|
||
}
|
||
if (backslashCount % 2 === 1) return false;
|
||
|
||
let inSingleQuote = false;
|
||
let inDoubleQuote = false;
|
||
let inBacktick = false;
|
||
let escapeNext = false;
|
||
let commandSubstitutionDepth = 0;
|
||
|
||
for (let i = 0; i <= trailingAmpIndex; i++) {
|
||
const ch = trimmed[i]!;
|
||
|
||
if (inSingleQuote) {
|
||
if (ch === "'") inSingleQuote = false;
|
||
continue;
|
||
}
|
||
|
||
if (inBacktick) {
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === '`') inBacktick = false;
|
||
continue;
|
||
}
|
||
|
||
if (inDoubleQuote) {
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inDoubleQuote = false;
|
||
continue;
|
||
}
|
||
if (ch === '$' && trimmed[i + 1] === '(') {
|
||
commandSubstitutionDepth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === ')' && commandSubstitutionDepth > 0) {
|
||
commandSubstitutionDepth--;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (escapeNext) {
|
||
escapeNext = false;
|
||
continue;
|
||
}
|
||
if (ch === '\\') {
|
||
escapeNext = true;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
inSingleQuote = true;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
inDoubleQuote = true;
|
||
continue;
|
||
}
|
||
if (ch === '`') {
|
||
inBacktick = true;
|
||
continue;
|
||
}
|
||
if (ch === '$' && trimmed[i + 1] === '(') {
|
||
commandSubstitutionDepth++;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (ch === ')' && commandSubstitutionDepth > 0) {
|
||
commandSubstitutionDepth--;
|
||
continue;
|
||
}
|
||
if (i === trailingAmpIndex) {
|
||
return commandSubstitutionDepth === 0;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export interface ShellToolParams {
|
||
command: string;
|
||
is_background: boolean;
|
||
timeout?: number;
|
||
description?: string;
|
||
directory?: string;
|
||
}
|
||
|
||
export class ShellToolInvocation extends BaseToolInvocation<
|
||
ShellToolParams,
|
||
ToolResult
|
||
> {
|
||
constructor(
|
||
private readonly config: Config,
|
||
params: ShellToolParams,
|
||
) {
|
||
super(params);
|
||
}
|
||
|
||
getDescription(): string {
|
||
let description = `${this.params.command}`;
|
||
// append optional [in directory]
|
||
// note description is needed even if validation fails due to absolute path
|
||
if (this.params.directory) {
|
||
description += ` [in ${this.params.directory}]`;
|
||
}
|
||
// append background indicator
|
||
if (this.params.is_background) {
|
||
description += ` [background]`;
|
||
} else if (this.params.timeout) {
|
||
// append timeout for foreground commands
|
||
description += ` [timeout: ${this.params.timeout}ms]`;
|
||
}
|
||
// append optional (description), replacing any line breaks with spaces
|
||
if (this.params.description) {
|
||
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
|
||
}
|
||
return description;
|
||
}
|
||
|
||
/**
|
||
* AST-based permission check for the shell command.
|
||
* - Read-only commands (via AST analysis) → 'allow'
|
||
* - All other commands → 'ask'
|
||
*/
|
||
override async getDefaultPermission(): Promise<PermissionDecision> {
|
||
const command = stripShellWrapper(this.params.command);
|
||
|
||
// AST-based read-only detection
|
||
try {
|
||
const isReadOnly = await isShellCommandReadOnlyAST(command);
|
||
if (isReadOnly) {
|
||
return 'allow';
|
||
}
|
||
} catch (e) {
|
||
debugLogger.warn('AST read-only check failed, falling back to ask:', e);
|
||
}
|
||
|
||
return 'ask';
|
||
}
|
||
|
||
/**
|
||
* Constructs confirmation dialog details for a shell command that needs
|
||
* user approval. For compound commands (e.g. `cd foo && npm run build`),
|
||
* sub-commands that are already allowed (read-only) are excluded from both
|
||
* the displayed root-command list and the suggested permission rules.
|
||
*/
|
||
override async getConfirmationDetails(
|
||
_abortSignal: AbortSignal,
|
||
): Promise<ToolCallConfirmationDetails> {
|
||
const command = stripShellWrapper(this.params.command);
|
||
const pm = this.config.getPermissionManager?.();
|
||
const cwd = this.params.directory || this.config.getTargetDir();
|
||
|
||
// Split compound command and filter out already-allowed (read-only) sub-commands
|
||
const subCommands = splitCommands(command);
|
||
const confirmableSubCommands: string[] = [];
|
||
for (const sub of subCommands) {
|
||
let isReadOnly = false;
|
||
try {
|
||
isReadOnly = await isShellCommandReadOnlyAST(sub);
|
||
} catch {
|
||
// conservative: treat unknown commands as requiring confirmation
|
||
}
|
||
|
||
if (isReadOnly) {
|
||
continue;
|
||
}
|
||
|
||
if (pm) {
|
||
try {
|
||
if ((await pm.isCommandAllowed(sub, cwd)) === 'allow') {
|
||
continue;
|
||
}
|
||
} catch (e) {
|
||
debugLogger.warn('PermissionManager command check failed:', e);
|
||
}
|
||
}
|
||
|
||
confirmableSubCommands.push(sub);
|
||
}
|
||
|
||
// Fallback to all sub-commands if everything was filtered out (shouldn't
|
||
// normally happen since getDefaultPermission already returned 'ask').
|
||
const effectiveSubCommands =
|
||
confirmableSubCommands.length > 0 ? confirmableSubCommands : subCommands;
|
||
|
||
const rootCommands = [
|
||
...new Set(
|
||
effectiveSubCommands
|
||
.map((c) => getCommandRoot(c))
|
||
.filter((c): c is string => !!c),
|
||
),
|
||
];
|
||
|
||
// Extract minimum-scope permission rules only for sub-commands that
|
||
// actually need confirmation.
|
||
let permissionRules: string[] = [];
|
||
try {
|
||
const allRules: string[] = [];
|
||
for (const sub of effectiveSubCommands) {
|
||
const rules = await extractCommandRules(sub);
|
||
allRules.push(...rules);
|
||
}
|
||
permissionRules = [...new Set(allRules)].map((rule) => `Bash(${rule})`);
|
||
} catch (e) {
|
||
debugLogger.warn('Failed to extract command rules:', e);
|
||
}
|
||
|
||
const confirmationDetails: ToolExecuteConfirmationDetails = {
|
||
type: 'exec',
|
||
title: 'Confirm Shell Command',
|
||
command: this.params.command,
|
||
rootCommand: rootCommands.join(', '),
|
||
permissionRules,
|
||
onConfirm: async (
|
||
_outcome: ToolConfirmationOutcome,
|
||
_payload?: ToolConfirmationPayload,
|
||
) => {
|
||
// No-op: persistence is handled by coreToolScheduler via PM rules
|
||
},
|
||
};
|
||
return confirmationDetails;
|
||
}
|
||
|
||
async execute(
|
||
signal: AbortSignal,
|
||
updateOutput?: (output: ToolResultDisplay) => void,
|
||
shellExecutionConfig?: ShellExecutionConfig,
|
||
setPidCallback?: (pid: number) => void,
|
||
): Promise<ToolResult> {
|
||
const strippedCommand = stripShellWrapper(this.params.command);
|
||
|
||
if (signal.aborted) {
|
||
return {
|
||
llmContent: 'Command was cancelled by user before it could start.',
|
||
returnDisplay: 'Command cancelled by user.',
|
||
};
|
||
}
|
||
|
||
if (this.params.is_background) {
|
||
return this.executeBackground(signal, shellExecutionConfig);
|
||
}
|
||
|
||
const effectiveTimeout =
|
||
this.params.timeout ?? DEFAULT_FOREGROUND_TIMEOUT_MS;
|
||
|
||
// Create combined signal with timeout for foreground execution
|
||
let combinedSignal = signal;
|
||
if (effectiveTimeout) {
|
||
const timeoutSignal = AbortSignal.timeout(effectiveTimeout);
|
||
combinedSignal = AbortSignal.any([signal, timeoutSignal]);
|
||
}
|
||
|
||
// Add co-author to git commit commands and Qwen Code attribution to
|
||
// `gh pr create` bodies. Both wrappers are no-ops on commands they
|
||
// don't recognise. Apply to the *trimmed original* (not strippedCommand)
|
||
// so leading env assignments and shell wrappers (`FOO=bar bash -c '...'`)
|
||
// are preserved through to execution; the rewriters operate at the
|
||
// top-level shell layer and become no-ops when the commit hides
|
||
// inside a wrapper.
|
||
const processedCommand = this.addAttributionToPR(
|
||
this.addCoAuthorToGitCommit(this.params.command.trim()),
|
||
);
|
||
const commandToExecute = processedCommand;
|
||
const cwd = this.params.directory || this.config.getTargetDir();
|
||
|
||
// Snapshot HEAD before running so attachCommitAttribution can detect
|
||
// commit creation by HEAD movement instead of trusting the shell
|
||
// exit code (which is unreliable for compound commands).
|
||
//
|
||
// Synchronous capture via `execFileSync`: a fire-and-forget async
|
||
// rev-parse can resolve AFTER a fast-cached `git commit` moves
|
||
// HEAD (real race seen on slow filesystems / heavy contention),
|
||
// leaving preHead === postHead and silently skipping the
|
||
// attribution note. ~10–50ms event-loop block per commit-shaped
|
||
// command, only when `commitCtx.hasCommit` is true.
|
||
//
|
||
// We act on `gitCommitContext` rather than a raw regex so quoted
|
||
// text like `echo "git commit"` doesn't trigger snapshot/notes,
|
||
// and so attribution still runs after a `git commit && cd ..`
|
||
// chain (which would have failed an "any cd anywhere" gate).
|
||
const commitCtx = gitCommitContext(strippedCommand);
|
||
// Capture preHead whenever ANY git commit was attempted in the
|
||
// chain — even non-attributable ones — so the post-command branch
|
||
// can detect HEAD movement and clear stale singleton state.
|
||
// Without this, `cd subdir && git commit` (a real same-repo
|
||
// commit) would skip attribution AND fail to clear pending
|
||
// attributions, leaking them into the next foreground commit.
|
||
const preHead: string | null = commitCtx.hasCommit
|
||
? this.getGitHeadSync(cwd)
|
||
: null;
|
||
|
||
let cumulativeOutput: string | AnsiOutput = '';
|
||
let lastUpdateTime = Date.now();
|
||
let isBinaryStream = false;
|
||
let totalLines = 0;
|
||
let totalBytes = 0;
|
||
|
||
const { result: resultPromise, pid } = await ShellExecutionService.execute(
|
||
commandToExecute,
|
||
cwd,
|
||
(event: ShellOutputEvent) => {
|
||
let shouldUpdate = false;
|
||
|
||
switch (event.type) {
|
||
case 'data':
|
||
if (isBinaryStream) break;
|
||
cumulativeOutput = event.chunk;
|
||
// Stats are only consumed by the ANSI-output branch below,
|
||
// so skip the per-chunk accounting for plain string chunks.
|
||
if (Array.isArray(event.chunk)) {
|
||
totalLines = event.chunk.length;
|
||
totalBytes = event.chunk.reduce(
|
||
(sum, line) =>
|
||
sum +
|
||
line.reduce(
|
||
(ls, token) => ls + Buffer.byteLength(token.text, 'utf-8'),
|
||
0,
|
||
),
|
||
0,
|
||
);
|
||
}
|
||
shouldUpdate = true;
|
||
break;
|
||
case 'binary_detected':
|
||
isBinaryStream = true;
|
||
cumulativeOutput = '[Binary output detected. Halting stream...]';
|
||
shouldUpdate = true;
|
||
break;
|
||
case 'binary_progress':
|
||
isBinaryStream = true;
|
||
cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage(
|
||
event.bytesReceived,
|
||
)} received]`;
|
||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||
shouldUpdate = true;
|
||
}
|
||
break;
|
||
default: {
|
||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||
}
|
||
}
|
||
|
||
if (shouldUpdate && updateOutput) {
|
||
if (typeof cumulativeOutput === 'string') {
|
||
updateOutput(cumulativeOutput);
|
||
} else {
|
||
updateOutput({
|
||
ansiOutput: cumulativeOutput,
|
||
totalLines,
|
||
totalBytes,
|
||
// Only include timeout when user explicitly set it
|
||
...(this.params.timeout != null && {
|
||
timeoutMs: this.params.timeout,
|
||
}),
|
||
});
|
||
}
|
||
lastUpdateTime = Date.now();
|
||
}
|
||
},
|
||
combinedSignal,
|
||
this.config.getShouldUseNodePtyShell(),
|
||
shellExecutionConfig ?? {},
|
||
);
|
||
|
||
if (pid && setPidCallback) {
|
||
setPidCallback(pid);
|
||
}
|
||
|
||
// Bracket the spawn → settle wall-clock so the result builder below
|
||
// can decide whether to append the long-run advisory. Captured AFTER
|
||
// `await ShellExecutionService.execute(...)` returns its handle so
|
||
// pre-spawn setup (PTY dynamic import via `getPty()`, ~50–200ms on
|
||
// first call) is excluded — the elapsed should reflect the
|
||
// command's actual runtime, not the tool call's total wall time.
|
||
// The `pid` set above confirms the process has been spawned by this
|
||
// point, so subtraction below is true post-spawn-to-settle.
|
||
//
|
||
// `performance.now()` (monotonic high-res, ms-precision) instead of
|
||
// `Date.now()` so NTP corrections / VM clock drift between capture
|
||
// and read can't make `elapsedMs` go negative (which would silently
|
||
// skip the hint with no observable failure). Returned origin is
|
||
// arbitrary but consistent across the two reads — only the
|
||
// difference matters here.
|
||
const executionStartTime = performance.now();
|
||
|
||
const result = await resultPromise;
|
||
|
||
let llmContent = '';
|
||
if (result.aborted) {
|
||
// Check if it was a timeout or user cancellation
|
||
const wasTimeout =
|
||
effectiveTimeout && combinedSignal.aborted && !signal.aborted;
|
||
|
||
if (wasTimeout) {
|
||
llmContent = `Command timed out after ${effectiveTimeout}ms before it could complete.`;
|
||
if (result.output.trim()) {
|
||
llmContent += ` Below is the output before it timed out:\n${result.output}`;
|
||
} else {
|
||
llmContent += ' There was no output before it timed out.';
|
||
}
|
||
} else {
|
||
llmContent = 'Command was cancelled by user before it could complete.';
|
||
if (result.output.trim()) {
|
||
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
|
||
} else {
|
||
llmContent += ' There was no output before it was cancelled.';
|
||
}
|
||
}
|
||
} else {
|
||
// Create a formatted error string for display, replacing the wrapper command
|
||
// with the user-facing command.
|
||
const finalError = result.error
|
||
? result.error.message.replace(commandToExecute, this.params.command)
|
||
: '(none)';
|
||
|
||
llmContent = [
|
||
`Command: ${this.params.command}`,
|
||
`Directory: ${this.params.directory || '(root)'}`,
|
||
`Output: ${result.output || '(empty)'}`,
|
||
`Error: ${finalError}`, // Use the cleaned error string.
|
||
`Exit Code: ${result.exitCode ?? '(none)'}`,
|
||
`Signal: ${result.signal ?? '(none)'}`,
|
||
`Process Group PGID: ${result.pid ?? '(none)'}`,
|
||
].join('\n');
|
||
|
||
// (Long-run advisory append happens AFTER `truncateToolOutput`
|
||
// below — see the explanation there for why post-truncation.)
|
||
}
|
||
|
||
// Run attribution outside the aborted/non-aborted branch: a
|
||
// `git commit -m "x" && sleep 999` chain can move HEAD and then
|
||
// time out, leaving the new commit without its attribution note
|
||
// while the stale per-file attribution stays around for a later
|
||
// unrelated commit. attachCommitAttribution already gates on HEAD
|
||
// movement, so it's a no-op when no commit was actually created.
|
||
let attributionWarning: string | null = null;
|
||
if (commitCtx.attributableInCwd) {
|
||
// `git commit --amend` rewrites HEAD in place, so the diff
|
||
// `HEAD~1..HEAD` would span the entire amended commit (parent →
|
||
// amended), not just what this amend changed. Detect the flag
|
||
// so getCommittedFileInfo can switch to `HEAD@{1}..HEAD` and
|
||
// attribute only the actual amend delta.
|
||
const isAmend = isAmendCommit(strippedCommand);
|
||
attributionWarning = await this.attachCommitAttribution(
|
||
cwd,
|
||
preHead,
|
||
isAmend,
|
||
);
|
||
}
|
||
// Intentionally NO `else if (commitCtx.hasCommit)` cleanup branch:
|
||
// commands that match `hasCommit` but not `attributableInCwd`
|
||
// (e.g. `cd /abs/path/to/this/repo && git commit`, `git -C . commit`)
|
||
// can land a commit in our cwd, but we don't know which files were
|
||
// staged — the user may have done a partial `git add A` and left
|
||
// unstaged AI edits to B and C pending. A wholesale
|
||
// `clearAttributions(true)` here would silently lose B and C even
|
||
// though they weren't committed. Leave the singleton alone; the
|
||
// next attributable commit's `attachCommitAttribution` will do a
|
||
// proper partial clear via `clearAttributedFiles`.
|
||
|
||
// Decide whether to emit the long-run advisory. Conditions:
|
||
// - Process completed under its own steam (no AbortSignal
|
||
// trigger, no external signal). Specifically:
|
||
// * Suppressed on aborted (`result.aborted: true`) — covers
|
||
// the `if (result.aborted)` arm above (timeout / user-
|
||
// cancel). Their own messaging is enough; a "should have
|
||
// been background" reminder when the agent already knows
|
||
// the command didn't complete is noise.
|
||
// * Suppressed on external signal kills (`result.signal !=
|
||
// null` with `aborted: false`, e.g. SIGTERM from container
|
||
// shutdown, k8s eviction, OOM killer, sibling reaping the
|
||
// process group). `shellExecutionService` only sets
|
||
// `aborted` when the AbortSignal we passed was triggered,
|
||
// so external signals fall through to the non-aborted
|
||
// branch — same rationale as timeout.
|
||
// - Wall-clock duration ≥ threshold. Measured spawn → resultPromise
|
||
// settle, intentionally BEFORE the post-processing block below
|
||
// (truncation I/O, output-file write). The hint reports how long
|
||
// the COMMAND blocked the agent, not how long the tool call
|
||
// spent including post-processing — that's the number the agent
|
||
// should be reasoning about when deciding whether to background
|
||
// next time. Truncation time is bounded by the temp-dir backend
|
||
// and isn't representative of the command's actual wait.
|
||
// Fires on both successful and naturally-failed completions since
|
||
// the advice ("next time, background it") is the same in both.
|
||
const elapsedMs = performance.now() - executionStartTime;
|
||
const longRunThreshold = longRunThresholdFor(effectiveTimeout);
|
||
const shouldAppendLongRunHint =
|
||
!result.aborted &&
|
||
result.signal === null &&
|
||
elapsedMs >= longRunThreshold;
|
||
// Observability: the hint decision is otherwise invisible. If a
|
||
// user reports "my 65s command didn't get the hint" or "5s command
|
||
// got the hint", the debug log shows which suppression branch fired
|
||
// (aborted / signal / under-threshold) plus the actual elapsed and
|
||
// computed threshold. No PII — just timing + result flags.
|
||
debugLogger.debug(
|
||
`long-run hint: elapsed=${Math.round(elapsedMs)}ms threshold=${longRunThreshold}ms ` +
|
||
`aborted=${result.aborted} signal=${result.signal} → ${shouldAppendLongRunHint ? 'fire' : 'suppress'}`,
|
||
);
|
||
|
||
// returnDisplayMessage build order — chronologically:
|
||
// 1. Initial value: in debug mode, snapshot of pre-truncation
|
||
// `llmContent`; in non-debug mode, terse output-or-status.
|
||
// 2. Truncation block (below) appends `Output too long and was
|
||
// saved to: <path>` if truncation fired (BOTH modes).
|
||
// 3. Long-run hint append (further below) appends the hint
|
||
// itself with append-style re-sync (BOTH modes), so the user
|
||
// sees the same advisory the agent does — otherwise the
|
||
// agent would suddenly suggest `is_background: true` with no
|
||
// visible trigger in the TUI.
|
||
// The pre-existing debug snapshot is captured here (pre-truncation,
|
||
// pre-hint); both subsequent steps APPEND to it rather than
|
||
// replacing, so all information accumulates rather than being lost
|
||
// when later steps fire.
|
||
let returnDisplayMessage = '';
|
||
if (this.config.getDebugMode()) {
|
||
returnDisplayMessage = llmContent;
|
||
} else {
|
||
if (result.output.trim()) {
|
||
returnDisplayMessage = result.output;
|
||
} else {
|
||
if (result.aborted) {
|
||
// Check if it was a timeout or user cancellation
|
||
const wasTimeout =
|
||
effectiveTimeout && combinedSignal.aborted && !signal.aborted;
|
||
|
||
returnDisplayMessage = wasTimeout
|
||
? `Command timed out after ${effectiveTimeout}ms.`
|
||
: 'Command cancelled by user.';
|
||
} else if (result.signal) {
|
||
returnDisplayMessage = `Command terminated by signal: ${result.signal}`;
|
||
} else if (result.error) {
|
||
returnDisplayMessage = `Command failed: ${getErrorMessage(
|
||
result.error,
|
||
)}`;
|
||
} else if (result.exitCode !== null && result.exitCode !== 0) {
|
||
returnDisplayMessage = `Command exited with code: ${result.exitCode}`;
|
||
}
|
||
// If output is empty and command succeeded (code 0, no error/signal/abort),
|
||
// returnDisplayMessage will remain empty, which is fine.
|
||
}
|
||
}
|
||
|
||
// Truncate large output and save full content to a temp file.
|
||
if (typeof llmContent === 'string') {
|
||
const truncatedResult = await truncateToolOutput(
|
||
this.config,
|
||
ShellTool.Name,
|
||
llmContent,
|
||
);
|
||
|
||
if (truncatedResult.outputFile) {
|
||
llmContent = truncatedResult.content;
|
||
returnDisplayMessage +=
|
||
(returnDisplayMessage ? '\n' : '') +
|
||
`Output too long and was saved to: ${truncatedResult.outputFile}`;
|
||
}
|
||
}
|
||
|
||
// Append the long-run advisory AFTER truncation so the hint isn't
|
||
// wrapped in `truncateToolOutput`'s "Truncated part of the output"
|
||
// header (which the LLM might misread as part of the command's own
|
||
// output). The hint is process metadata about the command, not
|
||
// command output, so it belongs outside the truncation envelope.
|
||
const longRunHint = shouldAppendLongRunHint
|
||
? buildLongRunningForegroundHint(elapsedMs)
|
||
: null;
|
||
if (longRunHint) {
|
||
if (typeof llmContent === 'string') {
|
||
llmContent += `\n\n${longRunHint}`;
|
||
// Surface the hint in the user-facing TUI too — the user is
|
||
// the one waiting for long commands and benefits from the
|
||
// same "consider backgrounding next time" cue the agent sees.
|
||
// Append (not replace) in BOTH modes so the truncation marker
|
||
// line ("Output too long and was saved to: ...") and any
|
||
// pre-existing returnDisplayMessage content (debug snapshot,
|
||
// status line, command output) are preserved.
|
||
returnDisplayMessage +=
|
||
(returnDisplayMessage ? '\n\n' : '') + longRunHint;
|
||
}
|
||
// else: llmContent is a structured `Part[]` / `Part` rather than
|
||
// a plain string. Today shell.ts only emits string llmContent,
|
||
// but the type union allows structured content. If a future
|
||
// refactor changes that, the hint silently disappears here. We
|
||
// accept that risk for now — the alternative (encoding the hint
|
||
// as a Part) would require deciding on a rendering convention,
|
||
// and structured llmContent isn't on the roadmap. Revisit if
|
||
// someone adds a non-string return path.
|
||
}
|
||
|
||
// Surface AI-attribution failures (note exec failure, payload too
|
||
// large, diff-analysis exception, shallow clone, etc.) on the tool
|
||
// result so the user knows their commit succeeded but the per-file
|
||
// git note didn't land. Without this, the only signal is a
|
||
// QWEN_DEBUG_LOG_FILE entry the user has likely never set up.
|
||
// Appended to BOTH llmContent (so the agent can react / report) and
|
||
// returnDisplayMessage (so the human sees it in the TUI). Skipped
|
||
// when null (intentional skips like a bare `git commit` with no
|
||
// tracked AI edits don't need user-visible feedback).
|
||
if (attributionWarning) {
|
||
if (typeof llmContent === 'string') {
|
||
llmContent += `\n\n${attributionWarning}`;
|
||
}
|
||
returnDisplayMessage +=
|
||
(returnDisplayMessage ? '\n\n' : '') + attributionWarning;
|
||
}
|
||
|
||
// When `result.error` is set, `coreToolScheduler` builds the
|
||
// model-facing functionResponse from `error.message`, NOT from
|
||
// `llmContent` (see `convertToFunctionResponse` and the error
|
||
// branch in scheduler's success/error split). So if a long
|
||
// command hits this path the hint we appended to llmContent above
|
||
// would be silently dropped before reaching the agent. Append the
|
||
// hint to error.message too so the advisory survives whichever
|
||
// branch the scheduler takes.
|
||
//
|
||
// Note on reach: `ShellExecutionResult.error` is reserved for
|
||
// SPAWN / setup failures (per the field's doc comment in
|
||
// shellExecutionService.ts); non-zero exits leave it null. Real
|
||
// spawn failures (ENOENT, permission denied) typically resolve in
|
||
// <1s, so the elapsed >= threshold + spawn-error combination is
|
||
// rare. The preservation is here for the slow-spawn edge cases
|
||
// (PTY init dragging, remote-fs exec syscalls, security scanners
|
||
// interposing) where the rare path could still trigger and the
|
||
// hint would otherwise vanish.
|
||
//
|
||
// Use a `---` divider line so downstream consumers of
|
||
// `error.message` (firePostToolUseFailureHook, telemetry grouping,
|
||
// SIEM alerting, hook-side error parsers) have an unambiguous
|
||
// boundary they can split on rather than getting ~400 chars of
|
||
// advisory text mixed inline with the original error body.
|
||
const executionError = result.error
|
||
? {
|
||
error: {
|
||
message:
|
||
result.error.message +
|
||
(longRunHint ? `\n\n---\n${longRunHint}` : ''),
|
||
type: ToolErrorType.SHELL_EXECUTE_ERROR,
|
||
},
|
||
}
|
||
: {};
|
||
|
||
return {
|
||
llmContent,
|
||
returnDisplay: returnDisplayMessage,
|
||
...executionError,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Background-execution path: spawn the command into a managed registry
|
||
* entry instead of detaching with `&`. Output streams to a per-shell file
|
||
* the agent can `Read`; cancellation flows through the entry's
|
||
* AbortController; the registry's terminal status is set when the process
|
||
* exits. Returns immediately so the agent's turn isn't blocked.
|
||
*/
|
||
private async executeBackground(
|
||
signal: AbortSignal,
|
||
shellExecutionConfig?: ShellExecutionConfig,
|
||
): Promise<ToolResult> {
|
||
const strippedCommand = stripShellWrapper(this.params.command);
|
||
|
||
// The background lifecycle (BackgroundShellRegistry) doesn't run
|
||
// the post-command attribution path — there's no clean place to
|
||
// hook pre/post-HEAD comparison and `git notes` writes between
|
||
// the early `Background shell started` return and the eventual
|
||
// process exit. Allowing `git commit` to slip through would leave
|
||
// the new commit without notes and let stale per-file attribution
|
||
// leak into the next foreground commit. Refuse the request and
|
||
// tell the user to run it foreground.
|
||
//
|
||
// Use the broader `hasCommit` flag rather than `attributableInCwd`:
|
||
// `cd /elsewhere && git commit` should still be refused even
|
||
// though we wouldn't attribute it.
|
||
if (gitCommitContext(strippedCommand).hasCommit) {
|
||
return {
|
||
llmContent:
|
||
'Refusing to run `git commit` in background mode: AI-attribution notes ' +
|
||
'are written by the foreground completion path. Re-run the commit ' +
|
||
'with is_background=false (or split it out of the compound command).',
|
||
returnDisplay:
|
||
'Refused: `git commit` is not supported in background shell mode.',
|
||
};
|
||
}
|
||
// Strip a single bare trailing `&` (the bash background operator) before
|
||
// spawn: bash treats it as background-detach, exits the wrapper
|
||
// immediately, and the real child outlives the wrapper — the registry
|
||
// would settle as `completed` while the shell is still running, and
|
||
// chunked output would land on a closed stream. The managed path is
|
||
// itself the backgrounding mechanism, so the trailing `&` is redundant.
|
||
//
|
||
// Deliberately precise: do not touch `&&` (logical AND), `\&` (escaped
|
||
// literal `&`), or commands without a trailing `&`. Earlier `\s*&+\s*$`
|
||
// was both too greedy (it ate `&&` and `\&`) and a ReDoS hazard on
|
||
// long all-`&` inputs. Plain string checks here are linear and clearer
|
||
// than a lookbehind regex.
|
||
//
|
||
// Operate on the trimmed *original* command so leading env assignments
|
||
// / shell wrappers survive through to execution; ShellExecutionService
|
||
// re-runs the user-approved invocation verbatim.
|
||
const trimmedOriginal = this.params.command.trim();
|
||
const noTrailingAmp = stripTrailingBackgroundAmp(trimmedOriginal);
|
||
if (noTrailingAmp !== trimmedOriginal) {
|
||
debugLogger.warn(
|
||
'Stripped trailing & from background shell command — managed path handles backgrounding',
|
||
);
|
||
}
|
||
const processedCommand = this.addAttributionToPR(
|
||
this.addCoAuthorToGitCommit(noTrailingAmp),
|
||
);
|
||
const cwd = this.params.directory || this.config.getTargetDir();
|
||
|
||
// Output goes under the project temp dir (which `ReadFileTool`
|
||
// auto-allows by default), so the LLM can `Read` the captured output
|
||
// without bouncing off a permission prompt — important because
|
||
// background-agent contexts can't surface interactive prompts.
|
||
const outputDir = path.join(
|
||
this.config.storage.getProjectTempDir(),
|
||
'background-shells',
|
||
this.config.getSessionId(),
|
||
);
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
|
||
const shellId = `bg_${crypto.randomBytes(4).toString('hex')}`;
|
||
const outputPath = path.join(outputDir, `shell-${shellId}.output`);
|
||
|
||
// Background shells are explicitly independent of the current turn:
|
||
// the user pressing Ctrl+C on a turn (which aborts `signal`) should
|
||
// NOT kill a long-running dev server / watcher they intentionally
|
||
// backgrounded. Cancellation flows only through the entry's own
|
||
// AbortController, driven by future `task_stop` integration (#3471).
|
||
// The `signal` parameter is still honored for the synchronous early
|
||
// return below (don't even spawn if the agent already aborted), but
|
||
// we deliberately do not forward it.
|
||
const entryAc = new AbortController();
|
||
|
||
const outputStream = fs.createWriteStream(outputPath, { flags: 'w' });
|
||
// Without an 'error' listener, a write failure (disk full, permission
|
||
// change, fs going away) would surface as an uncaught exception and
|
||
// kill the entire CLI session. Log + drop is the sane default — the
|
||
// process keeps running, the registry still settles via resultPromise.
|
||
outputStream.on('error', (err) => {
|
||
debugLogger.warn(
|
||
`background shell ${shellId} output write error: ${err.message}`,
|
||
);
|
||
});
|
||
|
||
const startTime = Date.now();
|
||
const entry: BackgroundShellEntry = {
|
||
shellId,
|
||
command: processedCommand,
|
||
cwd,
|
||
status: 'running',
|
||
startTime,
|
||
outputPath,
|
||
abortController: entryAc,
|
||
};
|
||
|
||
const { result: resultPromise, pid } = await ShellExecutionService.execute(
|
||
processedCommand,
|
||
cwd,
|
||
(event: ShellOutputEvent) => {
|
||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||
// Strip ANSI escape codes (color, cursor-move, clear-screen) before
|
||
// writing — agents read the file as plain text, and dev servers /
|
||
// build tools spam plenty of escape sequences that would render as
|
||
// garbage. Costs ~one regex per chunk; cheap relative to disk I/O.
|
||
outputStream.write(stripAnsi(event.chunk));
|
||
}
|
||
// ANSI array chunks and binary streams are not written to the output
|
||
// file: agents read the file as plain text and binary spam would be
|
||
// unhelpful.
|
||
},
|
||
entryAc.signal,
|
||
// Background shells are non-interactive by design — no terminal to
|
||
// attach a PTY to, no human to type at it. Force the child_process
|
||
// path so we don't pull in node-pty for fire-and-forget commands.
|
||
false,
|
||
shellExecutionConfig ?? {},
|
||
// Stream stdout/stderr through to the output file as chunks arrive.
|
||
// Default child_process mode buffers until exit, which would leave
|
||
// dev-server / watcher output files empty until the process dies.
|
||
{ streamStdout: true },
|
||
);
|
||
|
||
if (pid !== undefined) entry.pid = pid;
|
||
const registry = this.config.getBackgroundShellRegistry();
|
||
registry.register(entry);
|
||
|
||
// Settle in the background — do NOT await here, the agent should be
|
||
// unblocked immediately.
|
||
void resultPromise.then(
|
||
(result) => {
|
||
outputStream.end();
|
||
const endTime = Date.now();
|
||
if (entryAc.signal.aborted) {
|
||
if (registry.get(shellId)?.status === 'running') {
|
||
registry.cancel(shellId, endTime);
|
||
}
|
||
} else if (
|
||
result.error ||
|
||
(result.exitCode !== null && result.exitCode !== 0) ||
|
||
result.signal !== null
|
||
) {
|
||
// Non-zero exit / killed by signal / spawn error all count as failed.
|
||
// Treating them as `completed` would let `/tasks` (and any future
|
||
// model-facing notification) misreport a failed `npm test` or
|
||
// `false` command as a success.
|
||
const reason = result.error
|
||
? result.error.message
|
||
: result.signal !== null
|
||
? `terminated by signal ${result.signal}`
|
||
: `exited with code ${result.exitCode}`;
|
||
registry.fail(shellId, reason, endTime);
|
||
} else {
|
||
registry.complete(shellId, result.exitCode ?? 0, endTime);
|
||
}
|
||
},
|
||
(err) => {
|
||
outputStream.end();
|
||
registry.fail(shellId, getErrorMessage(err), Date.now());
|
||
},
|
||
);
|
||
|
||
const pidLine = pid !== undefined ? `pid: ${pid}\n` : '';
|
||
return {
|
||
llmContent:
|
||
`Background shell started.\n` +
|
||
`id: ${shellId}\n` +
|
||
pidLine +
|
||
`output file: ${outputPath}\n` +
|
||
`To inspect: /tasks (text) or the interactive Background tasks dialog (focus the footer Background tasks pill, then Enter — detail view + live updates). Read the output file directly to view the captured output.`,
|
||
returnDisplay: `Background shell ${shellId} started${pid !== undefined ? ` (pid ${pid})` : ''}.`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Count the commits between `preHead` (exclusive) and `HEAD`
|
||
* (inclusive). Returns 0 if either side is unreadable. Goes through
|
||
* `child_process.execFile` with argv to stay independent of the
|
||
* mockable `ShellExecutionService`.
|
||
*/
|
||
private async countCommitsAfter(
|
||
cwd: string,
|
||
preHead: string,
|
||
): Promise<number> {
|
||
return this.runGitCount(cwd, ['rev-list', '--count', `${preHead}..HEAD`]);
|
||
}
|
||
|
||
/**
|
||
* Count commits reachable from HEAD when the repo had no prior
|
||
* HEAD before the user's command — i.e. the very first commit (or
|
||
* compound `init && commit && commit ...`). Without this fallback
|
||
* the multi-commit guard would be skipped on a brand-new repo and
|
||
* mis-attribute combined data to the final commit.
|
||
*/
|
||
private async countCommitsFromRoot(cwd: string): Promise<number> {
|
||
return this.runGitCount(cwd, ['rev-list', '--count', 'HEAD']);
|
||
}
|
||
|
||
/** Shared helper for the two `rev-list --count` invocations. */
|
||
private async runGitCount(cwd: string, args: string[]): Promise<number> {
|
||
return new Promise((resolve) => {
|
||
const child = childProcess.execFile(
|
||
'git',
|
||
args,
|
||
{ cwd, timeout: 2000 },
|
||
(error, stdout) => {
|
||
if (error) {
|
||
resolve(0);
|
||
return;
|
||
}
|
||
const n = parseInt(String(stdout).trim(), 10);
|
||
resolve(Number.isFinite(n) && n > 0 ? n : 0);
|
||
},
|
||
);
|
||
child.on('error', () => {});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Read the current HEAD SHA, or null if unavailable (no commits
|
||
* yet, not a git repo, or git failed). Used to detect whether a
|
||
* `git commit` actually created a new commit, independent of the
|
||
* shell's exit code. Goes through `child_process.execFile` rather
|
||
* than {@link ShellExecutionService} so the lookup is unaffected
|
||
* by test mocks of the shell service and stays well clear of any
|
||
* user-supplied shell wrapper.
|
||
*/
|
||
private async getGitHead(cwd: string): Promise<string | null> {
|
||
return new Promise((resolve) => {
|
||
const child = childProcess.execFile(
|
||
'git',
|
||
['rev-parse', 'HEAD'],
|
||
{ cwd, timeout: 2000 },
|
||
(error, stdout) => {
|
||
if (error) {
|
||
resolve(null);
|
||
return;
|
||
}
|
||
const sha = String(stdout).trim();
|
||
resolve(sha.length > 0 ? sha : null);
|
||
},
|
||
);
|
||
// Suppress unhandled-error events from the child stream (e.g. ENOENT
|
||
// when git is missing); the callback still receives the error.
|
||
child.on('error', () => {});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Synchronous companion to {@link getGitHead}. Captured BEFORE the
|
||
* user's shell command spawns so a fast `git commit` (hot-cached,
|
||
* no hooks) cannot move HEAD before our async rev-parse has a chance
|
||
* to read it — a real race seen on slow filesystems / heavy contention
|
||
* where preHead would otherwise resolve to the new SHA, postHead would
|
||
* match, and `attachCommitAttribution` would silently skip writing the
|
||
* attribution note even though the commit succeeded.
|
||
*
|
||
* Worst case is ~10–50 ms of event-loop block per commit-shaped shell
|
||
* command; acceptable trade for correctness of the post-command HEAD
|
||
* comparison.
|
||
*/
|
||
private getGitHeadSync(cwd: string): string | null {
|
||
try {
|
||
const stdout = childProcess.execFileSync('git', ['rev-parse', 'HEAD'], {
|
||
cwd,
|
||
timeout: 2000,
|
||
// Discard stderr noise (e.g. "fatal: not a git repository") —
|
||
// the catch-or-empty-output path already covers failure.
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
});
|
||
const sha = String(stdout).trim();
|
||
return sha.length > 0 ? sha : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* After a successful git commit, attach per-file AI attribution metadata
|
||
* as git notes. Analyzes staged files via `git diff` to calculate real
|
||
* AI vs human contribution percentages.
|
||
*
|
||
* Detects commit creation by HEAD movement, not by shell exit code:
|
||
* for compound commands like `git commit -m "x" && npm test`, the
|
||
* commit can succeed and a later step can fail. Gating on `exitCode
|
||
* !== 0` would skip attribution for the successful commit, so we
|
||
* compare pre- and post-command HEAD instead.
|
||
*
|
||
* Respects the gitCoAuthor.commit setting: if the user disables commit
|
||
* attribution, the per-file note is skipped too (same toggle governs
|
||
* the Co-authored-by trailer and the git-notes payload).
|
||
*/
|
||
private async attachCommitAttribution(
|
||
cwd: string,
|
||
preHead: string | null,
|
||
isAmend: boolean,
|
||
): Promise<string | null> {
|
||
// Returns a one-line warning suitable for appending to the tool's
|
||
// returnDisplay when a write that the user could plausibly fix
|
||
// (note exec failure, payload too large, exception during diff
|
||
// analysis) drops the AI-attribution note. Returns null when the
|
||
// skip is intentional / inherent to the situation (no commit
|
||
// landed, multi-commit chain, attribution toggle off, no tracked
|
||
// edits) — those don't need user-visible feedback.
|
||
// Caller (`execute`) gates this with `commitCtx.attributableInCwd`,
|
||
// so we don't re-parse the command here. Re-parsing would be dead
|
||
// work and a maintenance trap — if the two checks ever drifted,
|
||
// trailer injection and git-notes writes could diverge silently.
|
||
|
||
const postHead = await this.getGitHead(cwd);
|
||
const commitCreated = postHead !== null && postHead !== preHead;
|
||
const attributionService = CommitAttributionService.getInstance();
|
||
|
||
if (!commitCreated) {
|
||
// HEAD didn't move in this cwd. Possible causes:
|
||
// 1. Commit failed (hook rejected, nothing staged, etc.)
|
||
// 2. User did `git commit && git reset HEAD~1` — HEAD reverted
|
||
// 3. Submodule case (`cd submodule && git commit`) — the inner
|
||
// repo's HEAD moved, ours didn't
|
||
// We can't tell these apart reliably from here. Dropping the
|
||
// per-file attributions on (1)/(2) is fine in isolation, but on
|
||
// (3) we'd silently lose the user's outer-repo edits even though
|
||
// none of them were committed. Leave attributions intact instead:
|
||
// a later successful commit will overwrite the counters and the
|
||
// accumulated aiContribution still represents real AI work.
|
||
return null;
|
||
}
|
||
|
||
// Refuse to attribute when a single shell command produced more
|
||
// than one commit (e.g. `git commit -m a && git commit -m b`).
|
||
// Our singleton has no way to partition the per-file AI
|
||
// contribution across the individual commits, so attaching the
|
||
// combined note to HEAD would mis-attribute earlier commits'
|
||
// changes to the last one. Snapshot prompt counters and bail.
|
||
//
|
||
// For a brand-new repo (preHead === null), use `git rev-list
|
||
// --count HEAD` so the very first compound `init && commit a &&
|
||
// commit b` chain still gets caught.
|
||
const commitCount =
|
||
preHead !== null
|
||
? await this.countCommitsAfter(cwd, preHead)
|
||
: await this.countCommitsFromRoot(cwd);
|
||
// commitCreated has already established that HEAD moved, so we
|
||
// expect exactly 1 commit. Anything else is suspicious:
|
||
// - >1: actual multi-commit chain we can't partition
|
||
// - 0: rev-list errored / timed out — could not verify, so
|
||
// we'd otherwise silently attribute as a single commit even
|
||
// though the count is unknown
|
||
// Bail in either case.
|
||
if (commitCount !== 1) {
|
||
const reason =
|
||
commitCount === 0
|
||
? 'commit count unavailable (rev-list failed) ' +
|
||
'after HEAD moved — refusing to assume single commit'
|
||
: `multi-commit shell command (${commitCount} commits since ` +
|
||
`${preHead ? preHead.slice(0, 12) : 'repo root'})`;
|
||
debugLogger.warn(`Refusing AI attribution: ${reason}.`);
|
||
// Snapshot the prompt counter but do NOT clear per-file
|
||
// attributions: in a `commit a && commit b` chain, the user
|
||
// may have unstaged AI edits to files that appeared in NEITHER
|
||
// commit. Wholesale-clearing here would erase those even
|
||
// though the rest of the flow is built to preserve unstaged
|
||
// entries across partial commits.
|
||
attributionService.noteCommitWithoutClearing();
|
||
return null;
|
||
}
|
||
|
||
// A new commit landed. Even when no per-file attribution was
|
||
// tracked (rare but possible — e.g. user committed external
|
||
// changes), we still need to snapshot the prompt counters as
|
||
// "at last commit" so a later `gh pr create` doesn't report an
|
||
// inflated N-shotted count spanning multiple commits.
|
||
if (!attributionService.hasAttributions()) {
|
||
attributionService.noteCommitWithoutClearing();
|
||
return null;
|
||
}
|
||
|
||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||
if (!gitCoAuthorSettings.commit) {
|
||
// Commit succeeded but attribution is disabled. Snapshot the
|
||
// prompt counters as "at last commit" but leave per-file
|
||
// attributions alone — a wholesale clear here would lose the
|
||
// user's pending unstaged AI work just because they toggled
|
||
// attribution off, which is a much harsher contract than the
|
||
// toggle name suggests.
|
||
attributionService.noteCommitWithoutClearing();
|
||
return null;
|
||
}
|
||
|
||
let committedAbsolutePaths: Set<string> | null = null;
|
||
let warning: string | null = null;
|
||
try {
|
||
// Analyze the just-committed files by diffing HEAD against its parent.
|
||
// The commit already happened, so we diff HEAD~1..HEAD instead of --cached.
|
||
const stagedInfo = await this.getCommittedFileInfo(cwd, isAmend);
|
||
|
||
// null = analysis failed (shallow clone, --amend without reflog,
|
||
// partial diff failure, etc.). Leave `committedAbsolutePaths`
|
||
// null so the finally block falls back to a full clear and we
|
||
// don't leak stale per-file attributions into the next commit.
|
||
// Skip the note write entirely — emitting a structurally valid
|
||
// but factually wrong all-zero note is worse than no note.
|
||
if (stagedInfo === null) {
|
||
warning =
|
||
'AI attribution note skipped: could not analyze the commit ' +
|
||
'diff (shallow clone, missing reflog for --amend, or partial ' +
|
||
'`git diff` failure). Co-authored-by trailer is unaffected.';
|
||
return warning; // finally still runs for cleanup
|
||
}
|
||
|
||
// Pass the actual model name (e.g. `qwen3-coder-plus`) rather than the
|
||
// co-author display label so the note's `generator` field reflects
|
||
// which model produced the changes — and so generateNotePayload's
|
||
// sanitizeModelName() actually has the codename it's meant to scrub.
|
||
// The base directory must be the git repo root: getCommittedFileInfo
|
||
// returns paths relative to `git rev-parse --show-toplevel`, and any
|
||
// mismatch here would cause path.relative to produce `../...` keys
|
||
// that never match in the AI-attribution lookup.
|
||
const baseDir = stagedInfo.repoRoot ?? this.config.getTargetDir();
|
||
|
||
// Capture the absolute paths actually included in this commit so
|
||
// the finally block can do a partial clear: files the AI edited
|
||
// but the user didn't `git add` should still be tracked for a
|
||
// later commit.
|
||
//
|
||
// Match against the canonical keys already stored in
|
||
// `fileAttributions` (recordEdit canonicalises every component
|
||
// via realpathSync) rather than re-resolving each diff path on
|
||
// the fly. Re-resolving fails for deleted files (realpathSync
|
||
// throws on a missing leaf) and for files behind intermediate
|
||
// symlinked directories (path.resolve only canonicalises the
|
||
// base) — both cases produced cleanup keys that didn't match
|
||
// the stored canonical keys, leaking stale per-file attribution
|
||
// into subsequent commits.
|
||
let canonicalBase: string;
|
||
try {
|
||
canonicalBase = fs.realpathSync(baseDir);
|
||
} catch {
|
||
canonicalBase = baseDir;
|
||
}
|
||
committedAbsolutePaths = attributionService.matchCommittedFiles(
|
||
stagedInfo.files,
|
||
canonicalBase,
|
||
);
|
||
|
||
// No file in this commit was AI-touched in the current session.
|
||
// Writing a note anyway would emit an all-zero "0% AI" payload
|
||
// attached to a commit that legitimately had no AI involvement
|
||
// — actively misleading. Skip the note; the partial clear in
|
||
// the finally block is a no-op (empty set) so unrelated pending
|
||
// attributions stay tracked for a later commit.
|
||
if (committedAbsolutePaths.size === 0) {
|
||
return null;
|
||
}
|
||
|
||
const note = attributionService.generateNotePayload(
|
||
stagedInfo,
|
||
baseDir,
|
||
this.config.getModel(),
|
||
);
|
||
const notesCommand = buildGitNotesCommand(note);
|
||
|
||
if (!notesCommand) {
|
||
debugLogger.warn(
|
||
'AI attribution note too large, skipping git notes attachment',
|
||
);
|
||
warning =
|
||
'AI attribution note skipped: payload exceeded the 30 KB ' +
|
||
'size cap (large generated-file exclusion list?). ' +
|
||
'Co-authored-by trailer is unaffected.';
|
||
return warning;
|
||
}
|
||
|
||
// Use execFile with argv (rather than ShellExecutionService) so the
|
||
// JSON note isn't subjected to shell quoting at all — important on
|
||
// Windows where the bash-style escape used previously is invalid
|
||
// for cmd.exe / PowerShell. 5s timeout keeps a wedged repo from
|
||
// stalling the user-visible turn.
|
||
const { exitCode, output } = await new Promise<{
|
||
exitCode: number | null;
|
||
output: string;
|
||
}>((resolve) => {
|
||
const child = childProcess.execFile(
|
||
notesCommand.command,
|
||
notesCommand.args,
|
||
{ cwd, timeout: 5000 },
|
||
(error, stdout, stderr) => {
|
||
const merged = (stdout || '') + (stderr || '');
|
||
if (error) {
|
||
const code =
|
||
typeof (error as NodeJS.ErrnoException).code === 'number'
|
||
? ((error as NodeJS.ErrnoException).code as unknown as number)
|
||
: null;
|
||
resolve({ exitCode: code ?? 1, output: merged });
|
||
} else {
|
||
resolve({ exitCode: 0, output: merged });
|
||
}
|
||
},
|
||
);
|
||
child.on('error', () => {});
|
||
});
|
||
|
||
if (exitCode !== 0) {
|
||
debugLogger.warn(`git notes exited with code ${exitCode}: ${output}`);
|
||
warning =
|
||
`AI attribution note skipped: \`git notes add\` exited ${exitCode}` +
|
||
(output ? ` (${output.trim().slice(0, 120)})` : '') +
|
||
'. Co-authored-by trailer is unaffected.';
|
||
} else {
|
||
debugLogger.debug(
|
||
`Attached AI attribution note: ${note.summary.aiPercent}% AI, ${note.summary.totalFilesTouched} file(s)`,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
debugLogger.warn(
|
||
`Failed to attach AI attribution note: ${getErrorMessage(err)}`,
|
||
);
|
||
warning =
|
||
`AI attribution note skipped: ${getErrorMessage(err)}. ` +
|
||
'Co-authored-by trailer is unaffected.';
|
||
} finally {
|
||
// Partial clear: only drop tracking for the files that actually
|
||
// landed in this commit. Files the AI edited but the user
|
||
// omitted from `git add` stay pending for a later commit.
|
||
// If we never determined the committed set (analysis failure:
|
||
// shallow clone, --amend without reflog, partial diff failure,
|
||
// exception), DO NOT wholesale-clear: that would erase pending
|
||
// AI edits for files the user never staged in this commit. The
|
||
// small risk is stale per-file state for the just-committed
|
||
// file (re-attributed if it appears in a future commit) — much
|
||
// less harmful than losing unrelated unstaged work.
|
||
if (committedAbsolutePaths) {
|
||
attributionService.clearAttributedFiles(committedAbsolutePaths);
|
||
} else {
|
||
attributionService.noteCommitWithoutClearing();
|
||
}
|
||
}
|
||
return warning;
|
||
}
|
||
|
||
/**
|
||
* Get information about files in the most recent commit by diffing
|
||
* HEAD against its parent (HEAD~1).
|
||
*
|
||
* Returns:
|
||
* - A populated `StagedFileInfo` when analysis succeeded.
|
||
* - An empty `StagedFileInfo` when the commit truly has no files
|
||
* (e.g. `--allow-empty`). The caller does a no-op partial clear so
|
||
* pending AI attributions stay tracked for the next real commit.
|
||
* - `null` when analysis itself failed (shallow clone with no parent
|
||
* object, --amend with no reflog, partial diff failure, exception).
|
||
* The caller treats this as "could not determine the committed
|
||
* set" and falls back to a full clear so stale per-file state
|
||
* doesn't leak into a subsequent commit.
|
||
*/
|
||
private async getCommittedFileInfo(
|
||
cwd: string,
|
||
isAmend: boolean,
|
||
): Promise<StagedFileInfo | null> {
|
||
const empty: StagedFileInfo = {
|
||
files: [],
|
||
diffSizes: new Map(),
|
||
deletedFiles: new Set(),
|
||
};
|
||
|
||
// Distinguish a successful git command with no output (e.g.
|
||
// `--allow-empty` -> empty `--name-only` listing) from a failed
|
||
// git command (silenced by ShellExecutionService) so the caller
|
||
// can choose between the empty-commit sentinel and the analysis-
|
||
// failure sentinel. Returning the same `''` for both used to
|
||
// alias `--allow-empty` to a `--name-only` failure, which left
|
||
// pending attributions tracked across the just-committed file
|
||
// and re-attributed it on the next commit.
|
||
const runGit = async (args: string): Promise<string | null> => {
|
||
const handle = await ShellExecutionService.execute(
|
||
`git ${args}`,
|
||
cwd,
|
||
() => {},
|
||
AbortSignal.timeout(5000),
|
||
false,
|
||
{},
|
||
);
|
||
const r = await handle.result;
|
||
return r.exitCode === 0 ? r.output : null;
|
||
};
|
||
|
||
try {
|
||
// The three calls are independent — fan out so we don't pay the
|
||
// spawn latency serially. Same for the three diff calls below
|
||
// once we know which form to use.
|
||
// - `rev-parse --verify HEAD~1`: probe whether the parent OBJECT
|
||
// is locally available (fails in shallow clones where the
|
||
// parent was pruned).
|
||
// - `log -1 --pretty=%P HEAD`: read the parent SHA from HEAD's
|
||
// commit metadata. Works regardless of shallow status because
|
||
// the parent SHA is recorded on the commit itself, not derived
|
||
// by walking. Empty output = HEAD is a true root commit.
|
||
// Non-empty output = HEAD has a parent (whether or not its
|
||
// object is locally available).
|
||
// - `rev-parse --show-toplevel`: capture the repo root.
|
||
//
|
||
// `rev-list --count HEAD` looks tempting as a "is this a root
|
||
// commit?" probe but it returns 1 in a depth-1 shallow clone
|
||
// (only the local object is reachable), aliasing the shallow
|
||
// and root cases. The parent-SHA approach disambiguates them
|
||
// correctly.
|
||
const [hasParentOutput, parentShaOutput, repoRootOutput] =
|
||
await Promise.all([
|
||
runGit('rev-parse --verify HEAD~1'),
|
||
runGit('log -1 --pretty=%P HEAD'),
|
||
runGit('rev-parse --show-toplevel'),
|
||
]);
|
||
// `rev-parse --verify HEAD~1` is allowed to fail (shallow
|
||
// clone, true root commit) — treat null and '' uniformly.
|
||
const hasParent = hasParentOutput !== null && hasParentOutput.length > 0;
|
||
// `log -1 --pretty=%P HEAD` MUST succeed; if git can't read the
|
||
// current HEAD's metadata we have no way to tell shallow apart
|
||
// from a real root commit. Bail.
|
||
if (parentShaOutput === null) {
|
||
debugLogger.warn(
|
||
'getCommittedFileInfo: log -1 --pretty=%P HEAD failed; ' +
|
||
'cannot distinguish shallow clone from true root commit.',
|
||
);
|
||
return null;
|
||
}
|
||
const isTrueRootCommit = parentShaOutput.trim().length === 0;
|
||
// Shallow clone: HEAD has a parent recorded but the object
|
||
// isn't local. Bail rather than over-attribute via --root.
|
||
if (!hasParent && !isTrueRootCommit) {
|
||
debugLogger.warn(
|
||
'getCommittedFileInfo: HEAD~1 unreadable but commit is not the ' +
|
||
'true root (shallow clone?); skipping attribution to avoid ' +
|
||
'attributing the entire commit contents.',
|
||
);
|
||
return null;
|
||
}
|
||
// Capture the repo root so the attribution service can
|
||
// reconcile paths from `git diff` (relative to the toplevel)
|
||
// against absolute paths recorded by the edit/write tools.
|
||
// Using the configured target directory as base would zero out
|
||
// attribution for any file outside it. Tolerate failure (null
|
||
// -> empty string -> caller falls back to targetDir).
|
||
const repoRoot = (repoRootOutput ?? '').trim();
|
||
|
||
// Choose the diff range:
|
||
// - amend: `HEAD@{1}..HEAD` — the actual amend delta. The
|
||
// pre-amend HEAD is in the reflog and points at the original
|
||
// commit; diffing against the *amended* HEAD captures only
|
||
// what changed in this amend operation, not the entire commit
|
||
// contents (which `HEAD~1..HEAD` would falsely include).
|
||
// - has parent: `HEAD~1..HEAD` — standard parent diff.
|
||
// - root commit: `diff-tree --root` against the empty tree.
|
||
let diffArgs: { name: string; status: string; numstat: string };
|
||
if (isAmend) {
|
||
// Verify HEAD@{1} actually exists; reflogs can be GC'd.
|
||
const reflogProbe = await runGit('rev-parse --verify HEAD@{1}');
|
||
const hasReflog = reflogProbe !== null && reflogProbe.length > 0;
|
||
if (!hasReflog) {
|
||
// Without a pre-amend snapshot we can't compute the amend
|
||
// delta; emitting `HEAD~1..HEAD` would over-attribute.
|
||
debugLogger.warn(
|
||
'getCommittedFileInfo: --amend with empty reflog; skipping ' +
|
||
'attribution note (cannot determine amend delta).',
|
||
);
|
||
return null;
|
||
}
|
||
diffArgs = {
|
||
name: 'diff --name-only HEAD@{1} HEAD',
|
||
status: 'diff --name-status HEAD@{1} HEAD',
|
||
numstat: 'diff --numstat HEAD@{1} HEAD',
|
||
};
|
||
} else if (hasParent) {
|
||
diffArgs = {
|
||
name: 'diff --name-only HEAD~1 HEAD',
|
||
status: 'diff --name-status HEAD~1 HEAD',
|
||
numstat: 'diff --numstat HEAD~1 HEAD',
|
||
};
|
||
} else {
|
||
diffArgs = {
|
||
name: 'diff-tree --root --no-commit-id -r --name-only HEAD',
|
||
status: 'diff-tree --root --no-commit-id -r --name-status HEAD',
|
||
numstat: 'diff-tree --root --no-commit-id -r --numstat HEAD',
|
||
};
|
||
}
|
||
const [nameOutput, statusOutput, numstatOutput] = await Promise.all([
|
||
runGit(diffArgs.name),
|
||
runGit(diffArgs.status),
|
||
runGit(diffArgs.numstat),
|
||
]);
|
||
|
||
// ANY of the three diffs failing (null) is an analysis failure,
|
||
// NOT an empty commit. Without this check, a `--name-only` that
|
||
// failed silently used to alias to `--allow-empty`, leaving the
|
||
// just-committed file's tracked AI edit in the singleton and
|
||
// re-attributing it to the next commit.
|
||
if (
|
||
nameOutput === null ||
|
||
statusOutput === null ||
|
||
numstatOutput === null
|
||
) {
|
||
debugLogger.warn(
|
||
'getCommittedFileInfo: one or more diff calls failed; ' +
|
||
'cannot distinguish empty commit from analysis failure.',
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const files = nameOutput
|
||
.split('\n')
|
||
.map((f) => f.trim())
|
||
.filter(Boolean);
|
||
if (files.length === 0) return empty;
|
||
|
||
// Get deleted files
|
||
const deletedFiles = new Set<string>();
|
||
for (const line of statusOutput.split('\n')) {
|
||
if (line.startsWith('D\t')) {
|
||
deletedFiles.add(line.slice(2).trim());
|
||
}
|
||
}
|
||
|
||
// Get diff sizes from numstat output. Bail if `--numstat`
|
||
// returned nothing while `--name-only` succeeded — that's the
|
||
// partial-failure signal for `Promise.all`, and writing a note
|
||
// anyway would force every file's diffSize to 0, then
|
||
// generateNotePayload would clamp aiChars to 0 and emit a
|
||
// structurally valid but factually wrong all-zero attribution.
|
||
const diffSizes = parseNumstat(numstatOutput);
|
||
if (diffSizes.size === 0) {
|
||
debugLogger.warn(
|
||
'getCommittedFileInfo: --numstat returned empty while ' +
|
||
'--name-only listed files; skipping attribution note to ' +
|
||
'avoid emitting all-zero AI percentages.',
|
||
);
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
files,
|
||
diffSizes,
|
||
deletedFiles,
|
||
repoRoot: repoRoot.length > 0 ? repoRoot : undefined,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Append a configured `Co-authored-by:` trailer to `git commit`
|
||
* commands when the commit co-author feature is enabled. No-op for
|
||
* commands that don't carry an inline `-m`/`-am` message (those open
|
||
* an editor, which we don't try to rewrite).
|
||
*/
|
||
private addCoAuthorToGitCommit(command: string): string {
|
||
// Check if commit co-author feature is enabled
|
||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||
|
||
if (!gitCoAuthorSettings.commit) {
|
||
return command;
|
||
}
|
||
|
||
// Same shell-type guard as addAttributionToPR — bash escaping is
|
||
// wrong for cmd/PowerShell. Gating on the active shell rather than
|
||
// the OS platform keeps Windows + Git Bash users (where
|
||
// getShellConfiguration() reports shell:'bash') working.
|
||
if (getShellConfiguration().shell !== 'bash') {
|
||
return command;
|
||
}
|
||
|
||
// Shell-aware detection — a raw regex would falsely match quoted
|
||
// text such as `echo "git commit"` and hand a corrupted command
|
||
// (with the trailer mid-string) back to the executor. The stricter
|
||
// `attributableInCwd` is what we want here: only inject the
|
||
// trailer when we're confident the commit lands in our cwd.
|
||
const segmentRange = findAttributableCommitSegment(command);
|
||
if (!segmentRange) {
|
||
return command;
|
||
}
|
||
|
||
// Handle different git commit patterns:
|
||
// Match -m "message" or -m 'message', including combined flags like -am
|
||
// Use separate patterns to avoid ReDoS (catastrophic backtracking).
|
||
// The regex tolerates `-m"msg"` shorthand (no space) — bash accepts
|
||
// both `-m foo` and `-mfoo`, and we shouldn't silently skip the
|
||
// shorthand form.
|
||
//
|
||
// The regex is scoped to the actual `git commit` segment (not the
|
||
// whole compound command) so a later `git tag -a v1 -m "..."` in
|
||
// the same chain can't be mistaken for the commit message.
|
||
//
|
||
// Pattern breakdown:
|
||
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
|
||
// \s* matches optional whitespace after the flag
|
||
// [^"\\] matches any char except double-quote and backslash
|
||
// \\. matches escape sequences like \" or \\
|
||
// (?:...|...)* matches normal chars or escapes, repeated
|
||
// Match both the short form (`-m`, `-am`, combined short flags)
|
||
// and git's long alias `--message` (with optional `=` separator:
|
||
// `--message="..."`). Inner alternation is non-capturing so the
|
||
// existing `[full, prefix, body]` destructure still applies.
|
||
const FLAG_PREFIX = `(?:-[a-zA-Z]*m|--message)\\s*=?\\s*`;
|
||
const doubleQuotePattern = new RegExp(
|
||
`(${FLAG_PREFIX})"((?:[^"\\\\]|\\\\.)*)"`,
|
||
'g',
|
||
);
|
||
// Bash single quotes can't be escaped, so apostrophes inside a
|
||
// single-quoted message use the close-escape-reopen form `'\''`
|
||
// (e.g. `git commit -m 'don'\''t'`). The inner alternation matches
|
||
// either a non-apostrophe character or that escape sequence as a
|
||
// whole, so the trailer lands at the true end of the body — at the
|
||
// FINAL closing `'` after the user's content — rather than after
|
||
// the first interior apostrophe. Mirrors `bodySinglePattern` in
|
||
// `addAttributionToPR`.
|
||
const singleQuotePattern = new RegExp(
|
||
`(${FLAG_PREFIX})'((?:[^']|'\\\\'')*)'`,
|
||
'g',
|
||
);
|
||
const segment = command.slice(segmentRange.start, segmentRange.end);
|
||
// Git concatenates multiple `-m` values with a blank line, so the
|
||
// co-author trailer has to land in the *last* `-m` value to be
|
||
// recognised by `git interpret-trailers`. matchAll → take the
|
||
// last match (`lastMatchOf` is the shared helper).
|
||
const doubleMatch = lastMatchOf(segment.matchAll(doubleQuotePattern));
|
||
const singleMatch = lastMatchOf(segment.matchAll(singleQuotePattern));
|
||
|
||
// Pick whichever match appears LAST in the segment, regardless of
|
||
// quote style — but reject any candidate that's nested inside the
|
||
// other's range. For `git commit -m "docs mention -m 'flag'"` the
|
||
// single-quoted `-m 'flag'` lives INSIDE the double-quoted real
|
||
// message; without a nesting check the later (inner) `-m` would
|
||
// win and the trailer would be spliced into the body text.
|
||
const matchRange = (m: RegExpMatchArray | null) =>
|
||
m ? { start: m.index ?? 0, end: (m.index ?? 0) + m[0].length } : null;
|
||
const isInside = (
|
||
inner: RegExpMatchArray | null,
|
||
outer: RegExpMatchArray | null,
|
||
): boolean => {
|
||
const i = matchRange(inner);
|
||
const o = matchRange(outer);
|
||
return !!(i && o && i.start >= o.start && i.end <= o.end);
|
||
};
|
||
let match: RegExpMatchArray | null;
|
||
if (doubleMatch && singleMatch) {
|
||
if (isInside(singleMatch, doubleMatch)) {
|
||
match = doubleMatch;
|
||
} else if (isInside(doubleMatch, singleMatch)) {
|
||
match = singleMatch;
|
||
} else {
|
||
match =
|
||
(doubleMatch.index ?? 0) > (singleMatch.index ?? 0)
|
||
? doubleMatch
|
||
: singleMatch;
|
||
}
|
||
} else {
|
||
match = doubleMatch ?? singleMatch;
|
||
}
|
||
const quote = match === doubleMatch ? '"' : "'";
|
||
|
||
// Escape the configured name/email for the surrounding quote
|
||
// style — has to follow the actually-selected match.
|
||
const escape =
|
||
match === doubleMatch
|
||
? escapeForBashDoubleQuote
|
||
: escapeForBashSingleQuote;
|
||
const escapedName = escape(gitCoAuthorSettings.name ?? '');
|
||
const escapedEmail = escape(gitCoAuthorSettings.email ?? '');
|
||
const coAuthor = `\n\nCo-authored-by: ${escapedName} <${escapedEmail}>`;
|
||
|
||
if (match) {
|
||
const [fullMatch, prefix, existingMessage] = match;
|
||
|
||
// Bail on `$(...)` command substitution inside the captured
|
||
// body: our regex's `(?:[^"\\]|\\.)*` body group stops at the
|
||
// first interior `"`, so a heredoc-style
|
||
// `git commit -m "$(cat <<'HEREDOC' ... HEREDOC)"` (which the
|
||
// tool description recommends for multi-line messages) would
|
||
// be matched only up to the first inner `"`, then the trailer
|
||
// would be spliced into the middle of the command
|
||
// substitution and break the shell command. Recognising
|
||
// `$(` is enough — if it's there we can't safely rewrite
|
||
// without a real shell parser.
|
||
if (existingMessage.includes('$(')) {
|
||
return command;
|
||
}
|
||
|
||
const newMessage = existingMessage + coAuthor;
|
||
const replacement = prefix + quote + newMessage + quote;
|
||
|
||
// Splice the modified segment back into the original command,
|
||
// preserving everything outside the commit segment exactly as
|
||
// the caller had it.
|
||
const matchStart = (match.index ?? 0) + segmentRange.start;
|
||
if (matchStart >= segmentRange.start) {
|
||
return (
|
||
command.slice(0, matchStart) +
|
||
replacement +
|
||
command.slice(matchStart + fullMatch.length)
|
||
);
|
||
}
|
||
}
|
||
|
||
// If no -m flag found, the command might open an editor
|
||
// In this case, we can't easily modify it, so return as-is
|
||
return command;
|
||
}
|
||
|
||
/**
|
||
* Detect `gh pr create` commands and append AI attribution text to the
|
||
* PR body. Format: "🤖 Generated with Qwen Code (N-shotted by Qwen-Coder)"
|
||
* when at least one user prompt has been recorded since the last commit;
|
||
* otherwise just "🤖 Generated with Qwen Code".
|
||
*
|
||
* Skipped on Windows: the appended text relies on bash quote-escape
|
||
* conventions (`\$`, `'\''`) that cmd.exe and PowerShell don't honor,
|
||
* so on those shells our injection could either break the user-approved
|
||
* `gh pr create` command or be evaluated as command substitution.
|
||
* Losing PR attribution on Windows is an acceptable trade for safety.
|
||
*/
|
||
private addAttributionToPR(command: string): string {
|
||
// Shell-aware detection — a raw regex would falsely match quoted
|
||
// text such as `echo "gh pr create --body \"x\""` and rewrite a
|
||
// command that wasn't actually creating a PR.
|
||
const ghSegment = findGhPrCreateSegment(command);
|
||
if (!ghSegment) {
|
||
return command;
|
||
}
|
||
|
||
// Gate on shell type rather than OS platform: bash escaping is
|
||
// invalid under cmd/PowerShell but works fine under Windows +
|
||
// Git Bash, which `getShellConfiguration()` reports as `'bash'`.
|
||
if (getShellConfiguration().shell !== 'bash') {
|
||
return command;
|
||
}
|
||
|
||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||
if (!gitCoAuthorSettings.pr) {
|
||
return command;
|
||
}
|
||
|
||
const attributionService = CommitAttributionService.getInstance();
|
||
const shots = attributionService.getPromptsSinceLastCommit();
|
||
const generator = gitCoAuthorSettings.name ?? 'Qwen-Coder';
|
||
|
||
const attribution =
|
||
shots > 0
|
||
? `\n\n🤖 Generated with Qwen Code (${shots}-shotted by ${generator})`
|
||
: `\n\n🤖 Generated with Qwen Code`;
|
||
|
||
// Match both the long form `--body` and the short alias `-b`
|
||
// (documented in `gh pr create --help`), with either space or
|
||
// `=` separator: `--body "..."`, `--body="..."`, `-b "..."`,
|
||
// `-b="..."`. Inner alternation is non-capturing so the existing
|
||
// `[full, prefix, body]` destructure stays intact.
|
||
//
|
||
// Run the regex against just the gh segment, NOT the full
|
||
// command. Otherwise a compound like
|
||
// `curl -b "session=abc" && gh pr create --body "summary"` would
|
||
// have the body regex match `curl`'s `-b` cookie flag and inject
|
||
// attribution into the cookie value, corrupting the curl call.
|
||
const BODY_FLAG = `(?:--body|-b)[\\s=]+`;
|
||
const bodyDoublePattern = new RegExp(
|
||
`(${BODY_FLAG})"((?:[^"\\\\]|\\\\.)*)"`,
|
||
'g',
|
||
);
|
||
// Bash apostrophes inside a single-quoted body use the
|
||
// close-escape-reopen form `'\''`. The inner alternation matches
|
||
// either a non-apostrophe character or that escape sequence as a
|
||
// whole, so the trailer lands at the true end of the body rather
|
||
// than after only the first quoted segment.
|
||
const bodySinglePattern = new RegExp(
|
||
`(${BODY_FLAG})'((?:[^']|'\\\\'')*)'`,
|
||
'g',
|
||
);
|
||
const segment = command.slice(ghSegment.start, ghSegment.end);
|
||
// gh ignores all but the last `--body`/`-b` flag, so the trailer
|
||
// has to land in the final occurrence to actually appear in the PR.
|
||
// matchAll → take the last match for each quote style, then pick
|
||
// whichever sits later in the segment (mirrors addCoAuthorToGitCommit;
|
||
// shares the `lastMatchOf` helper).
|
||
const bodyDoubleMatch = lastMatchOf(segment.matchAll(bodyDoublePattern));
|
||
const bodySingleMatch = lastMatchOf(segment.matchAll(bodySinglePattern));
|
||
// Pick whichever match appears LAST in the segment, regardless of
|
||
// quote style — but reject any candidate that's nested inside the
|
||
// other's range. For `gh pr create --body "docs mention -b 'flag'"`
|
||
// the inner `-b 'flag'` is INSIDE the outer `--body "..."`; without
|
||
// a nesting check the inner (later) `-b` would win and the trailer
|
||
// would be spliced into the body text rather than appended after it.
|
||
const bodyMatchRange = (m: RegExpMatchArray | null) =>
|
||
m ? { start: m.index ?? 0, end: (m.index ?? 0) + m[0].length } : null;
|
||
const bodyIsInside = (
|
||
inner: RegExpMatchArray | null,
|
||
outer: RegExpMatchArray | null,
|
||
): boolean => {
|
||
const i = bodyMatchRange(inner);
|
||
const o = bodyMatchRange(outer);
|
||
return !!(i && o && i.start >= o.start && i.end <= o.end);
|
||
};
|
||
let bodyMatch: RegExpMatchArray | null;
|
||
if (bodyDoubleMatch && bodySingleMatch) {
|
||
if (bodyIsInside(bodySingleMatch, bodyDoubleMatch)) {
|
||
bodyMatch = bodyDoubleMatch;
|
||
} else if (bodyIsInside(bodyDoubleMatch, bodySingleMatch)) {
|
||
bodyMatch = bodySingleMatch;
|
||
} else {
|
||
bodyMatch =
|
||
(bodyDoubleMatch.index ?? 0) > (bodySingleMatch.index ?? 0)
|
||
? bodyDoubleMatch
|
||
: bodySingleMatch;
|
||
}
|
||
} else {
|
||
bodyMatch = bodyDoubleMatch ?? bodySingleMatch;
|
||
}
|
||
const bodyQuote = bodyMatch === bodyDoubleMatch ? '"' : "'";
|
||
|
||
if (bodyMatch) {
|
||
const [fullMatch, prefix, existingBody] = bodyMatch;
|
||
// Same `$(...)` bailout as addCoAuthorToGitCommit: a heredoc-
|
||
// style body (`gh pr create --body "$(cat <<'EOF' ... EOF)"`)
|
||
// contains nested `"` that our regex's `(?:[^"\\]|\\.)*` body
|
||
// group can't span — the match would terminate at the first
|
||
// interior quote and the splice would land mid-substitution,
|
||
// corrupting the user-approved command.
|
||
if (existingBody.includes('$(')) {
|
||
return command;
|
||
}
|
||
// Escape the appended text for the surrounding quote style.
|
||
// Without this, a configured generator name containing `"`, `$`, a
|
||
// backtick, or `'` would either break the user-approved `gh pr
|
||
// create` command or, worse, be interpreted as command substitution.
|
||
const escapedAttribution =
|
||
bodyMatch === bodyDoubleMatch
|
||
? escapeForBashDoubleQuote(attribution)
|
||
: escapeForBashSingleQuote(attribution);
|
||
const newBody = existingBody + escapedAttribution;
|
||
// Splice the modified segment back into the original command,
|
||
// offsetting the in-segment match index by the segment start.
|
||
const idx = (bodyMatch.index ?? 0) + ghSegment.start;
|
||
if (idx >= ghSegment.start) {
|
||
const replacement = prefix + bodyQuote + newBody + bodyQuote;
|
||
return (
|
||
command.slice(0, idx) +
|
||
replacement +
|
||
command.slice(idx + fullMatch.length)
|
||
);
|
||
}
|
||
}
|
||
|
||
return command;
|
||
}
|
||
}
|
||
|
||
function getShellToolDescription(): string {
|
||
const isWindows = os.platform() === 'win32';
|
||
const executionWrapper = isWindows
|
||
? 'cmd.exe /c <command>'
|
||
: 'bash -c <command>';
|
||
const processGroupNote = isWindows
|
||
? ''
|
||
: '\n - Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.';
|
||
|
||
return `Executes a given shell command (as \`${executionWrapper}\`) in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||
|
||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||
|
||
**Usage notes**:
|
||
- The command argument is required.
|
||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes).
|
||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||
|
||
- Avoid using run_shell_command with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||
- File search: Use ${ToolNames.GLOB} (NOT find or ls)
|
||
- Content search: Use ${ToolNames.GREP} (NOT grep or rg)
|
||
- Read files: Use ${ToolNames.READ_FILE} (NOT cat/head/tail)
|
||
- Edit files: Use ${ToolNames.EDIT} (NOT sed/awk)
|
||
- Write files: Use ${ToolNames.WRITE_FILE} (NOT echo >/cat <<EOF)
|
||
- Communication: Output text directly (NOT echo/printf)
|
||
- **Shell argument quoting and special characters**: When passing arguments that contain special characters (parentheses \`()\`, backticks \`\`\`\`, dollar signs \`$\`, backslashes \`\\\`, semicolons \`;\`, pipes \`|\`, angle brackets \`<>\`, ampersands \`&\`, exclamation marks \`!\`, etc.), you MUST ensure they are properly quoted to prevent the shell from misinterpreting them as shell syntax:
|
||
- **Single quotes** \`'...'\` pass everything literally, but cannot contain a literal single quote.
|
||
- **ANSI-C quoting** \`$'...'\` supports escape sequences (e.g. \`\\n\` for newline, \`\\'\` for single quote) and is the safest approach for multi-line strings or strings with single quotes.
|
||
- **Heredoc** is the most robust approach for large, multi-line text with mixed quotes:
|
||
\`\`\`bash
|
||
gh pr create --title "My Title" --body "$(cat <<'HEREDOC'
|
||
Multi-line body with (parentheses), \`backticks\`, and 'single-quotes'.
|
||
HEREDOC
|
||
)"
|
||
\`\`\`
|
||
- NEVER use unescaped single quotes inside single-quoted strings (e.g. \`'it\\'s'\` is wrong; use \`$'it\\'s'\` or \`"it's"\` instead).
|
||
- If unsure, prefer double-quoting arguments and escape inner double-quotes as \`\\"\`.
|
||
- When issuing multiple commands:
|
||
- If the commands are independent and can run in parallel, make multiple run_shell_command tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two run_shell_command tool calls in parallel.
|
||
- If the commands depend on each other and must run sequentially, use a single run_shell_command call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before run_shell_command for git operations, or git add before git commit), run these operations sequentially instead.
|
||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||
<good-example>
|
||
pytest /foo/bar/tests
|
||
</good-example>
|
||
<bad-example>
|
||
cd /foo/bar && pytest tests
|
||
</bad-example>
|
||
|
||
**Background vs Foreground Execution:**
|
||
- You should decide whether commands should run in background or foreground based on their nature:
|
||
- Use background execution (is_background: true) for:
|
||
- Long-running development servers: \`npm run start\`, \`npm run dev\`, \`yarn dev\`, \`bun run start\`
|
||
- Build watchers: \`npm run watch\`, \`webpack --watch\`
|
||
- Database servers: \`mongod\`, \`mysql\`, \`redis-server\`
|
||
- Web servers: \`python -m http.server\`, \`php -S localhost:8000\`
|
||
- Any command expected to run indefinitely until manually stopped
|
||
${processGroupNote}
|
||
- Use foreground execution (is_background: false) for:
|
||
- One-time commands: \`ls\`, \`cat\`, \`grep\`
|
||
- Build commands: \`npm run build\`, \`make\`
|
||
- Installation commands: \`npm install\`, \`pip install\`
|
||
- Git operations: \`git commit\`, \`git push\`
|
||
- Test runs: \`npm test\`, \`pytest\`
|
||
`;
|
||
}
|
||
|
||
function getCommandDescription(): string {
|
||
if (os.platform() === 'win32') {
|
||
return 'Exact command to execute as `cmd.exe /c <command>`';
|
||
} else {
|
||
return 'Exact bash command to execute as `bash -c <command>`';
|
||
}
|
||
}
|
||
|
||
export class ShellTool extends BaseDeclarativeTool<
|
||
ShellToolParams,
|
||
ToolResult
|
||
> {
|
||
static Name: string = ToolNames.SHELL;
|
||
|
||
constructor(private readonly config: Config) {
|
||
super(
|
||
ShellTool.Name,
|
||
ToolDisplayNames.SHELL,
|
||
getShellToolDescription(),
|
||
Kind.Execute,
|
||
{
|
||
type: 'object',
|
||
properties: {
|
||
command: {
|
||
type: 'string',
|
||
description: getCommandDescription(),
|
||
},
|
||
is_background: {
|
||
type: 'boolean',
|
||
description:
|
||
'Optional: Whether to run the command in background. If not specified, defaults to false (foreground execution). Explicitly set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
|
||
},
|
||
timeout: {
|
||
type: 'number',
|
||
description: 'Optional timeout in milliseconds (max 600000)',
|
||
},
|
||
description: {
|
||
type: 'string',
|
||
description:
|
||
'Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.',
|
||
},
|
||
directory: {
|
||
type: 'string',
|
||
description:
|
||
'(OPTIONAL) The absolute path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
|
||
},
|
||
},
|
||
required: ['command'],
|
||
},
|
||
false, // output is not markdown
|
||
true, // output can be updated
|
||
);
|
||
}
|
||
|
||
protected override validateToolParamValues(
|
||
params: ShellToolParams,
|
||
): string | null {
|
||
// NOTE: Permission checks (read-only detection, PM rules) are handled at
|
||
// L3 (getDefaultPermission) and L4 (PM override) in coreToolScheduler.
|
||
// This method only performs pure parameter validation.
|
||
if (!params.command.trim()) {
|
||
return 'Command cannot be empty.';
|
||
}
|
||
const strippedCommand = stripShellWrapper(params.command);
|
||
if (
|
||
params.is_background &&
|
||
hasTopLevelTrailingBackgroundOperator(strippedCommand)
|
||
) {
|
||
return 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.';
|
||
}
|
||
if (getCommandRoots(params.command).length === 0) {
|
||
return 'Could not identify command root to obtain permission from user.';
|
||
}
|
||
if (params.timeout !== undefined) {
|
||
if (
|
||
typeof params.timeout !== 'number' ||
|
||
!Number.isInteger(params.timeout)
|
||
) {
|
||
return 'Timeout must be an integer number of milliseconds.';
|
||
}
|
||
if (params.timeout <= 0) {
|
||
return 'Timeout must be a positive number.';
|
||
}
|
||
if (params.timeout > 600000) {
|
||
return 'Timeout cannot exceed 600000ms (10 minutes).';
|
||
}
|
||
}
|
||
if (params.directory) {
|
||
if (!path.isAbsolute(params.directory)) {
|
||
return 'Directory must be an absolute path.';
|
||
}
|
||
|
||
const userSkillsDirs = this.config.storage.getUserSkillsDirs();
|
||
const resolvedDirectoryPath = path.resolve(params.directory);
|
||
const isWithinUserSkills = isSubpaths(
|
||
userSkillsDirs,
|
||
resolvedDirectoryPath,
|
||
);
|
||
if (isWithinUserSkills) {
|
||
return `Explicitly running shell commands from within the user skills directory is not allowed. Please use absolute paths for command parameter instead.`;
|
||
}
|
||
|
||
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
|
||
const isWithinWorkspace = workspaceDirs.some((wsDir) =>
|
||
params.directory!.startsWith(wsDir),
|
||
);
|
||
|
||
if (!isWithinWorkspace) {
|
||
return `Directory '${params.directory}' is not within any of the registered workspace directories.`;
|
||
}
|
||
}
|
||
// Sleep interception: block sleep >= 2s in foreground, suggest Monitor.
|
||
// Strip shell wrappers first so `bash -c 'sleep 5'` / `sh -c '...'` etc.
|
||
// cannot route around the check by hiding the foreground sleep inside a
|
||
// `-c` script. This matches every other sensitive check in this file
|
||
// (directory, read-only, command-root extraction, etc.).
|
||
if (!params.is_background) {
|
||
const sleepPattern = detectBlockedSleepPattern(
|
||
stripShellWrapper(params.command),
|
||
);
|
||
if (sleepPattern !== null) {
|
||
return (
|
||
`Blocked: ${sleepPattern}. ` +
|
||
'Run blocking commands in the background with is_background: true. ' +
|
||
'For streaming events (watching logs, polling APIs), use the Monitor tool. ' +
|
||
'If you genuinely need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.'
|
||
);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
protected createInvocation(
|
||
params: ShellToolParams,
|
||
): ToolInvocation<ShellToolParams, ToolResult> {
|
||
return new ShellToolInvocation(this.config, params);
|
||
}
|
||
}
|