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
5 changes: 5 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

### Fixed

- Fixed async `bash({ async: true })` jobs finishing silently: completed or failed session-managed background bash jobs now enqueue an `async-job-result` follow-up message into the originating chat session automatically, while explicit completed-job polling, explicit cancellation, and parent aborts acknowledge the job and suppress duplicate or unwanted idle turns. Suppression is checked again at the streaming boundary so a completed job polled while its automatic follow-up is staged does not later deliver a duplicate. Async delivery bookkeeping is now bounded by the existing background-job retention defaults, suppressions stay tied to retained jobs so disposed-session running jobs cannot later fall back into the owner session, 12KB–50KB follow-up outputs persist their full output path before inline preview truncation, just-under-threshold raw outputs remain fully inline instead of being preview-truncated without a `fullOutputPath`, shared-manager lifecycle tracking prevents owner-session disposal from dropping later-session jobs while cleaning stale handlers from disposed fork/subagent sessions, and non-blocking delivery attempts keep one live streaming session from delaying unrelated async job completions.
- Fixed a background bash job leak where a failed async-manager registration (disposed manager/session or a capacity race mid-flight) left a never-executed job permanently reported as `running` to `__atomic_bash_job` polls; the managed job entry is now discarded when registration fails and the tool call error is surfaced unchanged.

## [0.9.4-alpha.10] - 2026-07-03

### Fixed
Expand Down
6 changes: 4 additions & 2 deletions packages/coding-agent/docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ Before writing, Atomic verifies the current file against the tagged snapshot. If

## `bash` and `bashInterceptor`

The `bash` tool executes shell commands in the session workspace, with optional PTY or background-job handling. When `pty: true` is requested, local execution uses the bundled Rust-backed PTY session so commands see a real terminal, including headless/tool-only and async job calls; if the native PTY package is unavailable, Atomic degrades to normal pipe execution. Set `PI_NO_PTY=1` or `ATOMIC_NO_PTY=1` to force normal pipe execution. Completed foreground results include oh-my-pi-style `timeoutSeconds`, `requestedTimeoutSeconds`, `wallTimeMs`, and non-zero `exitCode` metadata; background jobs use `details.async: { state, jobId, type: "bash" }`, can be polled with `bash({"command":"__atomic_bash_job <id>"})`, can be cancelled with `bash({"command":"__atomic_bash_job_cancel <id>"})`, and preserve overflow output in a temporary `fullOutputPath` when polling output is truncated. `bashInterceptor.enabled` defaults to `false`; interception is not auto-enabled.
The `bash` tool executes shell commands in the session workspace, with optional PTY or background-job handling. When `pty: true` is requested, local execution uses the bundled Rust-backed PTY session so commands see a real terminal, including headless/tool-only and async job calls; if the native PTY package is unavailable, Atomic degrades to normal pipe execution. Set `PI_NO_PTY=1` or `ATOMIC_NO_PTY=1` to force normal pipe execution. Completed foreground results include oh-my-pi-style `timeoutSeconds`, `requestedTimeoutSeconds`, `wallTimeMs`, and non-zero `exitCode` metadata; background jobs use `details.async: { state, jobId, type: "bash" }`, can be polled with `bash({"command":"__atomic_bash_job <id>"})`, can be cancelled with `bash({"command":"__atomic_bash_job_cancel <id>"})`, and preserve overflow output in a temporary `fullOutputPath` when polling output is truncated.

When explicitly enabled in settings, built-in bash interceptor rules block common shell substitutes for first-class tools (`cat`/`grep`/`find`/in-place `sed`/redirection, etc.) only when the corresponding tool is available. Enabled bash tool calls are also offered to `user_bash` extension handlers before local execution. Atomic checks the original command, the internal-URL-expanded command, configured-prefix forms, `spawnHook`-rewritten commands, and a leading `cd path && command` or `cd path; command`-stripped form only when structured `cwd` was omitted, so interceptors can route commands by effective working directory without overriding explicit `cwd`. The bash schema accepts `cwd`, `env`, `timeout`, `pty`, and `async`; `cwd` and `env` are honored by the local executor, `timeout` defaults to 300s and is clamped to 1..3600s, and normal sessions enable tracked async jobs with bounded retention.
When a session-managed background bash job completes or fails, Atomic sends an `async-job-result` custom follow-up into the conversation automatically (`display: true`, delivered as a follow-up turn). Small results are inlined, and results whose raw output stays below the persistence threshold remain fully inline even if the formatted follow-up header pushes the message over the preview limit; persisted large results include a preview plus the retained `fullOutputPath` (persisted before the normal polling truncation limit so 12KB–50KB outputs remain recoverable). If the model explicitly polls a completed job with `__atomic_bash_job <id>` before the queued follow-up is delivered, or cancels a job with `__atomic_bash_job_cancel <id>`, Atomic acknowledges the result and suppresses duplicate auto-delivery while keeping the job pollable until normal bounded retention/TTL cleanup. Suppression is tied to the retained job rather than a short timer, so disposed-session jobs cannot later fall back into another session after a long-running command completes. Session delivery attempts are non-blocking across sessions: a live streaming session can defer its own follow-up until the stream boundary without delaying unrelated completed jobs. Session disposal removes that session's pending async delivery handlers; a shared manager remains alive while other live sessions still own active jobs, then cleans up when the last session is disposed. Direct SDK/tool-factory uses only get automatic delivery when they provide an async job manager/delivery handler; otherwise async jobs remain manually pollable.

When explicitly enabled in settings, built-in bash interceptor rules block common shell substitutes for first-class tools (`cat`/`grep`/`find`/in-place `sed`/redirection, etc.) only when the corresponding tool is available. Enabled bash tool calls are also offered to `user_bash` extension handlers before local execution. Atomic checks the original command, the internal-URL-expanded command, configured-prefix forms, `spawnHook`-rewritten commands, and a leading `cd path && command` or `cd path; command`-stripped form only when structured `cwd` was omitted, so interceptors can route commands by effective working directory without overriding explicit `cwd`. The bash schema accepts `cwd`, `env`, `timeout`, `pty`, and `async`; `cwd` and `env` are honored by the local executor, `timeout` defaults to 300s and is clamped to 1..3600s, and normal sessions enable tracked async jobs with bounded retention. `bashInterceptor.enabled` defaults to `false`; interception is not auto-enabled.

```json
{
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/agent-session-events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { disposeSessionAsyncJobManager } from "./async/session-manager.js";
import type { AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, TextContent } from "@earendil-works/pi-ai/compat";
import { cleanupSessionResources } from "@earendil-works/pi-ai/compat";
Expand Down Expand Up @@ -405,6 +406,7 @@ export function dispose(this: AgentSession): void {
this._extensionRunner.invalidate(
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
);
disposeSessionAsyncJobManager(this._asyncJobManager, this._asyncJobManagerSessionId);
this._disconnectFromAgent();
this._eventListeners = [];
cleanupSessionResources(this.sessionId);
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/core/agent-session-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import type { BranchSummaryEntry, SessionManager } from "./session-manager.ts";
import type { BranchSummaryEntry, SessionManager } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.ts";
import type { BuildSystemPromptOptions } from "./system-prompt.ts";
import type { AsyncJobManager } from "./async/job-manager.js";
import type { BashOperations } from "./tools/bash.ts";
import type {
AgentSessionEvent,
Expand Down Expand Up @@ -376,5 +377,7 @@ export interface AgentSessionInternalSurface extends AgentSessionMethodSurface,
_baseSystemPromptOptions: BuildSystemPromptOptions;
_systemPromptOverride?: string;
_lastAssistantMessage: AssistantMessage | undefined;
_asyncJobManager: AsyncJobManager;
_asyncJobManagerSessionId: symbol;
}

4 changes: 4 additions & 0 deletions packages/coding-agent/src/core/agent-session-tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createAllToolDefinitions, defaultToolNames } from "./tools/index.ts";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts";
import type { AgentSessionInternalSurface as AgentSession } from "./agent-session-methods.ts";
import type { ToolDefinitionEntry } from "./agent-session-types.ts";
import { createSessionAsyncDeliveryHandler } from "./async/session-manager.js";

export function _refreshToolRegistry(this: AgentSession, options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {
const previousRegistryNames = new Set(this._toolRegistry.keys());
Expand Down Expand Up @@ -154,6 +155,9 @@ export function _buildRuntime(this: AgentSession, options: {
shellPath,
interceptorEnabled: () => this.settingsManager.getBashInterceptorEnabled(),
availableTools: activeBuiltinTools,
asyncJobManager: this._asyncJobManager,
asyncJobDeliveryHandler: createSessionAsyncDeliveryHandler(this, this._asyncJobManager, this._asyncJobManagerSessionId),
asyncJobSessionId: this._asyncJobManagerSessionId,
interceptor: async (context) => {
const result = await this._extensionRunner.emitUserBash({
type: "user_bash",
Expand Down
11 changes: 8 additions & 3 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai/compat"
import type { BashExecutionMessage, CustomMessage } from "./messages.ts";
import type { ModelRegistry } from "./model-registry.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import type { SessionManager } from "./session-manager.ts";
import type { SessionManager } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.ts";
import type { BuildSystemPromptOptions } from "./system-prompt.ts";
import type { AsyncJobManager } from "./async/job-manager.js";
import { createSessionAsyncJobManager } from "./async/session-manager.js";
import { installAgentSessionAccessors } from "./agent-session-accessors.ts";
import { agentSessionAutoCompactionMethods } from "./agent-session-auto-compaction.ts";
import { agentSessionBashMethods } from "./agent-session-bash.ts";
Expand Down Expand Up @@ -117,7 +119,8 @@ export class AgentSession {
protected _baseSystemPromptOptions!: BuildSystemPromptOptions;
protected _systemPromptOverride?: string;
protected _lastAssistantMessage: AssistantMessage | undefined = undefined;

protected _asyncJobManager: AsyncJobManager;
protected _asyncJobManagerSessionId: symbol;
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
Expand All @@ -134,8 +137,10 @@ export class AgentSession {
this._baseToolsOverride = config.baseToolsOverride;
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
this._orchestrationContext = config.orchestrationContext;

const internals = this as unknown as AgentSessionInternalSurface;
const asyncJobManagerHandle = createSessionAsyncJobManager(internals);
this._asyncJobManager = asyncJobManagerHandle.manager;
this._asyncJobManagerSessionId = asyncJobManagerHandle.sessionId;
internals._handleAgentEvent = internals._handleAgentEvent.bind(this);
this._unsubscribeAgent = this.agent.subscribe(internals._handleAgentEvent);
internals._installAgentToolHooks();
Expand Down
52 changes: 52 additions & 0 deletions packages/coding-agent/src/core/async/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ManagedAsyncBashJob, AsyncJobDeliveryMessage } from "./types.js";

const INLINE_OUTPUT_LIMIT = 12_000;
const LARGE_OUTPUT_PREVIEW_LIMIT = 4_000;

function truncateText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return text.slice(0, maxChars).trimEnd();
}

function jobOutput(job: ManagedAsyncBashJob): string {
const output = job.output.trimEnd();
if (output.length > 0) return output;
return job.error ? `(no output)\n\n${job.error}` : "(no output)";
}

function statusLine(job: ManagedAsyncBashJob): string | undefined {
if (job.error) return `Error: ${job.error}`;
if (job.exitCode !== undefined && job.exitCode !== null && job.exitCode !== 0) {
return `Command exited with code ${job.exitCode}`;
}
return undefined;
}

export function formatAsyncResultForFollowUp(job: ManagedAsyncBashJob): AsyncJobDeliveryMessage {
const elapsedMs = (job.endedAt ?? Date.now()) - job.startedAt;
const header = `Async bash job ${job.jobId} ${job.status}: ${job.command}`;
const body = jobOutput(job);
const status = statusLine(job);
const inline = [header, body, status].filter((part): part is string => part !== undefined && part.length > 0).join("\n\n");
let content = inline;
if (inline.length > INLINE_OUTPUT_LIMIT && job.fullOutputPath) {
const preview = truncateText(body, LARGE_OUTPUT_PREVIEW_LIMIT);
content = [header, preview, `[Output truncated for async follow-up. Full output: ${job.fullOutputPath}]`, status]
.filter((part): part is string => part !== undefined && part.length > 0)
.join("\n\n");
}
return {
customType: "async-job-result",
content,
display: true,
details: {
jobId: job.jobId,
type: "bash",
status: job.status,
command: job.command,
exitCode: job.exitCode,
fullOutputPath: job.fullOutputPath,
wallTimeMs: elapsedMs,
},
};
}
Loading
Loading