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 @@ -12,6 +12,7 @@

- Stopped a broad Python packaging ignore rule from excluding `scripts/lib/` files inside bundled upstream skill trees, so a synchronized skill ships its complete set of modules instead of silently losing part of the tree.
- Fixed the main and workflow-stage chats losing the automatic-compaction activity indicator after a tool result. When a tool call (for example a subagent) expanded the context enough to trigger post-tool compaction, the follow-up turn that Pi opens before compaction finishes replaced the factual `Auto-compacting...` status with a generic Working spinner, and the compaction's own cleanup then removed that spinner too — leaving the main chat with no activity at all for the rest of the continuing stream until the next user message. Compaction now keeps the status surface until its own completion event, ordinary Working resumes automatically when the same stream continues (including a successful compaction that had nothing to compact), and the attached workflow-stage chat paints its compaction status from the start event instead of waiting for an animation frame. Cancelled and failed compactions still stop all activity; the main chat keeps its existing cancellation receipt, while failures retain event-provided error text.
- Fixed attached workflow-stage chats missing Working during workflow-authored idle follow-ups, including while a saved stage session restores or a chat attaches mid-startup. Working persists through prompt startup and pre-turn compaction; live-turn queueing and factual status precedence remain intact, and late events cannot restart it after the final retained delivery settles.

## [0.9.11-alpha.6] - 2026-07-28

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -2819,7 +2819,7 @@ Surface behavior:
- **Wheel and trackpad** - While the workflow graph is active, vertical wheel/trackpad gestures pan it up and down, and horizontal gestures pan wide graphs left and right when the terminal exposes horizontal wheel events; these gestures remain scoped to the graph instead of leaking into the main chat or terminal scrollback. Attached stage chats capture mouse/trackpad wheel events by default so scrolling stays inside the active stage transcript or prompt instead of falling through to terminal/main-chat scrollback.
- **Tool and node detail** - Attached stage chats match main chat's tool-detail expansion behavior while keeping expansion state local to the workflow UI context. Press Ctrl+O (the configurable `app.tools.expand` binding) to expand every visible workflow node and tool card, including single, parallel, and chain subagent progress, current tool activity, and artifact paths; press it again to collapse them. The toggle works for active, completed, and archived stage views, including at the supported 40-column terminal minimum. A mounted prompt, custom question, or other input-owning overlay keeps the key instead of changing expansion.
- **Footer context** - An attached live stage chat carries the main chat's current-folder and Git-branch identity into its themed footer and mirrors live extension status lines such as the MCP server indicator. Branch changes trigger a repaint through the host's cached footer provider, and extension status changes are read from that same provider rather than recomputed by the workflow UI.
- **Working animation lifecycle** - Ordinary attached-stage work keeps the same exact one-cell `∀` visible while following the active workflow theme's dark → accent → bright/bold → accent → dark luminance ramp every 88ms. Every agent and SDK turn resets to the dark regular phase with a fresh lifecycle-relative cadence; turn, terminal, error, replacement, and disposal cleanup stop the active timer without stale repaint. In an eligible retained terminal-stage chat, an accepted follow-up shows Working immediately while Atomic attaches the retained conversation and starts the prompt, then hands the same visible status to the agent turn without a gap. A no-turn result, prompt error, or terminal completion removes it; an accepted manual retry clears stale status from the prior prompt before showing new pre-stream activity. `NO_COLOR` retains regular/bold activity without foreground-color escapes. Reduced motion uses a static regular accent `∀` without an animation timer; factual automatic retry, fallback, compaction, cancellation, and error copy retains precedence.
- **Working animation lifecycle** - Ordinary attached-stage work keeps the same exact one-cell `∀` visible while following the active workflow theme's dark → accent → bright/bold → accent → dark luminance ramp every 88ms. Every agent and SDK turn resets to the dark regular phase with a fresh lifecycle-relative cadence; turn, terminal, error, replacement, and disposal cleanup stop the active timer without stale repaint. In an eligible retained-stage chat, every accepted idle follow-up — including a workflow-authored `stage.sendUserMessage(...)` after a prior turn ended — shows Working on admission or attach, including while Atomic restores a saved retained conversation, and keeps it through prompt startup, pre-turn compaction, and agent handoff. Attaching or remounting mid-delivery paints immediately rather than waiting for the turn's first event. A message queued into a live turn with `followUp`/`steer` uses that turn's existing status instead of starting a new one. A no-turn result, prompt or restore error, or terminal completion removes it; once the last accepted post-terminal delivery settles, a leftover start cannot bring it back. An accepted manual retry clears stale status from the prior prompt before showing new pre-stream activity. `NO_COLOR` retains regular/bold activity without foreground-color escapes. Reduced motion uses a static regular accent `∀` without an animation timer; factual automatic retry, fallback, compaction, cancellation, and error copy retains precedence.
- **Async statusline** - If an async/background subagent is running while the fullscreen workflow graph is open, the graph statusline mirrors the async summary so the background run remains visible; hide the graph with `h`, leave it with `ctrl+x`, or reconnect later to return to the full below-editor async widget.
- **Copy mode** - Press `ctrl+t` inside an attached stage chat to toggle **copy mode**: copy mode disables workflow-chat mouse reporting so normal terminal/tmux text selection can work; press `ctrl+t` again to leave copy mode and restore transcript or prompt scrolling. Archived read-only stage transcripts expose the same footer and copy-mode status, so their text can also be selected and copied; `esc` closes the transcript and `ctrl+x` returns to the graph. While copy mode is on, wheel/trackpad gestures are handled by the terminal/tmux and may scroll terminal scrollback, so leave copy mode before using the wheel again.
- **Run control** - Use `interrupt`, `pause`, and `resume` for resumable live work. Pause/interrupt holds a stage's queued steering and follow-up items in place without dequeuing them or starting continuation; `resume` releases those items once in their existing per-queue order, but queue release alone does not start a model turn. `resume` on a non-paused run reopens the saved snapshot or overlay. Use `quit` to pause a live run gracefully while preserving it for `/workflow resume`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
notifyChatSessionStatus,
notifyChatSessionWarning,
requiredChatSessionCommand,
settleChatSessionPromptLifecycle,
startChatSessionWorkingLifecycle,
stopChatSessionWorkingLifecycle,
syncChatSessionAnimationTick,
Expand Down Expand Up @@ -202,23 +203,7 @@ export async function submitChatSession<TExtraEntry extends ChatTranscriptEntryL
}
}

function settleSubmittedPromptLifecycle<
TExtraEntry extends ChatTranscriptEntryLike,
>(
state: ChatSessionHostState<TExtraEntry>,
submittedGeneration: number | undefined,
): void {
if (submittedGeneration === undefined) {
state.sdkBusy = false;
return;
}
const lifecycleWasReplaced = state.workingLifecycleGeneration !== submittedGeneration;
if (lifecycleWasReplaced && state.workingLifecycleActive) return;
state.sdkBusy = false;
if (!lifecycleWasReplaced && !isChatSessionStreaming(state)) {
stopChatSessionWorkingLifecycle(state);
}
}
const settleSubmittedPromptLifecycle = settleChatSessionPromptLifecycle;

export async function abortChatSessionCompaction<
TExtraEntry extends ChatTranscriptEntryLike,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,74 @@ export function stopChatSessionWorkingLifecycle<
state.immediateEventRenderPending = !state.disposed && immediateEventRender;
}

/**
* Begin a Working lifecycle for a prompt this host did not submit itself —
* currently a workflow-authored `sendUserMessage()` accepted on an idle
* retained stage. Mirrors the manual submit path so an accepted delivery paints
* immediately and then hands the same visible period to the agent turn.
*
* Returns the lifecycle generation that identifies this delivery. Pass it back
* to `settleChatSessionPromptLifecycle` so a stale settlement cannot clear a
* newer turn.
*/
export function startChatSessionExternalPromptLifecycle<
TExtraEntry extends ChatTranscriptEntryLike,
>(state: ChatSessionHostState<TExtraEntry>): number | undefined {
// Compaction status outranks ordinary Working and owns its own indicator.
return openChatSessionExternalPromptLifecycle(state, state.compacting);
}

/**
* Reopen the Working lifecycle for a delivery that is still active after a
* temporary overlay ended — a pre-turn compaction clears busy state and the
* lifecycle even though the accepted delivery has not started its turn yet.
*
* Unlike the initial start, this preserves `statusMessage` verbatim, so a
* compaction error stays visible and keeps outranking ordinary Working.
*/
export function reassertChatSessionExternalPromptLifecycle<
TExtraEntry extends ChatTranscriptEntryLike,
>(state: ChatSessionHostState<TExtraEntry>): number | undefined {
return openChatSessionExternalPromptLifecycle(state, true);
}

function openChatSessionExternalPromptLifecycle<
TExtraEntry extends ChatTranscriptEntryLike,
>(
state: ChatSessionHostState<TExtraEntry>,
preserveStatusMessage: boolean,
): number | undefined {
if (state.disposed) return undefined;
if (!preserveStatusMessage) state.statusMessage = "";
state.sdkBusy = true;
startChatSessionWorkingLifecycle(state);
syncChatSessionAnimationTick(state);
state.requestRender?.();
return state.workingLifecycleGeneration;
}

/**
* Settle a prompt lifecycle started by this host. A replaced generation means a
* newer turn owns the indicator, so only its own owner may clear busy state.
*/
export function settleChatSessionPromptLifecycle<
TExtraEntry extends ChatTranscriptEntryLike,
>(
state: ChatSessionHostState<TExtraEntry>,
submittedGeneration: number | undefined,
): void {
if (submittedGeneration === undefined) {
state.sdkBusy = false;
return;
}
const lifecycleWasReplaced = state.workingLifecycleGeneration !== submittedGeneration;
if (lifecycleWasReplaced && state.workingLifecycleActive) return;
state.sdkBusy = false;
if (!lifecycleWasReplaced && !isChatSessionStreaming(state)) {
stopChatSessionWorkingLifecycle(state);
}
}

function clearChatSessionAnimation<
TExtraEntry extends ChatTranscriptEntryLike,
>(state: ChatSessionHostState<TExtraEntry>): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import {
disposeChatSession,
isChatSessionBashRunning,
isChatSessionStreaming,
reassertChatSessionExternalPromptLifecycle,
settleChatSessionPromptLifecycle,
startChatSessionExternalPromptLifecycle,
syncChatSessionAnimationTick,
} from "./chat-session-host-runtime.ts";
import { ChatSessionHostState } from "./chat-session-host-state.ts";
Expand Down Expand Up @@ -204,6 +207,30 @@ export class ChatSessionHost<TExtraEntry extends ChatTranscriptEntryLike = never
clearChatSessionBusyForTerminalWorkflowStage(this.state);
}

/**
* Start Working for an accepted prompt delivered outside this host — a
* workflow definition auto-sending to its own retained stage. Returns the
* lifecycle token to hand back to `settleExternalPromptLifecycle`.
*/
beginExternalPromptLifecycle(): number | undefined {
return startChatSessionExternalPromptLifecycle(this.state);
}

/**
* Reopen Working for a delivery still active after a temporary overlay — a
* pre-turn compaction — cleared it. Preserves factual status text.
*/
reassertExternalPromptLifecycle(): number | undefined {
return reassertChatSessionExternalPromptLifecycle(this.state);
}

/** Settle a lifecycle from `beginExternalPromptLifecycle`. Stale tokens no-op. */
settleExternalPromptLifecycle(generation: number | undefined): void {
settleChatSessionPromptLifecycle(this.state, generation);
syncChatSessionAnimationTick(this.state);
this.state.requestRender?.();
}

dispose(): void {
disposeChatSession(this.state);
}
Expand Down
32 changes: 23 additions & 9 deletions packages/workflows/src/runs/foreground/executor-stage-control.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import type { StageControlHandle, AgentSessionEventListener } from "./stage-control-registry.js";
import type { LiveStageRuntime } from "./executor-stage-types.js";
import type { StageUserMessageDeliveryAction } from "./stage-runner-types.js";
import type { StageUserMessageDeliveryAction, StageUserMessagePreparation } from "./stage-runner-types.js";
import { isTerminalStage } from "./executor-scheduler.js";
import { StageToolExecutionBuffer } from "./stage-tool-execution-buffer.js";

export function createStageControlHandle(runtime: LiveStageRuntime): StageControlHandle {
const ensureMessagingSession = async (): Promise<void> => {
const messagePreparation = (): StageUserMessagePreparation => {
const meta = runtime.innerCtx.__sessionMeta();
if (meta.sessionId !== undefined || meta.sessionFile !== undefined) return;
if (meta.sessionId !== undefined || meta.sessionFile !== undefined) {
return { beforePreparation: runtime.throwIfStageMutationBlocked };
}
if (runtime.stageSnapshot.sessionFile !== undefined) {
await runtime.innerCtx.__ensureSessionFromFile(runtime.stageSnapshot.sessionFile);
runtime.captureStageSessionMeta();
return;
return {
sessionFile: runtime.stageSnapshot.sessionFile,
beforePreparation: runtime.throwIfStageMutationBlocked,
};
}
if (isTerminalStage(runtime.stageSnapshot)) {
throw new Error(`atomic-workflows: cannot message stage "${runtime.name}" because no retained session metadata is available.`);
}
return { beforePreparation: runtime.throwIfStageMutationBlocked };
};
const ensureMessagingSession = async (): Promise<void> => {
const sessionFile = messagePreparation().sessionFile;
if (sessionFile === undefined) return;
await runtime.innerCtx.__ensureSessionFromFile(sessionFile);
runtime.captureStageSessionMeta();
};
const toolExecutions = new StageToolExecutionBuffer();
const unsubscribeToolExecutions = runtime.innerCtx.subscribe((event) => toolExecutions.record(event));
Expand Down Expand Up @@ -57,8 +67,7 @@ export function createStageControlHandle(runtime: LiveStageRuntime): StageContro
},
async sendUserMessage(text, options, beforeDelivery) {
runtime.throwIfStageMutationBlocked();
await ensureMessagingSession();
runtime.throwIfStageMutationBlocked();
const preparation = messagePreparation();
const admitDelivery = (): void => {
runtime.throwIfStageMutationBlocked();
beforeDelivery?.();
Expand All @@ -68,6 +77,7 @@ export function createStageControlHandle(runtime: LiveStageRuntime): StageContro
text,
options,
admitDelivery,
preparation,
);
if (action === "steer" || action === "followUp") {
runtime.state.resumeContinuationPending = "queued-user-message";
Expand All @@ -79,13 +89,14 @@ export function createStageControlHandle(runtime: LiveStageRuntime): StageContro
},
async prompt(text: string) {
runtime.throwIfStageMutationBlocked();
await ensureMessagingSession();
const preparation = messagePreparation();
let action: StageUserMessageDeliveryAction | undefined;
try {
action = await runtime.innerCtx.__sendUserMessage(
text,
undefined,
runtime.throwIfStageMutationBlocked,
preparation,
);
} finally {
runtime.captureStageSessionMeta();
Expand Down Expand Up @@ -197,6 +208,9 @@ export function createStageControlHandle(runtime: LiveStageRuntime): StageContro
subscribe(listener: AgentSessionEventListener) {
return runtime.innerCtx.subscribe(listener);
},
subscribeDeliveryActivity(listener) {
return runtime.innerCtx.__subscribeDeliveryActivity(listener);
},
async dispose() {
unsubscribeToolExecutions();
toolExecutions.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function createReplayStageContext(input: {
steer: async () => rejectReplayMutation("steer"),
followUp: async () => rejectReplayMutation("follow up"),
subscribe: () => () => {},
__subscribeDeliveryActivity: () => () => {},
get sessionFile() { return replaySource.sessionFile; },
get sessionId() { return replaySource.sessionId ?? ""; },
setModel: async () => rejectReplayMutation("set model"),
Expand Down
25 changes: 20 additions & 5 deletions packages/workflows/src/runs/foreground/postmortem-stage-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
type StageControlHandle,
type StageControlRegistry,
} from "./stage-control-registry.js";
import type { StageUserMessagePreparation } from "./stage-runner-types.js";

/** Why a terminal stage could not be revived as an interactive post-mortem chat. */
export type PostMortemUnavailableReason =
Expand Down Expand Up @@ -153,10 +154,18 @@ export function createPostMortemStageHandle(
...(defaultSessionDir !== undefined ? { defaultSessionDir } : {}),
});
let disposed = false;
const ensureAttached = async (): Promise<void> => {
const throwIfClosed = (): void => {
if (disposed) throw new Error(`Post-mortem stage chat "${stage.name}" is closed.`);
if (context.__sessionMeta().sessionFile === undefined) {
await context.__ensureSessionFromFile(sessionFile);
};
const messagePreparation = (): StageUserMessagePreparation => ({
...(context.__sessionMeta().sessionFile === undefined ? { sessionFile } : {}),
beforePreparation: throwIfClosed,
});
const ensureAttached = async (): Promise<void> => {
throwIfClosed();
const sessionFileToRestore = messagePreparation().sessionFile;
if (sessionFileToRestore !== undefined) {
await context.__ensureSessionFromFile(sessionFileToRestore);
}
};
return {
Expand All @@ -172,8 +181,13 @@ export function createPostMortemStageHandle(
get agentSession() { return context.__agentSession(); },
async ensureAttached() { await ensureAttached(); },
async sendUserMessage(text, options, beforeDelivery) {
await ensureAttached();
return context.__sendUserMessage(text, options, beforeDelivery);
throwIfClosed();
const preparation = messagePreparation();
const admitDelivery = (): void => {
throwIfClosed();
beforeDelivery?.();
};
return context.__sendUserMessage(text, options, admitDelivery, preparation);
},
async prompt(text: string) {
await ensureAttached();
Expand All @@ -194,6 +208,7 @@ export function createPostMortemStageHandle(
throw new Error("Post-mortem stage chat cannot pause or resume workflow execution.");
},
subscribe(listener: AgentSessionEventListener) { return context.subscribe(listener); },
subscribeDeliveryActivity(listener) { return context.__subscribeDeliveryActivity(listener); },
async dispose() {
if (disposed) return;
disposed = true;
Expand Down
Loading
Loading