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
29 changes: 28 additions & 1 deletion docs/orchestration-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ Lanes belong to the session runtime that started them. `session_shutdown`
(quit, signals, `/new`, `/resume`, `/fork`, and reload) aborts every lane of that
runtime, stops its children, and waits up to three seconds. Children still
running after that are killed, and their temporary directories are removed.
The lane list and widget are cleared, and results that arrive later are dropped.
The lane list and widget are cleared, and results that arrive later are dropped. Background lanes
draw no widget above the editor: `/subagents` reads their live state on demand.
Lanes are not persisted or resumed. Lane children are also registered for the
host's process-exit sweep. Shutting down one runtime never touches another
runtime's lanes. A host that keeps several sessions open, such as a web client,
Expand Down Expand Up @@ -323,6 +324,32 @@ Log files survive their command: they are the only record of what a dev server
printed before it was stopped, so the registry keeps the `logPath` and does not
delete it.

### When a command is released

`session_shutdown` fires for five reasons, and only `quit` ends the session.
`new`, `resume` and `fork` replace the runtime while the old conversation stays
on disk — and, in a multi-session host, on screen — and `reload` keeps the same
session object. Releasing on those would stop a dev server the user is still
watching, so the release is gated on `reason === "quit"`, which covers a host
that is exiting and a session that is deleted. A host killed outright is covered
by the `process.exit` sweep.

### Reading them from a terminal

`/ps` lists this session's background commands with pid, state, elapsed time and
log path, and stops one or all of them. `/subagents` lists the running subagent
lanes with the step each child is on. Both print into the transcript and fold
when long. Both are terminal commands. A graphical host is expected to project
this state itself, where it can stay live instead of being a snapshot; the
concrete reason the commands refuse elsewhere is that the `notify` they depend on
is a no-op in an RPC host, so they would appear to do nothing.

Background lanes draw no widget above the editor. A permanent panel competes
with the conversation for as long as a lane runs, and its rows cannot show which
step a child is on — the question a user actually has. Progress notifications to
the model stay out of the transcript for the same reason; completion and
needs-input still display.

## Cron scheduling and delivery

`cron_create` accepts numeric five-field local-time cron expressions. Fields
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/features/step-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import type { ExtensionAPI, ExtensionFactory, InlineExtension } from "../core/extensions/types.ts";
import type { StepTelemetryReporter } from "../step/telemetry.ts";
import { createStepPlanExtension } from "./step-plan.ts";
import { createStepPsExtension } from "./step-ps.ts";
import { registerStepClarifyUserExtension } from "./step-questionnaire.ts";
import { createStepSubagentExtension, type StepSubagentExtensionOptions } from "./step-subagent.ts";
import { createStepSubagentsCommandExtension } from "./step-subagents-command.ts";
import { createStepTasksExtension } from "./step-tasks.ts";
import { registerWorkflowChildAcl } from "./workflow/acl-extension.ts";
import { createStepWorkflowExtension, type StepWorkflowExtensionOptions } from "./workflow/step-workflow.ts";
Expand All @@ -21,6 +23,8 @@ export interface StepCapabilitiesExtensionOptions {
/** Compose Pi's native UI examples behind one Step-owned inline extension. */
export function createStepCapabilitiesExtension(options: StepCapabilitiesExtensionOptions = {}): ExtensionFactory {
const plan = createStepPlanExtension({ telemetry: options.telemetry });
const ps = createStepPsExtension();
const subagentsCommand = createStepSubagentsCommandExtension();
const tasks = createStepTasksExtension();
const subagent = createStepSubagentExtension({
...options.subagent,
Expand All @@ -41,6 +45,8 @@ export function createStepCapabilitiesExtension(options: StepCapabilitiesExtensi
registerWorkflowChildAcl(pi, options.telemetry);
registerStepClarifyUserExtension(pi, options.telemetry);
plan(pi);
ps(pi);
subagentsCommand(pi);
tasks(pi);
subagent(pi);
workflow(pi);
Expand Down
161 changes: 161 additions & 0 deletions packages/coding-agent/src/features/step-ps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* `/ps`: the session's background commands, in the transcript.
*
* A background command is the one thing a session can leave running that the transcript does not
* show. The status line reports a count, but a count cannot tell the user which pid to stop, what a
* command was, or which one is stuck. This command answers that, in the place a terminal user
* already reads command output.
*
* Deliberately a command rather than a permanent panel: a dev server can run for hours, and a
* block of always-visible rows would compete with the conversation for the rest of the session.
* The status line carries the always-on part; this carries the detail on demand.
*
* Terminal only. A graphical host is expected to project this state itself, where it can stay live
* rather than being a snapshot the user has to re-request. The concrete reason to refuse rather than
* stay silent: `notify` is a no-op in an RPC host, so a command there would print nothing at all.
* The handler refuses at invocation because `registerCommand` runs once per process and cannot see
* the host's mode.
*/

import type { ExtensionAPI, ExtensionCommandContext } from "../core/extensions/types.ts";
import {
listProcesses,
PROCESS_TERM_GRACE_MS,
type ProcessRegistration,
releaseOwnerGracefully,
} from "../core/process-registry.ts";

/** Commands shown before the list is folded. A long-lived session can accumulate many. */
const MAX_LISTED = 16;

/** First line only, bounded: a row identifies a command, it does not reproduce it. */
function labelOf(entry: ProcessRegistration): string {
const firstLine = entry.label.trim().split("\n")[0]?.trim() ?? "";
return firstLine.length > 60 ? `${firstLine.slice(0, 57)}...` : firstLine || "(no command)";
}

/** How long the command has been running, in the compact form a status line uses. */
function elapsedOf(entry: ProcessRegistration, now: number): string {
const seconds = Math.max(0, Math.floor((now - entry.startedAt) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}

function formatList(entries: readonly ProcessRegistration[], now: number): string {
if (entries.length === 0) {
return "Background commands\n\n No background commands running.";
}
const shown = entries.slice(0, MAX_LISTED);
const lines = shown.map((entry) => {
const state = entry.releasing ? "stopping" : "running";
return ` • ${labelOf(entry)}\n pid ${entry.pid} · ${state} · ${elapsedOf(entry, now)}${
entry.logPath ? `\n log ${entry.logPath}` : ""
}`;
});
if (entries.length > shown.length) {
lines.push(` ... and ${entries.length - shown.length} more running`);
}
return ["Background commands", "", ...lines, "", "Stop one with /ps kill <pid>, or all with /ps kill all."].join(
"\n",
);
}

function resolveOwnerId(ctx: ExtensionCommandContext): string | undefined {
try {
return ctx.sessionManager.getSessionId() || undefined;
} catch {
// A context from a replaced session cannot name its owner.
return undefined;
}
}

function backgroundCommands(ownerId: string): ProcessRegistration[] {
return listProcesses(ownerId)
.filter((entry) => entry.kind === "bash")
.sort((left, right) => left.startedAt - right.startedAt);
}

export function createStepPsExtension(): (pi: ExtensionAPI) => void {
return (pi: ExtensionAPI): void => {
pi.registerCommand("ps", {
description: "List or stop this session's background commands",
handler: async (args: string, ctx: ExtensionCommandContext) => {
if (ctx.mode !== "tui") {
ctx.ui.notify(
"`/ps` is a terminal command; this host shows background commands in its own interface.",
"warning",
);
return;
}
const tokens = args.trim().split(/\s+/u).filter(Boolean);
const ownerId = resolveOwnerId(ctx);
if (!ownerId) {
ctx.ui.notify("This session cannot name its background commands.", "warning");
return;
}
if (tokens[0] === "kill") {
const target = tokens[1];
if (!target || tokens.length !== 2) {
ctx.ui.notify("Usage: /ps kill <pid|all>", "warning");
return;
}
const owned = backgroundCommands(ownerId);
if (owned.length === 0) {
ctx.ui.notify("No background commands running.", "info");
return;
}
// Report before waiting, not after: releasing a tree takes the SIGTERM grace window
// (3s by default), and a command that prints nothing for three seconds reads as
// ignored. `notify` is captured now because a fork or `/new` during the wait
// invalidates `ctx`, and a stale context throws on read.
const notify = (message: string, tone: "info" | "warning" = "info"): void => {
try {
ctx.ui.notify(message, tone);
} catch {
// The session was replaced mid-wait; the release already happened.
}
};
if (target === "all") {
// A command already in its grace window is skipped by the registry, so it must be
// counted as stopping rather than silently reported as zero.
const stopping = owned.filter((entry) => entry.releasing).length;
const live = owned.filter((entry) => !entry.releasing);
if (live.length === 0) {
notify(`Already stopping ${stopping} background command${stopping === 1 ? "" : "s"}.`);
return;
}
notify(`Stopping ${live.length} background command${live.length === 1 ? "" : "s"}...`);
// Report `live.length`, not the registry's return value: that counts every tree it
// signalled, including one that exited between this listing and the kill. The
// number the user saw in "Stopping N" is the honest answer.
await releaseOwnerGracefully(ownerId, PROCESS_TERM_GRACE_MS, new Set(live.map((e) => e.pid)));
notify(`Stopped ${live.length} background command${live.length === 1 ? "" : "s"}.`);
return;
}
const pid = Number(target);
if (!Number.isSafeInteger(pid) || pid <= 0) {
ctx.ui.notify("Usage: /ps kill <pid|all>", "warning");
return;
}
// Scoped by construction: the pid must belong to this session's own commands.
if (!owned.some((entry) => entry.pid === pid)) {
ctx.ui.notify(`No background command with pid ${pid} in this session.`, "warning");
return;
}
notify(`Stopping background command ${pid}...`);
await releaseOwnerGracefully(ownerId, PROCESS_TERM_GRACE_MS, new Set([pid]));
notify(`Stopped background command ${pid}.`);
return;
}
if (tokens.length > 0 && tokens[0] !== "list") {
ctx.ui.notify("Usage: /ps [list|kill <pid|all>]", "warning");
return;
}
ctx.ui.notify(formatList(backgroundCommands(ownerId), Date.now()), "info");
},
});
};
}
107 changes: 19 additions & 88 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,11 +680,13 @@ function prependNotice(
* Show a live lane list under the editor for the duration of a blocking
* subagent call.
*
* Background lanes already have their own `aboveEditor` widget; the blocking
* path had none, so a parallel run's only surface was the transcript tool row,
* which the generic collapsed shell truncates. One component instance is reused
* across updates: `setExtensionWidget` disposes the component it replaces, so
* the instance deliberately has no `dispose` and the factory returns it again.
* Only the blocking path draws this: it is the turn's own output, it ends with
* the turn, and there is nothing left to ask about afterwards. Background lanes
* are read through `/subagents` instead, so they add no surface while they run.
* A parallel run's only other surface is the transcript tool row, which the
* generic collapsed shell truncates. One component instance is reused across
* updates: `setExtensionWidget` disposes the component it replaces, so the
* instance deliberately has no `dispose` and the factory returns it again.
*/
async function withSubagentListWidget(
toolCallId: string,
Expand Down Expand Up @@ -758,91 +760,20 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
if (process.env[CHILD_MARKER] === "1") return;

const lanes = new Map<string, BackgroundAgentLane>();
// Every background lane shares one list widget, built the same way as the
// blocking call's list: a row per task with a fixed title and metrics. A
// widget per lane, republished on every streamed delta with the child's
// live text, reordered the lanes on each publish (the host re-inserts the
// key) and changed height as lines wrapped, so the block flickered.
// Background lanes are not drawn under the editor. A panel that sits above the editor for as
// long as a lane runs competes with the conversation, and its rows cannot show what a user
// actually wants next — which step each child is on. `/subagents` answers that on demand; the
// lane snapshots it reads are published by the lifecycle (`publishLaneSnapshot`), so nothing
// here needs to observe lanes for display.
//
// Lanes stay listed until the whole batch settles, so the header reads
// "2/4 complete" rather than shrinking as lanes finish; then it clears and
// the transcript's agent-notification entries take over.
//
// Elapsed is read from the clock at render time. A blocking call gets its
// redraws from the working indicator, but background lanes usually run
// while the parent sits idle, so a 1s tick keeps the column moving; the
// differential renderer only rewrites the cells that changed.
const lanesWidgetKey = "step-agent-lanes";
const shownLanes = new Set<string>();
let lanesWidget: SubagentListWidget | undefined;
let lanesPublished: string | undefined;
let lanesTicker: ReturnType<typeof setInterval> | undefined;
const stopLanesTicker = (): void => {
if (lanesTicker) clearInterval(lanesTicker);
lanesTicker = undefined;
// A blocking `subagent` call still renders its list inline: it is the turn's own output, it
// ends with the turn, and there is nothing to ask about afterwards.
const updateLaneWidget = (_lane: BackgroundAgentLane): void => {
// Retained as the lifecycle host hook: lane snapshots are published there, and a
// replacement host that wants a live view has somewhere to put it.
};
const updateLaneWidget = (lane: BackgroundAgentLane): void => {
if (!lane.ctx.hasUI) return;
shownLanes.add(lane.id);
// `lanes` iterates in creation order; `shownLanes` in first-update order,
// which depends on which child streams first and would shuffle the rows.
const batch = [...lanes.values()].filter((entry) => shownLanes.has(entry.id));
try {
if (batch.every((entry) => entry.status !== "running")) {
shownLanes.clear();
stopLanesTicker();
lanesWidget = undefined;
lanesPublished = undefined;
lane.ctx.ui.setWidget(lanesWidgetKey, undefined);
return;
}
// Each lane's frame is byte-bounded, so its omitted count must be
// summed too: otherwise a two-lane batch under-reports its total.
const omitted = batch.reduce((sum, entry) => sum + (entry.details.omittedResults ?? 0), 0);
const details: StepSubagentDetails = {
...lane.details,
mode: "parallel",
results: batch.flatMap((entry) => entry.details.results),
...(omitted > 0 ? { omittedResults: omitted } : {}),
};
lanesWidget?.setDetails(details);
const signature = subagentListSignature(details);
if (signature === lanesPublished) return;
lanesPublished = signature;
lane.ctx.ui.setWidget(
lanesWidgetKey,
(tui, theme) => {
lanesWidget ??= new SubagentListWidget(details, theme, "background agents");
if (!lanesTicker) {
lanesTicker = setInterval(() => tui.requestRender(), 1_000);
lanesTicker.unref?.();
}
return lanesWidget;
},
{ placement: "aboveEditor" },
);
} catch {
// A host may tear down its UI while a detached child is finishing.
}
};
let widgetCtx: ExtensionContext | undefined;
const clearLaneWidget = (): void => {
shownLanes.clear();
stopLanesTicker();
lanesWidget = undefined;
lanesPublished = undefined;
const ctx = widgetCtx;
widgetCtx = undefined;
if (!ctx?.hasUI) return;
try {
ctx.ui.setWidget(lanesWidgetKey, undefined);
} catch {
// The host may already have torn its UI down during shutdown.
}
};
const trackedUpdateLaneWidget = (lane: BackgroundAgentLane): void => {
if (lane.ctx.hasUI) widgetCtx = lane.ctx;
updateLaneWidget(lane);
// Nothing to clear: this host draws no lane surface.
};
const lifecycle = createLaneLifecycle({
pi,
Expand All @@ -851,7 +782,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
run: (prepared, signal, onUpdate, ctx, laneRuntime) =>
runPreparedSubagent(prepared, signal, onUpdate, ctx, resolved, laneRuntime),
outputDirectory: (ctx) => path.join(ctx.sessionManager?.getSessionDir?.() || resolved.agentDir, "tool-output"),
updateLaneWidget: trackedUpdateLaneWidget,
updateLaneWidget,
clearLaneWidget,
});

Expand Down
Loading
Loading