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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Removed
- Removed the `tsx` dependency from the shipped runtime. The Intercom broker's Node path now runs on Atomic's bundled `jiti` loader, which is dependency-free pure JavaScript, instead of resolving `tsx` first. `tsx` was the only thing pulling `esbuild`, so this removes `esbuild`, its 26 platform packages, and `fsevents` from `npm-shrinkwrap.json` and from every release archive — about 11.5 MB across 72 files. The `npx --no-install tsx` config pair remains a recognized compatibility sentinel, and explicit custom broker commands are unaffected ([#2208](https://github.com/bastani-inc/atomic/issues/2208)).
- Behavior change worth noting for extension authors: TypeScript reached through the Node broker path is now transpiled by jiti (Babel-based) rather than esbuild. Both erase types without type-checking, but they are not identical — jiti defaults JSX support and `tsconfig` path aliases to off, and enables legacy decorators. A third-party extension that resolved `tsx` from the bundled `node_modules` was relying on an undocumented dependency and will no longer find it ([#2208](https://github.com/bastani-inc/atomic/issues/2208)).
- Reduced redundant repaints of a Ctrl+O-expanded live subagent widget, which were scrolling the chat window to the bottom and clearing terminal scrollback during a run. The widget was republished on every child session event, including assistant streaming deltas, so it repainted continuously instead of when its contents changed; each repaint of a row above the terminal fold costs a pi-tui full redraw that clears scrollback. Progress is now published only at milestones that change what the widget shows. A genuine above-fold milestone change can still require that redraw when the editor/footer region plus the live widget exceed the terminal height — that path is upstream (earendil-works/pi#4785, #7194) and is not fixed here ([#2213](https://github.com/bastani-inc/atomic/pull/2213), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).

## [0.9.13-alpha.1] - 2026-08-05

Expand Down
1 change: 1 addition & 0 deletions packages/subagents/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Fixed the in-process depth guard and nested workflow-stage children. Admission-issued child depth now travels in the typed policy into every single, parallel, chain, async, and resume path, so the executor can reject delegation at the configured limit while Rust admission keeps the hard five-level ceiling; the orphaned process-environment depth bridge and its self-fulfilling tests are gone. In-process children now load the bundled package resources needed to register `subagent`, so a nested child no longer starts with only the base built-in tools and no way to delegate, while workflow-stage children suppress only the workflow extension lifecycle ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).
- Fixed an agent's `maxSubagentDepth` being dropped at the in-process admission door. A child admitted from an agent whose definition tightened the limit received a policy carrying no maximum, so it could keep delegating as if only the global five-level ceiling applied. The effective limit — the stricter of the parent's limit and the child agent's own — now travels on the admitted child spec and policy, is reissued unchanged by a cold reload, and is applied by the executor's depth check alongside the local configuration. Admission also derives the limit from the agent definition when a caller supplies a child spec without one, so the door no longer issues an unbounded policy for an agent that declared a limit ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).
- Fixed a resumed foreground child losing the delegation limit its agent definition had narrowed. Retained resume re-derived the limit from the current stage or process configuration, so a child that ran under an agent maximum of 1 resumed with the configured maximum instead. The effective limit is now recorded per retained child — parallel and chain branches can each carry a different one — and reused on resume, so editing an agent definition between a run and its resume cannot widen that child's budget ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).
- Reduced redundant live subagent repaints by publishing progress only for widget-visible milestones. The in-process runner published `AgentProgress` from a catch-all over every child session event, including the high-frequency `message_update` streaming deltas and `tool_execution_update`, and each publish rewrites the elapsed fields the widget renders. With a subagent expanded via Ctrl+O, that made the live widget repaint for the whole run rather than when its contents changed, and each repaint of a row above the terminal fold costs a destructive pi-tui full redraw that clears terminal scrollback. Progress is now published only on events that change what the widget shows — `agent_start`, `tool_execution_start`, `tool_execution_end`, and (throttled) `message_end` — which is the emission profile foreground subagents had before the in-process runner. Measured against a 20-second replay in the tight geometry, scrollback clears drop from 23 to 3; a live tmux run against the built CLI dropped from 8 clears (7 of them repaints that changed nothing visible) to 3 (none of them). **Genuine above-fold milestone changes can still require a pi-tui redraw**, so a short terminal whose editor/footer region plus the live widget exceed the terminal height will still lose scrollback at tool boundaries; that path is upstream (earendil-works/pi#4785, #7194) and outside this package. The 400 ms throttle, the forced-emit milestones, the depth guard, and the typed status contract are unchanged ([#2213](https://github.com/bastani-inc/atomic/pull/2213), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)).

## [0.9.13-alpha.1] - 2026-08-05

Expand Down
42 changes: 36 additions & 6 deletions packages/subagents/src/runs/inprocess/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,40 @@ function safeArgsPreview(args: unknown): string {
}
}

/**
* How a child session event should publish {@link AgentProgress} to the host UI.
*
* - `force` — the event changed progress the live widget shows; bypass the throttle.
* - `throttled` — worth publishing, but subject to the 400 ms throttle.
* - `none` — carries nothing the widget shows; do not publish.
*
* `none` is the important case. Foreground subagent results render into chat
* scrollback, which can sit above pi-tui's viewport fold. Every publish rewrites
* `durationMs`/`lastActivityAt` and repaints the widget, and a repaint of a row
* above the fold makes `TUI.doRender()` take its `firstChanged < viewportTop`
* branch, which issues a full redraw that writes `\x1b[2J\x1b[H\x1b[3J` — clearing
* the user's scrollback and snapping the terminal to the bottom. A catch-all
* publish therefore destroyed the scrollback ~2.5x/s for the whole run, because
* `AgentSessionEvent` includes high-frequency traffic (`message_update` streaming
* deltas, `tool_execution_update`, `entry_appended`) that the widget never shows.
* Keep this table narrow: add an event only when the widget renders something the
* event changed.
*/
export type ProgressEmission = "force" | "throttled" | "none";

export function progressEmissionFor(eventType: AgentSessionEvent["type"]): ProgressEmission {
switch (eventType) {
case "agent_start":
case "tool_execution_start":
case "tool_execution_end":
return "force";
case "message_end":
return "throttled";
default:
return "none";
}
}

function writeEvent(pathValue: string | undefined, event: AgentSessionEvent): void {
if (!pathValue) return;
mkdirSync(dirname(pathValue), { recursive: true });
Expand Down Expand Up @@ -774,15 +808,14 @@ export class SubagentControlRuntime {
};
unsubscribe = session.subscribe((event) => {
writeEvent(admitted.spec.artifactJsonlPath, event);
const emission = progressEmissionFor(event.type);
if (event.type === "agent_start") {
this.native.publishChildStatus(admitted.identity.path, nativeStatus("running"));
emitProgress(true);
} else if (event.type === "tool_execution_start") {
progressState.toolCount += 1;
progressState.currentTool = event.toolName;
progressState.currentToolArgs = safeArgsPreview(event.args);
progressState.currentToolStartedAt = Date.now();
emitProgress(true);
} else if (event.type === "tool_execution_end") {
if (progressState.currentTool !== undefined) {
progressState.recentTools.push({
Expand All @@ -795,7 +828,6 @@ export class SubagentControlRuntime {
progressState.currentTool = undefined;
progressState.currentToolArgs = undefined;
progressState.currentToolStartedAt = undefined;
emitProgress(true);
} else if (event.type === "message_end") {
const message = (event as { message?: { role?: string; usage?: { input?: number; output?: number } } })
.message;
Expand All @@ -804,10 +836,8 @@ export class SubagentControlRuntime {
const usage = message.usage;
if (usage) progressState.tokens += (usage.input ?? 0) + (usage.output ?? 0);
}
emitProgress(false);
} else {
emitProgress(false);
}
if (emission !== "none") emitProgress(emission === "force");
if (event.type === "model_fallback_start") {
if (!attemptedModels.includes(event.to)) attemptedModels.push(event.to);
effectiveModelId = event.to;
Expand Down
102 changes: 102 additions & 0 deletions test/unit/subagents-inprocess-progress-emission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import type { AgentSessionEvent } from "@bastani/atomic";
import { describe, test } from "vitest";
import { progressEmissionFor } from "../../packages/subagents/src/runs/inprocess/runner.ts";

/**
* The in-process runner publishes AgentProgress into chat scrollback, which can
* sit above pi-tui's viewport fold. A publish for an event the widget does not
* render still repaints an above-fold row, which makes pi-tui take its
* `firstChanged < viewportTop` branch and issue a scrollback-clearing full
* redraw. These tests pin the narrow emission table that keeps that from firing
* on every streaming delta.
*/

type EventType = AgentSessionEvent["type"];

const FORCED: readonly EventType[] = ["agent_start", "tool_execution_start", "tool_execution_end"];
const THROTTLED: readonly EventType[] = ["message_end"];

/**
* High-frequency traffic the live widget never renders. `message_update` is the
* assistant streaming delta and fires many times per second; publishing there is
* what destroyed the user's scrollback on every Ctrl+O-expanded subagent run.
*/
const SILENT: readonly EventType[] = [
"message_update",
"message_start",
"tool_execution_update",
"turn_start",
"turn_end",
"entry_appended",
"queue_update",
"agent_settled",
"agent_end",
"bash_execution_update",
"session_info_changed",
"model_changed",
"thinking_level_changed",
"compaction_start",
"compaction_end",
"auto_retry_start",
"auto_retry_end",
"model_fallback_start",
"model_fallback_end",
];

describe("in-process runner progress emission profile", () => {
test("events that change rendered progress publish immediately", () => {
for (const eventType of FORCED) {
assert.equal(progressEmissionFor(eventType), "force", `${eventType} must bypass the throttle`);
}
});

test("message_end publishes under the 400 ms throttle", () => {
for (const eventType of THROTTLED) {
assert.equal(progressEmissionFor(eventType), "throttled", `${eventType} must stay throttled`);
}
});

test("high-frequency events the widget does not render publish nothing", () => {
for (const eventType of SILENT) {
assert.equal(
progressEmissionFor(eventType),
"none",
`${eventType} must not repaint the live subagent widget; a catch-all publish here clears the ` +
"user's terminal scrollback on every streaming delta",
);
}
});

test("there is no catch-all: unknown events default to none", () => {
assert.equal(progressEmissionFor("some_future_event" as EventType), "none");
});

test("streaming a realistic turn publishes once per milestone, not once per delta", () => {
// One assistant turn: 40 streaming deltas around a single tool call.
const stream: EventType[] = [
"agent_start",
"turn_start",
"message_start",
...Array.from({ length: 20 }, (): EventType => "message_update"),
"message_end",
"tool_execution_start",
...Array.from({ length: 8 }, (): EventType => "tool_execution_update"),
"tool_execution_end",
"message_start",
...Array.from({ length: 20 }, (): EventType => "message_update"),
"message_end",
"turn_end",
"agent_settled",
];
const published = stream.filter((eventType) => progressEmissionFor(eventType) !== "none");
assert.deepEqual(published, [
"agent_start",
"message_end",
"tool_execution_start",
"tool_execution_end",
"message_end",
]);
assert.equal(published.length, 5, `55 session events must publish 5 progress updates, not ${stream.length}`);
});
});
Loading