Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .opencode/command/specgit-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,18 @@ AGENTS.md SpecGit block; this command only launches it.

1. Collect the argument: `$ARGUMENTS` is either an issue title (create) or a
pure number (reuse). Multiple arguments = N issues in one delivery.
2. Run from the repo root:
2. Run from the repo root — keep `$ARGUMENTS` UNQUOTED so each quoted title
arrives as its own argument:

```bash
specgit issue "$ARGUMENTS" --json
specgit issue $ARGUMENTS --json
```

3. On success report the brief: issue URL(s), PR URL (draft), branch name —
then fill each issue body it created (Why / Scope / Approach /
Acceptance) from the discussion with `gh issue edit <n>`, then
implement. Fill in the draft PR's scaffold (Why / What changed /
Evidence) as you deliver; its placeholders are advisory, never gates,
and the closing references stay intact.
Evidence / Checklist) as you deliver; its placeholders are advisory,
never gates, and the closing references stay intact.
4. Switch to the delivery branch and begin the TDD loop.
5. On error, read `errors[].fix` and follow it — never bypass the record.
2 changes: 1 addition & 1 deletion .opencode/hooks/specgit-merge-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ esac
command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})")

case "$command" in
gh\ pr\ merge*)
gh\ pr\ merge*|glab\ mr\ merge*)
exec node -e '
const { spawn } = require("child_process");
const fs = require("fs");
Expand Down
11 changes: 5 additions & 6 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
version: 1
delivery: 477-remove-legacy-artifacts
delivery: shell-silence-guard
context:
kind: worktree
label: docs-477
branch: docs/477-remove-legacy-artifacts
kind: branch
branch: feat/433-shell-silence-guard
issues:
- 477
pr: 480
- 433
pr: 491
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
bashSilenceWarnMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
Expand Down
37 changes: 36 additions & 1 deletion packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export { Parameters } from "./shell/prompt"
export const SHELL_ABORT_NOTE =
"The command was aborted before completion (client interrupt or session cancel). For long-running work, bound it with `timeout <seconds>` and stream progress instead of piping into a silent buffer."

const DEFAULT_SILENCE_WARN_MS = 5 * 60 * 1000

const shellSilenceNote = (ms: number) =>
`shell tool emitted an inactivity warning after ${ms} ms without output; the command was left running. If this command is expected to stay silent, pass expectedSilent: true to opt out.`

const MAX_METADATA_LENGTH = 30_000
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
Expand Down Expand Up @@ -352,6 +357,7 @@ export const ShellTool = Tool.define(
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
const silenceWarnMs = flags.bashSilenceWarnMs ?? DEFAULT_SILENCE_WARN_MS

const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
const lines = yield* spawner
Expand Down Expand Up @@ -439,6 +445,7 @@ export const ShellTool = Tool.define(
cwd: string
env: NodeJS.ProcessEnv
timeout: number
expectedSilent: boolean
},
ctx: Tool.Context,
) {
Expand All @@ -453,6 +460,9 @@ export const ShellTool = Tool.define(
let cut = false
let expired = false
let aborted = false
let lastActivity = Date.now()
let silenceWarned = false
let silenceWarnings = 0

const closeSink = Effect.fnUntraced(function* () {
const stream = sink
Expand Down Expand Up @@ -492,6 +502,8 @@ export const ShellTool = Tool.define(

const readerFiber = yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
lastActivity = Date.now()
silenceWarned = false
const size = Buffer.byteLength(chunk, "utf-8")
list.push({ text: chunk, size })
used += size
Expand Down Expand Up @@ -537,6 +549,27 @@ export const ShellTool = Tool.define(
}),
)

// Warn-only silence guard: lives on its own fiber and must never
// join the race below — a silence warning may not change exit.kind.
if (!input.expectedSilent) {
yield* Effect.forkScoped(
Effect.forever(
Effect.gen(function* () {
const idle = Date.now() - lastActivity
yield* Effect.sleep(`${idle < silenceWarnMs ? silenceWarnMs - idle : silenceWarnMs} millis`)
if (silenceWarned || Date.now() - lastActivity < silenceWarnMs) return
silenceWarned = true
silenceWarnings++
yield* ctx.metadata({
metadata: {
output: last + `\n\n${shellSilenceNote(silenceWarnMs)}`,
},
})
}),
),
)
}

const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
Expand Down Expand Up @@ -579,6 +612,7 @@ export const ShellTool = Tool.define(
)
}
if (aborted) meta.push(SHELL_ABORT_NOTE)
for (let i = 0; i < silenceWarnings; i++) meta.push(shellSilenceNote(silenceWarnMs))
const raw = list.map((item) => item.text).join("")
const end = tail(raw, limits.maxLines, limits.maxBytes)
if (end.cut) cut = true
Expand Down Expand Up @@ -614,7 +648,7 @@ export const ShellTool = Tool.define(
const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs, silenceWarnMs)
yield* Effect.logInfo("shell tool using shell", { shell })

return {
Expand Down Expand Up @@ -649,6 +683,7 @@ export const ShellTool = Tool.define(
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
expectedSilent: params.expectedSilent === true,
},
ctx,
)
Expand Down
23 changes: 16 additions & 7 deletions packages/opencode/src/tool/shell/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export function parameterSchema() {
workdir: Schema.optional(Schema.String).annotate({
description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
}),
expectedSilent: Schema.optional(Schema.Boolean).annotate({
description:
"Set true when the command is expected to produce no output for long stretches (watchers, listeners, waits). Suppresses the warn-only shell inactivity warning.",
}),
})
}

Expand Down Expand Up @@ -75,7 +79,7 @@ function chainGuidance(name: string) {
return "If the commands depend on each other and must run sequentially, use a single Bash 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 Bash for git operations, or git add before git commit), run these operations sequentially instead."
}

function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number, silenceWarnMs: number) {
return `Before executing the command, please follow these steps:

1. Directory Verification:
Expand All @@ -95,6 +99,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- If a command produces no output for ${silenceWarnMs}ms, a warn-only inactivity warning is emitted. For commands that legitimately stay silent (watchers, listeners, waits), pass \`expectedSilent: true\` to opt out.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.

- Avoid using Bash 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:
Expand Down Expand Up @@ -124,6 +129,7 @@ function powershellCommandSection(
pathSep: string,
limits: Limits,
defaultTimeoutMs: number,
silenceWarnMs: number,
) {
return `${powershellNotes(name)}

Expand All @@ -146,6 +152,7 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- If a command produces no output for ${silenceWarnMs}ms, a warn-only inactivity warning is emitted. For commands that legitimately stay silent (watchers, listeners, waits), pass \`expectedSilent: true\` to opt out.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.

- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
Expand All @@ -169,7 +176,7 @@ Usage notes:
</bad-example>`
}

function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number, silenceWarnMs: number) {
return `# cmd.exe shell notes
- Use double quotes for paths with spaces.
- Use %VAR% for environment variables.
Expand All @@ -195,6 +202,7 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- If a command produces no output for ${silenceWarnMs}ms, a warn-only inactivity warning is emitted. For commands that legitimately stay silent (watchers, listeners, waits), pass \`expectedSilent: true\` to opt out.
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.

- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
Expand All @@ -218,15 +226,15 @@ Usage notes:
</bad-example>`
}

function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number, silenceWarnMs: number) {
const isPowerShell = PS.has(name)
const chain = chainGuidance(name)
if (CMD.has(name)) {
return {
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
workdirSection:
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
commandSection: cmdCommandSection(chain, limits, defaultTimeoutMs),
commandSection: cmdCommandSection(chain, limits, defaultTimeoutMs, silenceWarnMs),
gitCommands: "git commands",
gitCommandRestriction: "git commands",
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
Expand All @@ -244,6 +252,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
platform === "win32" ? "\\" : "/",
limits,
defaultTimeoutMs,
silenceWarnMs,
),
gitCommands: "git commands",
gitCommandRestriction: "git commands",
Expand All @@ -259,7 +268,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",
workdirSection:
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.",
commandSection: bashCommandSection(chain, limits, defaultTimeoutMs),
commandSection: bashCommandSection(chain, limits, defaultTimeoutMs, silenceWarnMs),
gitCommands: "bash commands",
gitCommandRestriction: "git bash commands",
createPrInstruction:
Expand All @@ -270,8 +279,8 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
}
}

export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
const selected = profile(name, platform, limits, defaultTimeoutMs)
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number, silenceWarnMs: number) {
const selected = profile(name, platform, limits, defaultTimeoutMs, silenceWarnMs)
return {
description: renderPrompt(DESCRIPTION, {
intro: selected.intro,
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/effect/runtime-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,35 @@ describe("RuntimeFlags", () => {
)
}

for (const input of [
{ name: "absent", config: {}, expected: undefined },
{
name: "valid positive integer",
config: { OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS: "1234" },
expected: 1234,
},
{
name: "invalid string",
config: { OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS: "nope" },
expected: undefined,
},
{ name: "zero", config: { OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS: "0" }, expected: undefined },
{ name: "negative", config: { OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS: "-1" }, expected: undefined },
{
name: "non-integer",
config: { OPENCODE_EXPERIMENTAL_BASH_SILENCE_WARN_MS: "1.5" },
expected: undefined,
},
]) {
it.effect(`parses bashSilenceWarnMs from config: ${input.name}`, () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config)))

expect(flags.bashSilenceWarnMs).toBe(input.expected)
}),
)
}

for (const input of [
{ name: "absent", config: {}, expected: undefined },
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
"description": "The command to execute",
"type": "string",
},
"expectedSilent": {
"description": "Set true when the command is expected to produce no output for long stretches (watchers, listeners, waits). Suppresses the warn-only shell inactivity warning.",
"type": "boolean",
},
"timeout": {
"description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0,
Expand Down
Loading
Loading