From f54d3ac5b7bfac1d7fc19d40718c9475c92efdcb Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:18:01 +0800 Subject: [PATCH 1/3] feat(coding-agent): /ps and /subagents, and release background commands on quit only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session_shutdown` fires for five reasons and only one of them ends the session. The cleanup added with the process registry released on all five, so `new`, `resume`, `fork` and `reload` each stopped the session's background commands: opening a new conversation or forking one killed the dev server the user was watching. `process-registry.ts` promises the opposite — `bash` survives a switch and a fork — and the comment beside the call claimed a switch or fork never reaches this handler, which is what made the bug look correct. Gate it 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. Two terminal commands, shaped the same way — print into the transcript, fold when long, scoped to the calling session: - `/ps` lists the session's background commands with pid, state, elapsed time and log path, and stops one (`/ps kill `) or all (`/ps kill all`). `kill all` narrows to command pids, because the registry's owner release is kind-agnostic and stopping subagent trees is not what it promises. It reports before it waits: releasing a tree takes the SIGTERM grace window, and three silent seconds read as ignored. A repeat `kill all` during that window says how many are already stopping instead of "Stopped 0". `notify` is captured before the await, since a fork during the wait invalidates `ctx`. - `/subagents` lists each running lane with its tasks and the step each child is on — the active tool, or the thinking/reply text while it reasons. It reads `SUBAGENT_LANE_CHANNEL`, so it observes lanes without extending their lifetime, and it evicts settled lanes (the map never shrank otherwise). Both refuse in a non-terminal host rather than silently doing nothing: Web and Desktop draw the same state in their own interface, and the `notify` a transcript command depends on is a no-op there. `registerCommand` runs once per process and cannot see the host's mode, so the check is at invocation. `pi.events?.` because a missing bus discarded the whole capabilities extension, taking `/ps`, plan, tasks and subagent with it. A session status line reports how many background commands are running. `skipped` is not counted as failed (it is a chain step that never ran), elapsed time stops at `updatedAt` so a settled task does not read as still running, and a lane whose roster is not populated yet says "starting". Tests: four `session_shutdown` reasons assert the process survives; reverting the guard makes that test fail. --- docs/orchestration-lifecycle.md | 28 ++- .../src/features/step-capabilities.ts | 6 + packages/coding-agent/src/features/step-ps.ts | 152 +++++++++++++++ .../src/features/step-subagents-command.ts | 154 ++++++++++++++++ packages/coding-agent/src/features/step.ts | 67 ++++++- .../src/features/subagent/lane-events.ts | 8 +- .../test/session-process-release.test.ts | 167 +++++++++++++++++ packages/coding-agent/test/step-ps.test.ts | 160 ++++++++++++++++ .../coding-agent/test/step-subagents.test.ts | 173 ++++++++++++++++++ 9 files changed, 905 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/src/features/step-ps.ts create mode 100644 packages/coding-agent/src/features/step-subagents-command.ts create mode 100644 packages/coding-agent/test/session-process-release.test.ts create mode 100644 packages/coding-agent/test/step-ps.test.ts create mode 100644 packages/coding-agent/test/step-subagents.test.ts diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index c63856db0..a676485ec 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -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, @@ -323,6 +324,31 @@ 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: Web and Desktop draw the same state in +their own interface, and the `notify` a transcript command depends on is a no-op +in those hosts, so they refuse there rather than appearing 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 diff --git a/packages/coding-agent/src/features/step-capabilities.ts b/packages/coding-agent/src/features/step-capabilities.ts index 563719291..0dc9107a8 100644 --- a/packages/coding-agent/src/features/step-capabilities.ts +++ b/packages/coding-agent/src/features/step-capabilities.ts @@ -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"; @@ -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, @@ -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); diff --git a/packages/coding-agent/src/features/step-ps.ts b/packages/coding-agent/src/features/step-ps.ts new file mode 100644 index 000000000..6e02f2e20 --- /dev/null +++ b/packages/coding-agent/src/features/step-ps.ts @@ -0,0 +1,152 @@ +/** + * `/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. Web and Desktop draw the same list in the composer strip, so the command would + * duplicate an interface they already have — and worse, `notify` is a no-op in those hosts, so it + * would look like nothing happened. The handler refuses rather than registering conditionally: + * `registerCommand` runs once per process and cannot see the host's mode. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "../core/extensions/types.ts"; +import { listProcesses, 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 , 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 ", "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"}...`); + const count = await releaseOwnerGracefully(ownerId, undefined, new Set(live.map((e) => e.pid))); + notify(`Stopped ${count} background command${count === 1 ? "" : "s"}.`); + return; + } + const pid = Number(target); + if (!Number.isSafeInteger(pid) || pid <= 0) { + ctx.ui.notify("Usage: /ps kill ", "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, undefined, new Set([pid])); + notify(`Stopped background command ${pid}.`); + return; + } + if (tokens.length > 0 && tokens[0] !== "list") { + ctx.ui.notify("Usage: /ps [list|kill ]", "warning"); + return; + } + ctx.ui.notify(formatList(backgroundCommands(ownerId), Date.now()), "info"); + }, + }); + }; +} diff --git a/packages/coding-agent/src/features/step-subagents-command.ts b/packages/coding-agent/src/features/step-subagents-command.ts new file mode 100644 index 000000000..ee595a83c --- /dev/null +++ b/packages/coding-agent/src/features/step-subagents-command.ts @@ -0,0 +1,154 @@ +/** + * `/subagents`: what this session's background agents are doing right now. + * + * Background lanes draw nothing above the editor. A permanent panel would compete with the + * conversation for as long as a lane runs — minutes, sometimes an hour — and its rows cannot show + * the thing a user wants next: which step each child is on. This command answers that on demand. + * + * Shaped like `/ps`: a command that prints into the transcript. The list is a snapshot of the + * current activity, so it is stale the moment it is printed; that is the right shape for something + * the user asks for when they want to know. + * + * Fed by `SUBAGENT_LANE_CHANNEL`, the same session-local bus the Web host projects from. Reading the + * bus rather than the lane map keeps this command outside the lane lifecycle's ownership: it + * observes, it does not extend a lane's lifetime. + * + * Progress notifications stay out of the transcript on purpose (`lane-events.ts`): at roughly one + * update per lane per 15s they would bury the session. Completion and needs-input do display, so the + * outcome is never silent — only the intermediate noise is. + * + * Terminal only, for the same reason as `/ps`: Web and Desktop already draw lane state in their + * inspector, and `notify` is a no-op there. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "../core/extensions/types.ts"; +import type { StepSubagentResultRecord } from "./step-subagent.ts"; +import { SUBAGENT_LANE_CHANNEL, type SubagentLaneSnapshot } from "./subagent/lane-events.ts"; + +/** Agents listed before the list is folded. */ +const MAX_LISTED = 16; + +/** First line only, bounded: a preview identifies the step, it does not reproduce it. */ +function oneLine(text: string | undefined, limit = 100): string | undefined { + const first = text?.trim().split("\n")[0]?.trim(); + if (!first) return undefined; + return first.length > limit ? `${first.slice(0, limit - 3)}...` : first; +} + +function elapsedOf(record: StepSubagentResultRecord, now: number): string { + const startedAt = record.startedAt; + if (!startedAt) return ""; + // A finished task stops counting: `now - startedAt` kept growing for a task that completed a + // minute ago, so a settled row read as still running. + const end = record.status === "running" ? now : (record.updatedAt ?? now); + const seconds = Math.max(0, Math.floor((end - startedAt) / 1000)); + const minutes = Math.floor(seconds / 60); + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +/** + * The step a child is on, in the terms the user thinks in: the tool it called, or what it is + * writing while it reasons. Falls back to the reply text, so a child streaming an answer does not + * read as idle. + */ +function activityOf(record: StepSubagentResultRecord): string | undefined { + if (record.activeTool) { + const args = oneLine(record.activeToolArgs, 60); + return args ? `${record.activeTool} ${args}` : record.activeTool; + } + // `activeThinking` is present on builds that project a separate reasoning stream; read it + // optionally so this stays correct either way. + const thinking = (record as { activeThinking?: string }).activeThinking; + if (thinking) return `thinking: ${oneLine(thinking, 80) ?? ""}`; + if (record.activeText) return `writing: ${oneLine(record.activeText, 80) ?? ""}`; + return undefined; +} + +function statusMark(status: StepSubagentResultRecord["status"]): string { + switch (status) { + case "running": + return "~"; + case "completed": + return "\u2713"; + case "queued": + return "\u00b7"; + case "skipped": + return "-"; + default: + return "x"; + } +} + +function formatLane(snapshot: SubagentLaneSnapshot, now: number): string[] { + const records = snapshot.details.results; + const running = records.filter((record) => record.status === "running").length; + // `skipped` is a chain step that never ran because an earlier one did not complete — a normal + // ending, not a failure. Counting it as failed made a settled chain read as broken. + const failed = records.filter((record) => record.status === "failed" || record.status === "aborted").length; + const total = records.length + (snapshot.details.omittedResults ?? 0); + const summary = [`${total} task${total === 1 ? "" : "s"}`]; + if (running > 0) summary.push(`${running} running`); + if (failed > 0) summary.push(`${failed} failed`); + + // A lane that just launched has no roster yet; say so rather than printing a bare heading. + const lines = [` \u2022 ${snapshot.laneId} \u00b7 ${records.length === 0 ? "starting" : summary.join(", ")}`]; + const shown = records.slice(0, MAX_LISTED); + for (const record of shown) { + const activity = activityOf(record); + const meta = [record.status, elapsedOf(record, now)].filter(Boolean).join(" \u00b7 "); + lines.push(` ${statusMark(record.status)} ${record.agent} \u2014 ${oneLine(record.task, 70) ?? "(no task)"}`); + lines.push(` ${meta}${activity ? ` \u2014 ${activity}` : ""}`); + } + const omitted = total - shown.length; + if (omitted > 0) lines.push(` ... and ${omitted} more`); + return lines; +} + +function formatList(snapshots: readonly SubagentLaneSnapshot[], now: number): string { + const running = snapshots.filter((snapshot) => snapshot.status === "running"); + if (running.length === 0) { + return "Subagents running\n\n No subagents running."; + } + const lines = ["Subagents running", ""]; + for (const snapshot of running) lines.push(...formatLane(snapshot, now)); + return lines.join("\n"); +} + +export function createStepSubagentsCommandExtension(): (pi: ExtensionAPI) => void { + return (pi: ExtensionAPI): void => { + // Latest snapshot per lane, so a lane that finished drops out of the live list. Registered + // once per extension generation: a replaced session drops the old subscription with its + // runner, and re-registering on the bus would leak one handler per reload. + const lanes = new Map(); + // `events` is required by the type but absent on some embedders, and a throw here discards + // the whole capabilities extension — taking `/ps`, plan, tasks and subagent with it. + pi.events?.on?.(SUBAGENT_LANE_CHANNEL, (payload: unknown) => { + const snapshot = payload as SubagentLaneSnapshot | undefined; + if (!snapshot || typeof snapshot.laneId !== "string" || !snapshot.details) return; + // Keep only live lanes: the map is never evicted elsewhere, and a finished lane's snapshot + // (up to 48KB) would stay for the life of the session. + if (snapshot.status === "running") lanes.set(snapshot.laneId, snapshot); + else lanes.delete(snapshot.laneId); + }); + + pi.registerCommand("subagents", { + description: "Show what this session's background subagents are doing", + handler: async (args: string, ctx: ExtensionCommandContext) => { + if (ctx.mode !== "tui") { + ctx.ui.notify( + "`/subagents` is a terminal command; this host shows subagents in its own interface.", + "warning", + ); + return; + } + const tokens = args.trim().split(/\s+/u).filter(Boolean); + if (tokens.length > 0 && tokens[0] !== "list") { + ctx.ui.notify("Usage: /subagents [list]", "warning"); + return; + } + ctx.ui.notify(formatList([...lanes.values()], Date.now()), "info"); + }, + }); + }; +} diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index c947bb0d6..6d4da501d 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -8,7 +8,12 @@ import type { ExtensionUIContext, InlineExtension, } from "../core/extensions/types.ts"; -import { releaseOwnerGracefully } from "../core/process-registry.ts"; +import { + listProcesses, + type ProcessRegistration, + releaseOwnerGracefully, + subscribeToProcessRegistry, +} from "../core/process-registry.ts"; import { BUILTIN_SLASH_COMMANDS } from "../core/slash-commands.ts"; import type { FeedbackIdentity } from "../step/feedback/types.ts"; import { STEP_INIT_PROMPT } from "../step/init-prompt.ts"; @@ -146,6 +151,8 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi }); let permissions = new StepPermissionController(resolvePermissionOptions(options)); let notify: ((message: string, type?: "info" | "warning" | "error") => void) | undefined; + /** Live status-line subscription for this session's background commands. */ + let unsubscribeBackgroundStatus: (() => void) | undefined; let autoResumeAllowed = false; let nativeRetryPreference: boolean | undefined; let nativeRetryOverridden = false; @@ -294,6 +301,11 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi pi.on("session_start", (_event, ctx) => { const generation = ++permissionSessionGeneration; fullAccessAcknowledged = false; + // The status line reports this session's background commands. Re-subscribe on every + // start because a replaced session gets a new id; the previous subscription is dropped in + // `session_shutdown`. + unsubscribeBackgroundStatus?.(); + unsubscribeBackgroundStatus = publishBackgroundCommandStatus(ctx); notify = ctx.ui.notify; // During project-trust probing Pi loads inline extensions while the // project scope is hidden. Rehydrate once the final trust decision has @@ -337,7 +349,7 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi publishStepPermissionStatus(ctx.ui, state); }); - pi.on("session_shutdown", (_event, ctx) => { + pi.on("session_shutdown", (event, ctx) => { permissionSessionGeneration++; // Timers and UI callbacks are scoped to the old session. Cancel them before // Pi tears down its extension runner, and restore the native retry setting @@ -347,11 +359,19 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi // A "this session" Full Access grant must not carry into the next // session; only the persisted acknowledgment does. fullAccessAcknowledged = false; - // Background commands belong to the session that started them. A session switch or a - // fork never reaches this handler (it keeps the same session object and the old one - // stays open), so reaching here means the session is really going away: release exactly - // its own trees, and leave every other session's dev servers running. - releaseSessionProcesses(ctx); + unsubscribeBackgroundStatus?.(); + unsubscribeBackgroundStatus = undefined; + ctx.ui.setStatus("background-commands", undefined); + // Background commands belong to the session that started them, and they outlive + // everything except the session itself. `session_shutdown` fires for five reasons, and + // four of them do NOT end the session: `new`, `resume` and `fork` all 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. Killing on those would stop the dev server + // the user is watching the moment they open a new conversation or fork one. + // + // `quit` is the one reason that means gone: the host is exiting, or a session is being + // deleted. `process.exit` covers a host that never gets here. + if (event.reason === "quit") releaseSessionProcesses(ctx); if (nativeRetryOverridden && nativeRetryPreference !== undefined && ctx.setAutoRetryEnabled) { try { ctx.setAutoRetryEnabled(nativeRetryPreference); @@ -528,6 +548,39 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi * persisted preset as `initialPreset` would otherwise outrank env aliases in * Pi's resolver, so only inject it when no policy override is present. */ +/** + * Show this session's background commands in the host's status line. + * + * The footer is where a terminal user sees state that outlives a turn: plan mode and goal state + * already live there. A background command belongs in that set — it is the one thing a session can + * leave running that the user cannot see in the transcript, and it is exactly what they need to + * stop before deleting the session. + * + * Returns the unsubscribe function. Subscribing per session, not globally, is what keeps a session + * from reporting another's commands: the registry is host-wide, the status line is not. + */ +function publishBackgroundCommandStatus(ctx: ExtensionContext): () => void { + let ownerId: string; + try { + ownerId = ctx.sessionManager.getSessionId(); + } catch { + return () => {}; + } + if (!ownerId) return () => {}; + const update = (entries: readonly ProcessRegistration[]): void => { + const mine = entries.filter((entry) => entry.ownerId === ownerId && entry.kind === "bash"); + if (mine.length === 0) { + ctx.ui.setStatus("background-commands", undefined); + return; + } + const stopping = mine.filter((entry) => entry.releasing).length; + const label = `${mine.length} background command${mine.length === 1 ? "" : "s"}`; + ctx.ui.setStatus("background-commands", stopping > 0 ? `${label} · stopping ${stopping}` : label); + }; + update(listProcesses(ownerId)); + return subscribeToProcessRegistry(update); +} + /** * Release the background process trees one session owns. * diff --git a/packages/coding-agent/src/features/subagent/lane-events.ts b/packages/coding-agent/src/features/subagent/lane-events.ts index b8f31b3ee..d884e8fc8 100644 --- a/packages/coding-agent/src/features/subagent/lane-events.ts +++ b/packages/coding-agent/src/features/subagent/lane-events.ts @@ -283,8 +283,12 @@ export function notifyLaneEvent( content: `` + `${escapeXmlAttr(body)}`, - // Progress is for the parent model; the user already watches the lanes - // widget, and a transcript entry every 15s per lane buries the session. + // Progress is for the parent model only. A lane reports every ~15s, and an entry per lane + // at that rate buries the session; a user who wants live state asks with `/subagents`. + // Terminal and needs-input events still display, so the outcome is never silent. + // + // This is deliberate, not a gap. Suppressing only progress keeps the transcript readable + // while still reporting what changed. display: event !== "background_progress", details, }, diff --git a/packages/coding-agent/test/session-process-release.test.ts b/packages/coding-agent/test/session-process-release.test.ts new file mode 100644 index 000000000..92d6284a0 --- /dev/null +++ b/packages/coding-agent/test/session-process-release.test.ts @@ -0,0 +1,167 @@ +import { execFileSync, spawn } from "node:child_process"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ExtensionAPI } from "../src/core/extensions/types.ts"; +import { + forgetProcess, + listProcesses, + registerProcess, + releaseAllProcessesSync, +} from "../src/core/process-registry.ts"; +import { createStepExtension } from "../src/features/step.ts"; + +const spawned: number[] = []; + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + } catch { + return false; + } + try { + const state = execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf8" }).trim(); + return !state.startsWith("Z"); + } catch { + return false; + } +} + +function spawnLongLived(): number { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1 << 30)"], { + stdio: "ignore", + detached: true, + }); + child.unref(); + if (child.pid === undefined) throw new Error("child did not start"); + spawned.push(child.pid); + return child.pid; +} + +/** Register the extension and return a dispatcher for one of its event handlers. */ +function stepHandlers(sessionId: string) { + const on = vi.fn(); + createStepExtension({ permission: { env: {}, initialPreset: "approve-for-me" } })({ + on, + registerProvider: vi.fn(), + registerCommand: vi.fn(), + sendUserMessage: vi.fn(), + } as unknown as ExtensionAPI); + const handler = (name: string) => + on.mock.calls.filter(([event]) => event === name).at(-1)?.[1] as + | ((event: unknown, ctx: unknown) => void) + | undefined; + const ctx = { + hasUI: false, + ui: { notify: vi.fn(), setStatus: vi.fn() }, + isIdle: () => true, + hasPendingMessages: () => false, + autoRetryEnabled: false, + setAutoRetryEnabled: vi.fn(), + sessionManager: { getSessionId: () => sessionId }, + }; + return { handler, ctx }; +} + +afterEach(() => { + releaseAllProcessesSync(); + for (const pid of spawned.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already gone. + } + } +}); + +describe("session-scoped process release", () => { + test("session_shutdown releases this session's trees only", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const other = `session-${Math.random().toString(16).slice(2)}`; + const mine = spawnLongLived(); + const theirs = spawnLongLived(); + registerProcess({ pid: mine, kind: "bash", ownerId: session, label: "npm run dev" }); + registerProcess({ pid: theirs, kind: "bash", ownerId: other, label: "other dev server" }); + + const { handler, ctx } = stepHandlers(session); + handler("session_shutdown")?.({ reason: "quit" }, ctx); + + // The release is fire-and-forget: the tree dies first, then its record is dropped once the + // grace window settles. Wait for the record, not just the signal. + const deadline = Date.now() + 15_000; + while (listProcesses(session).length > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + + expect(alive(mine)).toBe(false); + expect(alive(theirs)).toBe(true); + expect(listProcesses(session)).toEqual([]); + expect(listProcesses(other)).toHaveLength(1); + }, 20_000); + + test("session_shutdown only releases on quit: new, resume, fork and reload keep the dev server", async () => { + // The regression this locks: `session_shutdown` fires for five reasons and only `quit` ends the + // session. `new`/`resume`/`fork` replace the runtime while the old conversation stays on disk, + // and `reload` keeps the same session object — killing on those stops a dev server the user is + // still watching. Types, so a new reason cannot be added without revisiting this list. + for (const reason of ["new", "resume", "fork", "reload"] as const) { + const session = `session-${Math.random().toString(16).slice(2)}`; + const pid = spawnLongLived(); + registerProcess({ pid, kind: "bash", ownerId: session, label: "npm run dev" }); + + const { handler, ctx } = stepHandlers(session); + handler("session_shutdown")?.({ reason }, ctx); + + // Give a mistaken release time to land before asserting it did not happen. + await new Promise((r) => setTimeout(r, 300)); + expect(alive(pid), `reason=${reason} must not release`).toBe(true); + expect(listProcesses(session)).toHaveLength(1); + forgetProcess(pid); + } + }, 20_000); + + test("session_start does not release anything: starting a conversation must not kill its own dev server", async () => { + // The regression this locks: the release call was once wired into `session_start`, so opening + // a session killed the background commands that session owned. + const session = `session-${Math.random().toString(16).slice(2)}`; + const mine = spawnLongLived(); + registerProcess({ pid: mine, kind: "bash", ownerId: session, label: "npm run dev" }); + + const { handler, ctx } = stepHandlers(session); + handler("session_start")?.({}, ctx); + + // Give a mistaken release time to land before asserting it did not happen. + await new Promise((r) => setTimeout(r, 1_000)); + + expect(alive(mine)).toBe(true); + expect(listProcesses(session)).toHaveLength(1); + }, 20_000); +}); + +describe("background command status line", () => { + test("reports this session's commands, and clears when they are gone", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const other = `session-${Math.random().toString(16).slice(2)}`; + const { handler, ctx } = stepHandlers(session); + const setStatus = ctx.ui.setStatus as ReturnType; + + handler("session_start")?.({}, ctx); + // Nothing to show yet. `setStatus` is shared with other Step status lines, so read only this + // key rather than the last call overall. + const lastText = () => + setStatus.mock.calls.filter(([key]) => key === "background-commands").at(-1)?.[1] as string | undefined; + expect(lastText()).toBeUndefined(); + + const mine = spawnLongLived(); + const theirs = spawnLongLived(); + registerProcess({ pid: mine, kind: "bash", ownerId: session, label: "npm run dev" }); + registerProcess({ pid: theirs, kind: "bash", ownerId: other, label: "other" }); + // The registry notifies subscribers on change; the status line counts only this session's. + expect(lastText()).toBe("1 background command"); + + // A subagent is not a background command and must not inflate the count. + registerProcess({ pid: spawnLongLived(), kind: "subagent", ownerId: session, label: "agent" }); + expect(lastText()).toBe("1 background command"); + + handler("session_shutdown")?.({ reason: "quit" }, ctx); + expect(lastText()).toBeUndefined(); + }, 20_000); +}); diff --git a/packages/coding-agent/test/step-ps.test.ts b/packages/coding-agent/test/step-ps.test.ts new file mode 100644 index 000000000..76c0dabaf --- /dev/null +++ b/packages/coding-agent/test/step-ps.test.ts @@ -0,0 +1,160 @@ +import { execFileSync, spawn } from "node:child_process"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ExtensionAPI } from "../src/core/extensions/types.ts"; +import { registerProcess, releaseAllProcessesSync } from "../src/core/process-registry.ts"; +import { createStepPsExtension } from "../src/features/step-ps.ts"; + +const spawned: number[] = []; + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + } catch { + return false; + } + try { + const state = execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf8" }).trim(); + return !state.startsWith("Z"); + } catch { + return false; + } +} + +function spawnLongLived(): number { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1 << 30)"], { + stdio: "ignore", + detached: true, + }); + child.unref(); + if (child.pid === undefined) throw new Error("child did not start"); + spawned.push(child.pid); + return child.pid; +} + +/** Run one `/ps` invocation and return what it told the user. */ +async function runPs(sessionId: string, args: string, mode = "tui"): Promise { + const on = vi.fn(); + const commands = new Map Promise }>(); + createStepPsExtension()({ + on, + registerCommand: (name: string, definition: { handler: (args: string, ctx: unknown) => Promise }) => { + commands.set(name, definition); + }, + } as unknown as ExtensionAPI); + const command = commands.get("ps"); + if (!command) throw new Error("/ps was not registered"); + const notify = vi.fn(); + await command.handler(args, { + mode, + sessionManager: { getSessionId: () => sessionId }, + ui: { notify }, + }); + return notify.mock.calls.map(([message]) => message as string).join("\n"); +} + +afterEach(() => { + releaseAllProcessesSync(); + for (const pid of spawned.splice(0)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already gone. + } + } +}); + +describe("/ps", () => { + test("lists this session's background commands only", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const other = `session-${Math.random().toString(16).slice(2)}`; + const mine = spawnLongLived(); + registerProcess({ pid: mine, kind: "bash", ownerId: session, label: "npm run dev", logPath: "/tmp/dev.log" }); + registerProcess({ pid: spawnLongLived(), kind: "bash", ownerId: other, label: "someone else's server" }); + // A subagent is not a process in the `ps` sense, and is reported by the lane widget. + registerProcess({ pid: spawnLongLived(), kind: "subagent", ownerId: session, label: "agent x" }); + + const text = await runPs(session, ""); + + expect(text).toContain("Background commands"); + expect(text).toContain("npm run dev"); + expect(text).toContain(`pid ${mine}`); + expect(text).toContain("/tmp/dev.log"); + expect(text).not.toContain("someone else's server"); + expect(text).not.toContain("agent x"); + }); + + test("reports an empty list without pretending something failed", async () => { + const text = await runPs(`session-${Math.random().toString(16).slice(2)}`, "list"); + expect(text).toContain("No background commands running."); + }); + + test("folds a long list instead of printing every row", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + for (let index = 0; index < 20; index += 1) { + registerProcess({ pid: spawnLongLived(), kind: "bash", ownerId: session, label: `command ${index}` }); + } + const text = await runPs(session, "list"); + expect(text).toContain("command 15"); + expect(text).not.toContain("command 16"); + expect(text).toContain("... and 4 more running"); + }); + + test("kill stops that command and leaves the session's others running", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const target = spawnLongLived(); + const bystander = spawnLongLived(); + registerProcess({ pid: target, kind: "bash", ownerId: session, label: "to stop" }); + registerProcess({ pid: bystander, kind: "bash", ownerId: session, label: "keep" }); + + const text = await runPs(session, `kill ${target}`); + + expect(text).toContain(`Stopped background command ${target}`); + expect(alive(target)).toBe(false); + expect(alive(bystander)).toBe(true); + }, 20_000); + + test("kill refuses a pid owned by another session", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const other = `session-${Math.random().toString(16).slice(2)}`; + const foreign = spawnLongLived(); + registerProcess({ pid: foreign, kind: "bash", ownerId: other, label: "not yours" }); + + const text = await runPs(session, `kill ${foreign}`); + + // No commands at all in this session, so the refusal is the empty-list message. + expect(text).toContain("No background commands running."); + expect(alive(foreign)).toBe(true); + }); + + test("kill all stops only this session's background commands, never a subagent", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + const bash = spawnLongLived(); + const agent = spawnLongLived(); + registerProcess({ pid: bash, kind: "bash", ownerId: session, label: "dev server" }); + registerProcess({ pid: agent, kind: "subagent", ownerId: session, label: "agent x" }); + + const text = await runPs(session, "kill all"); + + expect(text).toContain("Stopped 1 background command"); + expect(alive(bash)).toBe(false); + expect(alive(agent)).toBe(true); + }, 20_000); + + test("refuses to run outside the terminal", async () => { + // Web and Desktop show background commands in the composer strip; a transcript command there + // would duplicate it, and `notify` is a no-op in those hosts. + for (const mode of ["rpc", "json", "print"] as const) { + const text = await runPs(`session-${Math.random().toString(16).slice(2)}`, "", mode); + expect(text).toContain("terminal command"); + } + }); + + test("a malformed invocation explains the usage instead of guessing", async () => { + const session = `session-${Math.random().toString(16).slice(2)}`; + registerProcess({ pid: spawnLongLived(), kind: "bash", ownerId: session, label: "dev server" }); + for (const args of ["kill", "kill not-a-pid", "bogus"]) { + const text = await runPs(session, args); + expect(text).toContain("Usage: /ps"); + } + }); +}); diff --git a/packages/coding-agent/test/step-subagents.test.ts b/packages/coding-agent/test/step-subagents.test.ts new file mode 100644 index 000000000..15bae9eb0 --- /dev/null +++ b/packages/coding-agent/test/step-subagents.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ExtensionAPI } from "../src/core/extensions/types.ts"; +import { createStepSubagentsCommandExtension } from "../src/features/step-subagents-command.ts"; +import { SUBAGENT_LANE_CHANNEL, type SubagentLaneSnapshot } from "../src/features/subagent/lane-events.ts"; + +type Handler = (data: unknown) => void; + +/** An extension API that records commands and the lane bus, so a test can publish snapshots. */ +function harness() { + const commands = new Map Promise }>(); + const bus = new Map>(); + const api = { + registerCommand: (name: string, definition: { handler: (args: string, ctx: unknown) => Promise }) => { + commands.set(name, definition); + }, + events: { + emit: (channel: string, data: unknown) => { + for (const handler of bus.get(channel) ?? []) handler(data); + }, + on: (channel: string, handler: Handler) => { + const set = bus.get(channel) ?? new Set(); + set.add(handler); + bus.set(channel, set); + return () => set.delete(handler); + }, + }, + } as unknown as ExtensionAPI; + createStepSubagentsCommandExtension()(api); + + const publish = (snapshot: SubagentLaneSnapshot) => { + for (const handler of bus.get(SUBAGENT_LANE_CHANNEL) ?? []) handler(snapshot); + }; + + const run = async (args: string, mode = "tui"): Promise => { + const command = commands.get("subagents"); + if (!command) throw new Error("/subagents was not registered"); + const notify = vi.fn(); + await command.handler(args, { mode, ui: { notify } }); + return (notify.mock.calls.at(-1)?.[0] as string | undefined) ?? ""; + }; + return { publish, run }; +} + +function snapshot(overrides: Partial & { laneId: string }): SubagentLaneSnapshot { + const now = Date.now(); + return { + status: "running", + details: { + mode: "parallel", + agentScope: "both", + userAgentsDir: "/tmp/agents", + projectAgentsDir: null, + agentId: overrides.laneId, + startedAt: now - 65_000, + updatedAt: now, + results: [ + { + agent: "general", + agentSource: "builtin", + task: "refactor utils", + status: "running", + activeTool: "edit_file", + activeToolArgs: '{"path":"src/utils.ts"}', + startedAt: now - 65_000, + updatedAt: now, + }, + ], + ...overrides.details, + }, + ...overrides, + } as SubagentLaneSnapshot; +} + +describe("/subagents", () => { + test("reports nothing when no lane is running", async () => { + const { run } = harness(); + expect(await run("")).toContain("No subagents running."); + }); + + test("shows the lane, its task, and the step the child is on", async () => { + const { publish, run } = harness(); + publish(snapshot({ laneId: "lane_abc12345" })); + + const text = await run("list"); + + expect(text).toContain("Subagents running"); + expect(text).toContain("lane_abc12345"); + expect(text).toContain("refactor utils"); + expect(text).toContain("general"); + // The activity line is the whole point: which tool the child is in. + expect(text).toContain("edit_file"); + expect(text).toContain("1m 5s"); + }); + + test("falls back to thinking or reply text so a reasoning child does not read as idle", async () => { + const { publish, run } = harness(); + const now = Date.now(); + publish({ + laneId: "lane_think", + status: "running", + details: { + mode: "single", + agentScope: "builtin" as never, + userAgentsDir: "/tmp", + projectAgentsDir: null, + startedAt: now, + updatedAt: now, + results: [ + { + agent: "general", + agentSource: "builtin", + task: "reason about it", + status: "running", + activeThinking: "weighing two approaches", + startedAt: now, + updatedAt: now, + }, + ], + }, + } as unknown as SubagentLaneSnapshot); + + expect(await run("")).toContain("thinking: weighing two approaches"); + }); + + test("a settled lane leaves the live list", async () => { + const { publish, run } = harness(); + publish(snapshot({ laneId: "lane_done", status: "completed" })); + expect(await run("")).toContain("No subagents running."); + }); + + test("folds a long roster instead of printing every task", async () => { + const { publish, run } = harness(); + const now = Date.now(); + publish({ + laneId: "lane_many", + status: "running", + details: { + mode: "parallel", + agentScope: "both", + userAgentsDir: "/tmp", + projectAgentsDir: null, + startedAt: now, + updatedAt: now, + results: Array.from({ length: 20 }, (_unused, index) => ({ + agent: "general", + agentSource: "builtin" as const, + task: `task ${index}`, + status: "running" as const, + startedAt: now, + updatedAt: now, + })), + }, + } as unknown as SubagentLaneSnapshot); + + const text = await run(""); + expect(text).toContain("task 15"); + expect(text).not.toContain("task 16"); + expect(text).toContain("... and 4 more"); + }); + + test("refuses to run outside the terminal", async () => { + // Web and Desktop draw lane state in their inspector; `notify` is a no-op there. + const { run } = harness(); + for (const mode of ["rpc", "json", "print"] as const) { + expect(await run("", mode)).toContain("terminal command"); + } + }); + + test("a malformed invocation explains the usage", async () => { + const { run } = harness(); + expect(await run("bogus")).toContain("Usage: /subagents"); + }); +}); From f8f0b3cd8f255f35e5bec51347eecda215d4c92d Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:28:34 +0800 Subject: [PATCH 2/3] fix(coding-agent): tolerate a released UI while clearing the status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session_shutdown` ran `ctx.ui.setStatus` unconditionally. A host that has already torn down its UI — or a test that drives the handler without one — has no `ui`, so shutdown threw instead of finishing. Clearing a footer line is best effort; a missing UI must not turn teardown into an error. --- packages/coding-agent/src/features/step.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index 6d4da501d..3fc0efb10 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -361,7 +361,13 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi fullAccessAcknowledged = false; unsubscribeBackgroundStatus?.(); unsubscribeBackgroundStatus = undefined; - ctx.ui.setStatus("background-commands", undefined); + // A host tearing down may already have released its UI; clearing a footer line must not + // turn shutdown into an error. + try { + ctx.ui.setStatus("background-commands", undefined); + } catch { + // UI already gone. + } // Background commands belong to the session that started them, and they outlive // everything except the session itself. `session_shutdown` fires for five reasons, and // four of them do NOT end the session: `new`, `resume` and `fork` all replace the runtime From 4edae59c630585b1077eb8839e2e31debb5cb3ff Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:52:14 +0800 Subject: [PATCH 3/3] fix(coding-agent): actually remove the lane widget, and tighten three command details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit described removing the background-lane widget, and the doc and `lane-events.ts` comment both said it was gone, but the change was only in the desktop tree: `step-subagent.ts` still drew it. Three places claimed a state that did not exist. Remove it here: `updateLaneWidget` and `clearLaneWidget` become the no-ops the host hook expects, and `test/subagent-background-tui.test.ts` now asserts no widget is ever set — re-adding one fails it. `publishLaneSnapshot` is called from the lifecycle wrapper *before* the host hook, so snapshot publishing is unchanged; deleting that call fails five tests in `step-subagent-events.test.ts`. Also from review: - `/ps` and `/subagents` now say why they are terminal-only without claiming another repository's UI as the reason. The checkable reason is local: `notify` is a no-op in an RPC host. - The grace argument is passed explicitly instead of relying on a positional `undefined` to reach the default; a signature change would otherwise silently pick a different window. - `Stopped N` reports the count the user saw in `Stopping N`. The registry's return value includes a tree that exited between the listing and the kill, so it could read higher than what was shown. - The cross-session refusal test now gives the session one command of its own first. Before, the empty-list branch answered and the test would pass even if the pid check were broken. Not changed: `/subagents` needs no session filter. `createAgentSessionServices` builds a `DefaultResourceLoader` per session and does not pass an `eventBus`, so `createEventBus()` runs per session — every subscription sees only its own session's lane snapshots. Verified by comparing `resourceLoader.eventBus` across two sessions. --- docs/orchestration-lifecycle.md | 7 +- packages/coding-agent/src/features/step-ps.ts | 25 ++-- .../src/features/step-subagent.ts | 107 ++++-------------- .../src/features/step-subagents-command.ts | 4 +- packages/coding-agent/test/step-ps.test.ts | 11 +- .../test/subagent-background-tui.test.ts | 52 +++------ 6 files changed, 65 insertions(+), 141 deletions(-) diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index a676485ec..3733790d8 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -339,9 +339,10 @@ by the `process.exit` sweep. `/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: Web and Desktop draw the same state in -their own interface, and the `notify` a transcript command depends on is a no-op -in those hosts, so they refuse there rather than appearing to do nothing. +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 diff --git a/packages/coding-agent/src/features/step-ps.ts b/packages/coding-agent/src/features/step-ps.ts index 6e02f2e20..c2be39bc3 100644 --- a/packages/coding-agent/src/features/step-ps.ts +++ b/packages/coding-agent/src/features/step-ps.ts @@ -10,14 +10,20 @@ * 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. Web and Desktop draw the same list in the composer strip, so the command would - * duplicate an interface they already have — and worse, `notify` is a no-op in those hosts, so it - * would look like nothing happened. The handler refuses rather than registering conditionally: - * `registerCommand` runs once per process and cannot see the host's mode. + * 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, type ProcessRegistration, releaseOwnerGracefully } from "../core/process-registry.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; @@ -122,8 +128,11 @@ export function createStepPsExtension(): (pi: ExtensionAPI) => void { return; } notify(`Stopping ${live.length} background command${live.length === 1 ? "" : "s"}...`); - const count = await releaseOwnerGracefully(ownerId, undefined, new Set(live.map((e) => e.pid))); - notify(`Stopped ${count} background command${count === 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); @@ -137,7 +146,7 @@ export function createStepPsExtension(): (pi: ExtensionAPI) => void { return; } notify(`Stopping background command ${pid}...`); - await releaseOwnerGracefully(ownerId, undefined, new Set([pid])); + await releaseOwnerGracefully(ownerId, PROCESS_TERM_GRACE_MS, new Set([pid])); notify(`Stopped background command ${pid}.`); return; } diff --git a/packages/coding-agent/src/features/step-subagent.ts b/packages/coding-agent/src/features/step-subagent.ts index c2709d69b..5133a5c4f 100644 --- a/packages/coding-agent/src/features/step-subagent.ts +++ b/packages/coding-agent/src/features/step-subagent.ts @@ -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, @@ -758,91 +760,20 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption if (process.env[CHILD_MARKER] === "1") return; const lanes = new Map(); - // 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(); - let lanesWidget: SubagentListWidget | undefined; - let lanesPublished: string | undefined; - let lanesTicker: ReturnType | 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, @@ -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, }); diff --git a/packages/coding-agent/src/features/step-subagents-command.ts b/packages/coding-agent/src/features/step-subagents-command.ts index ee595a83c..351640b17 100644 --- a/packages/coding-agent/src/features/step-subagents-command.ts +++ b/packages/coding-agent/src/features/step-subagents-command.ts @@ -17,8 +17,8 @@ * update per lane per 15s they would bury the session. Completion and needs-input do display, so the * outcome is never silent — only the intermediate noise is. * - * Terminal only, for the same reason as `/ps`: Web and Desktop already draw lane state in their - * inspector, and `notify` is a no-op there. + * Terminal only, for the same reason as `/ps`: a graphical host projects lane state itself, and + * `notify` is a no-op in an RPC host. */ import type { ExtensionAPI, ExtensionCommandContext } from "../core/extensions/types.ts"; diff --git a/packages/coding-agent/test/step-ps.test.ts b/packages/coding-agent/test/step-ps.test.ts index 76c0dabaf..b0439e33c 100644 --- a/packages/coding-agent/test/step-ps.test.ts +++ b/packages/coding-agent/test/step-ps.test.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn } from "node:child_process"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { ExtensionAPI } from "../src/core/extensions/types.ts"; -import { registerProcess, releaseAllProcessesSync } from "../src/core/process-registry.ts"; +import { forgetProcess, registerProcess, releaseAllProcessesSync } from "../src/core/process-registry.ts"; import { createStepPsExtension } from "../src/features/step-ps.ts"; const spawned: number[] = []; @@ -117,13 +117,18 @@ describe("/ps", () => { const session = `session-${Math.random().toString(16).slice(2)}`; const other = `session-${Math.random().toString(16).slice(2)}`; const foreign = spawnLongLived(); + const mine = spawnLongLived(); registerProcess({ pid: foreign, kind: "bash", ownerId: other, label: "not yours" }); + // The session must own something, or the empty-list branch answers first and this test would + // pass even if cross-session refusal were broken. + registerProcess({ pid: mine, kind: "bash", ownerId: session, label: "mine" }); const text = await runPs(session, `kill ${foreign}`); - // No commands at all in this session, so the refusal is the empty-list message. - expect(text).toContain("No background commands running."); + expect(text).toContain(`No background command with pid ${foreign} in this session.`); expect(alive(foreign)).toBe(true); + expect(alive(mine)).toBe(true); + forgetProcess(mine); }); test("kill all stops only this session's background commands, never a subagent", async () => { diff --git a/packages/coding-agent/test/subagent-background-tui.test.ts b/packages/coding-agent/test/subagent-background-tui.test.ts index a824ebd23..46379a547 100644 --- a/packages/coding-agent/test/subagent-background-tui.test.ts +++ b/packages/coding-agent/test/subagent-background-tui.test.ts @@ -1,4 +1,3 @@ -import { visibleWidth } from "@step-harness/pi-tui"; import { expect, test, vi } from "vitest"; import type { ExtensionAPI, ExtensionContext } from "../src/core/extensions/types.ts"; import type { CustomMessage } from "../src/core/messages.ts"; @@ -60,6 +59,7 @@ function harness(): { tools: Map Promise }>; ctx: ExtensionContext; calls: WidgetCall[]; + sent: Array<{ customType?: string }>; } { const tools = new Map Promise }>(); const api = { @@ -73,10 +73,12 @@ function harness(): { setActiveTools: () => {}, getFlag: () => false, appendEntry: () => {}, - sendMessage: () => {}, + sendMessage: (message: { customType?: string }) => sent.push(message), sendUserMessage: () => {}, } as unknown as ExtensionAPI; const calls: WidgetCall[] = []; + /** Agent notifications, used as the deterministic "this lane settled" signal. */ + const sent: Array<{ customType?: string }> = []; const ctx = { mode: "tui", hasUI: true, @@ -100,7 +102,7 @@ function harness(): { }, sessionManager: { getEntries: () => [] }, } as unknown as ExtensionContext; - return { api, tools, ctx, calls }; + return { api, tools, ctx, calls, sent }; } async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { @@ -111,7 +113,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise { +test("background lanes draw no widget: /subagents is where they are read", async () => { const { api, tools, ctx, calls } = harness(); const gates = new Map void>(); const inputs = new Map(); @@ -135,42 +137,18 @@ test("background lanes share one stable list widget that clears when the batch s } await waitFor(() => inputs.size === 2); - // A burst of streamed deltas only moves the live text; none of it reaches - // the widget, so it is not republished. - const before = calls.length; - for (const delta of ["Now let me", "Now let me look at the section", "- INVERT_IF: `if cond:`"]) { - inputs.get("beta task")!.onUpdate?.({ ...runResult("", 0), exitCode: -1, activeText: delta, messages: [] }); - } - expect(calls.length).toBe(before); - - expect(new Set(calls.map((call) => call.key))).toEqual(new Set(["step-agent-lanes"])); - // Live frames coalesce on a repaint budget, so the roster showing both lanes - // running arrives on the next publish rather than synchronously. - await waitFor(() => (calls.at(-1)?.component?.render(100)[0] ?? "").includes("2 running")); - const widget = calls.at(-1)!.component!; - const rows = widget.render(100); - expect(rows[0]).toContain("background agents"); - expect(rows[0]).toContain("2 running"); - // Rows keep spawn order regardless of which lane updated last, never show - // the child's streamed text, and fit the width on one line each. - expect(rows.findIndex((row) => row.includes("alpha task"))).toBeLessThan( - rows.findIndex((row) => row.includes("beta task")), - ); - expect(rows.join("\n")).not.toContain("INVERT_IF"); - for (const row of rows) expect(visibleWidth(row)).toBeLessThanOrEqual(100); + // A background lane must not add anything above the editor. It used to share one list widget; + // the roster is now read through `/subagents`, which subscribes to the same lane snapshots. + expect(calls).toEqual([]); + // Settle both lanes, then let the event loop drain past a repaint interval. The assertion is that + // no widget is ever set, not that none was set in the first 50ms: a re-added 1s ticker, or a + // publish from the async final-delivery path, would escape a short fixed sample. gates.get("alpha task")!(); - await waitFor(() => (calls.at(-1)?.component?.render(100)[0] ?? "").includes("1/2 complete")); - expect( - calls - .at(-1)! - .component!.render(100) - .some((row) => row.includes("alpha task")), - ).toBe(true); - gates.get("beta task")!(); - await waitFor(() => calls.at(-1)?.component === undefined); - expect(calls.at(-1)!.key).toBe("step-agent-lanes"); + await waitFor(() => inputs.size === 2); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + expect(calls).toEqual([]); }); function fakeLane(subscribe: BackgroundAgentLane["subscribe"]): BackgroundAgentLane {