Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4a28e20344 Restore the desktop badge pressed colour
Main pressed badges to surface0; the field-trigger extraction folded it into the surface2 hover state.
2026-10-17 16:39:59 +02:00
Mohamed Boudra c768306260 Redesign the compact New workspace form as full-width field triggers
Each compact field is one full-width trigger with its label inside, above the
value, so the whole field is the tap target and presses highlight. The stack
stays anchored on the composer with Isolation and Branch rows reserved, so
nothing shifts as they come and go. Import session moves into the compact
header, and the title is gone; the fields name themselves.

The form's presentational code moves out of new-workspace-screen.tsx into
screens/new-workspace/form, where desktop badges and compact fields share one
FieldTrigger.
2026-10-17 16:08:33 +02:00
Derick WellsandMohamed Boudra f22775a8e1 Default agent-to-agent messages to steering in CLI and MCP (#5164)
* Let agents steer a running turn from send_agent_prompt and paseo send

When an agent used the send_agent_prompt MCP tool on a target that was
mid-turn, sendPromptToAgent ran without activeTurnBehavior, so the
prompt path fell through to replaceAgentRun. That cancelled the target's
active turn and every provider subagent it had in flight. The web UI,
child finish notifications and schedules already pass "steer"; the MCP
tool and the CLI had no way to ask for it.

Add an optional activeTurnBehavior input to send_agent_prompt. It
defaults to "steer" for agent-scoped callers and to "interrupt" for
top-level callers, so existing top-level behaviour is unchanged. Add
--steer to paseo send. Document both and cover them with tests.

* fix(agents): retain finish notifications during steering acknowledgement

Observe completion before dispatch and hold delivery until acceptance. A replacement discards the displaced turn observation and starts a fresh listener before its iterator runs.

* fix(agents): preserve notification ownership when follow-ups fail

Keep the previous observation until dispatch succeeds and suppress duplicate delivery during acknowledgement. Decide whether to retain the running turn from the dispatch result, including side commands requested with interrupt.

* fix(agents): deduplicate notifications across overlapping follow-ups

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-17 20:39:05 +07:00
32 changed files with 1435 additions and 3314 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ The rows combine two kinds of children:
parentAgentId === thisAgent.id AND !archivedAt
```
- **Provider subagents** are child executions owned by Claude, Codex, OpenCode, or Pi. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. Pi rows that a poll keeps alive carry `liveness: "poll"`, and only a marked row can be settled by a sweep once its poll stops reporting — that gate is what stops an idle parent from leaving a card spinning and keeps other providers' rows untouched. Unmarked rows and every other provider settle their own rows.
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
+2 -1
View File
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
},
fields: {
project: "المشروع",
base: "الأساس",
branch: "الفرع",
baseNotApplicable: "غير قابل للتطبيق",
host: "المضيف",
},
titlePlaceholder: "العنوان (اختياري)",
errors: {
+2 -1
View File
@@ -1342,8 +1342,9 @@ export const en = {
},
fields: {
project: "Project",
base: "Base",
branch: "Branch",
baseNotApplicable: "Not applicable",
host: "Host",
},
titlePlaceholder: "Title (optional)",
errors: {
+2 -1
View File
@@ -1370,8 +1370,9 @@ export const es: TranslationResources = {
},
fields: {
project: "Proyecto",
base: "Base",
branch: "Rama",
baseNotApplicable: "No aplicable",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1363,8 +1363,9 @@ export const fr: TranslationResources = {
},
fields: {
project: "Projet",
base: "Base",
branch: "Branche",
baseNotApplicable: "Non applicable",
host: "Hôte",
},
titlePlaceholder: "Titre (facultatif)",
errors: {
+2 -1
View File
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
},
fields: {
project: "プロジェクト",
base: "ベース",
branch: "ブランチ",
baseNotApplicable: "該当なし",
host: "ホスト",
},
titlePlaceholder: "タイトル(任意)",
errors: {
+2 -1
View File
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
},
fields: {
project: "프로젝트",
base: "기준",
branch: "브랜치",
baseNotApplicable: "해당 없음",
host: "호스트",
},
titlePlaceholder: "제목(선택 사항)",
errors: {
+2 -1
View File
@@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = {
},
fields: {
project: "Projeto",
base: "Base",
branch: "Branch",
baseNotApplicable: "Não aplicável",
host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
+2 -1
View File
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
},
fields: {
project: "Проект",
base: "Базовая ветка",
branch: "Ветка",
baseNotApplicable: "Неприменимо",
host: "Хост",
},
titlePlaceholder: "Заголовок (необязательно)",
errors: {
+2 -1
View File
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
},
fields: {
project: "项目",
base: "基线",
branch: "分支",
baseNotApplicable: "不适用",
host: "主机",
},
titlePlaceholder: "标题(可选)",
errors: {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
* `field` is the compact form: a full-width filled box with the field's label inside, above the
* value, so the whole field is one tap target.
*/
export type FieldTriggerVariant = "badge" | "field";
const ThemedChevronDown = withUnistyles(ChevronDown);
const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
function Chevron(): ReactElement {
return (
<View style={styles.chevron}>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
</View>
);
}
const chevron = <Chevron />;
export interface FieldTriggerContentProps {
variant: FieldTriggerVariant;
/** The field's name, shown inside a `field`. */
label: string;
icon: ReactNode;
value: string;
/** Secondary text after the value, such as a terminal profile's name. */
detail?: string;
}
export function FieldTriggerContent({
variant,
label,
icon,
value,
detail,
}: FieldTriggerContentProps): ReactElement {
const valueLine = (
<>
<View style={styles.iconBox}>{icon}</View>
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
{value}
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
</Text>
</>
);
if (variant === "badge") {
return (
<>
{valueLine}
{chevron}
</>
);
}
return (
<>
<View style={styles.fieldBody}>
<Text style={styles.fieldLabel} numberOfLines={1}>
{label}
</Text>
<View style={styles.fieldValueLine}>{valueLine}</View>
</View>
{chevron}
</>
);
}
/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
return useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
variant === "field" ? styles.field : styles.badge,
!disabled && Boolean(state.hovered) && styles.highlighted,
!disabled &&
state.pressed &&
(variant === "field" ? styles.highlighted : styles.badgePressed),
disabled && styles.disabled,
],
[disabled, variant],
);
}
export interface FieldTriggerProps extends FieldTriggerContentProps {
/** What a screen reader announces; defaults to `label`. */
accessibilityLabel?: string;
onPress: () => void;
disabled?: boolean;
/** Desktop hover hint; badges only, since a field shows its label. */
tooltip?: string;
testID?: string;
}
/** A field trigger that anchors a Combobox. */
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
ref,
): ReactElement {
const style = useFieldTriggerStyle(content.variant, disabled);
const trigger = (
<ComboboxTrigger
ref={ref}
chevron={null}
block={content.variant === "field"}
testID={testID}
onPress={onPress}
disabled={disabled}
style={style}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? content.label}
>
<FieldTriggerContent {...content} />
</ComboboxTrigger>
);
if (content.variant === "field" || !tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
});
// Height of a badge. The desktop row sizes from it.
export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
export const FIELD_HEIGHT = 56;
const styles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
height: FIELD_BADGE_HEIGHT,
maxWidth: 240,
overflow: "hidden",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
gap: theme.spacing[1],
},
field: {
flexDirection: "row",
alignItems: "center",
height: FIELD_HEIGHT,
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
gap: theme.spacing[2],
},
highlighted: {
backgroundColor: theme.colors.surface2,
},
badgePressed: {
backgroundColor: theme.colors.surface0,
},
disabled: {
opacity: 0.6,
},
// Optical: the chevron's ink sits high in its box.
chevron: {
flexShrink: 0,
transform: [{ translateY: 1 }],
},
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
value: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
fieldBody: {
flex: 1,
minWidth: 0,
gap: theme.spacing[0.5],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
fieldValueLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
fieldValue: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.lg,
color: theme.colors.foreground,
},
detail: {
color: theme.colors.foregroundExtraMuted,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,49 @@
import type { ReactElement } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export const folderIcon = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
// token, so it stays a literal sized to the box.
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
export function ProjectIcon({
projectViewKey,
projectName,
iconDataUri,
}: {
projectViewKey: string;
projectName: string;
iconDataUri: string | null;
}): ReactElement {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
return (
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.fallbackText}
/>
);
}
const styles = StyleSheet.create((theme) => ({
fallbackText: {
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -0,0 +1,392 @@
import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker";
import type { LaunchTarget } from "@/new-workspace-launch/target";
import type { HostProjectListItem } from "@/projects/host-projects";
import type { HostProfile } from "@/types/host-connection";
import type { PickerItem } from "../../new-workspace-picker-item";
import {
FIELD_BADGE_HEIGHT,
FIELD_HEIGHT,
FieldTrigger,
type FieldTriggerVariant,
} from "./field-trigger";
import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
import { LaunchControl } from "./launch-control";
import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
type Isolation = "local" | "worktree";
type RenderOption = NonNullable<ComboboxProps["renderOption"]>;
interface PickerControl {
anchorRef: RefObject<View | null>;
open: () => void;
openState: boolean;
onOpenChange: (open: boolean) => void;
}
export interface NewWorkspaceFormProps {
isCompact: boolean;
isPending: boolean;
project: PickerControl & {
options: ComboboxOption[];
projectByOptionId: Map<string, HostProjectListItem>;
iconDataByProjectViewKey: Map<string, string | null>;
selectedProject: HostProjectListItem | null;
selectedOptionId: string;
triggerLabel: string;
selectedServerId: string;
/** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
supportsWorkspaceMultiplicity: boolean;
onSelect: (id: string) => void;
onAddProject: () => void;
};
host: PickerControl & {
allHosts: HostProfile[];
selectedServerId: string;
onSelect: (id: string) => void;
onAddHost: () => void;
};
isolation: PickerControl & {
effectiveIsolation: Isolation;
canCreateWorktree: boolean;
onSelect: (isolation: Isolation) => void;
};
base: PickerControl & {
selectedSourceDirectory: string | null;
selectedItem: PickerItem | null;
triggerLabel: string;
options: ComboboxOption[];
itemById: Map<string, PickerItem>;
selectedOptionId: string;
onSelect: (id: string) => void;
setSearchQuery: (query: string) => void;
emptyText: string;
showRefPicker: boolean;
};
launch: {
serverId: string;
target: LaunchTarget;
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled: boolean;
};
}
function hostOptionTestID(serverId: string): string {
return `new-workspace-host-picker-option-${serverId}`;
}
function isProjectSelectable(
project: HostProjectListItem,
supportsWorkspaceMultiplicity: boolean,
): boolean {
return (
supportsWorkspaceMultiplicity ||
project.hosts.some((host) => host.worktreeSupport !== "unsupported")
);
}
function useProjectPicker(
project: NewWorkspaceFormProps["project"],
isPending: boolean,
): { renderOption: RenderOption; footer: ReactNode } {
const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
const { supportsWorkspaceMultiplicity, onAddProject } = project;
const renderOption = useCallback<RenderOption>(
(row) => {
const item = projectByOptionId.get(row.option.id);
return (
<ProjectOption
{...row}
project={item}
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
selectedServerId={selectedServerId}
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
/>
);
},
[
iconDataByProjectViewKey,
isPending,
projectByOptionId,
selectedServerId,
supportsWorkspaceMultiplicity,
],
);
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [onAddProject]);
return { renderOption, footer };
}
function useIsolationPicker(
isolation: NewWorkspaceFormProps["isolation"],
isPending: boolean,
): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
const { t } = useTranslation();
const { canCreateWorktree, onSelect, onOpenChange } = isolation;
// "New worktree" is omitted entirely (not disabled) when the project isn't a
// git checkout, since worktree isolation is impossible there.
const options = useMemo<ComboboxOption[]>(() => {
const local = { id: "local", label: t("newWorkspace.isolation.local") };
if (!canCreateWorktree) return [local];
return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
}, [canCreateWorktree, t]);
const handleSelect = useCallback(
(id: string) => {
onSelect(id === "worktree" ? "worktree" : "local");
onOpenChange(false);
},
[onOpenChange, onSelect],
);
const renderOption = useCallback<RenderOption>(
(row) => <IsolationOption {...row} disabled={isPending} />,
[isPending],
);
return { options, onSelect: handleSelect, renderOption };
}
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
return useCallback<RenderOption>(
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
[isPending, itemById],
);
}
/**
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
* that does not apply to the selected project is left out, and Host only shows with a second host.
*/
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
const { t } = useTranslation();
const { isCompact, isPending, project, host, isolation, base, launch } = props;
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
const projectPicker = useProjectPicker(project, isPending);
const isolationPicker = useIsolationPicker(isolation, isPending);
const renderRefOption = useRefRenderOption(base.itemById, isPending);
const showHost = host.allHosts.length > 1;
const selectedHostLabel =
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const selectedProject = project.selectedProject;
const selectedProjectIconUri = selectedProject
? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
: null;
const projectIcon = useMemo(
() =>
selectedProject ? (
<ProjectIcon
projectViewKey={selectedProject.viewKey}
projectName={selectedProject.projectName}
iconDataUri={selectedProjectIconUri}
/>
) : (
folderIcon
),
[selectedProject, selectedProjectIconUri],
);
const hostIcon = useMemo(
() => <HostStatusDot serverId={host.selectedServerId} />,
[host.selectedServerId],
);
const isWorktree = isolation.effectiveIsolation === "worktree";
const isolationValue = isWorktree
? t("newWorkspace.isolation.worktree")
: t("newWorkspace.isolation.local");
const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
const projectField = (
<FieldTrigger
ref={project.anchorRef}
variant={variant}
label={t("newWorkspace.fields.project")}
accessibilityLabel="Workspace project"
tooltip={t("newWorkspace.tooltips.project")}
icon={projectIcon}
value={project.triggerLabel}
onPress={project.open}
disabled={isPending}
testID="new-workspace-project-picker-trigger"
/>
);
const hostField = showHost ? (
<HostPicker
hosts={host.allHosts}
value={host.selectedServerId}
onSelect={host.onSelect}
open={host.openState}
onOpenChange={host.onOpenChange}
anchorRef={host.anchorRef}
includeAddHost={isCompact}
onAddHost={host.onAddHost}
searchable={false}
title={t("newWorkspace.fields.host")}
desktopPlacement="bottom-start"
desktopMinWidth={200}
hostOptionTestID={hostOptionTestID}
>
<FieldTrigger
ref={host.anchorRef}
variant={variant}
label={t("newWorkspace.fields.host")}
tooltip={t("newWorkspace.tooltips.host")}
icon={hostIcon}
value={selectedHostLabel}
onPress={host.open}
disabled={isPending}
testID="host-picker-trigger"
/>
</HostPicker>
) : null;
const isolationField = isolation.canCreateWorktree ? (
<FieldTrigger
ref={isolation.anchorRef}
variant={variant}
label={t("newWorkspace.isolation.label")}
accessibilityLabel="Workspace isolation"
tooltip={t("newWorkspace.tooltips.isolation")}
icon={isWorktree ? branchIcon : folderIcon}
value={isolationValue}
onPress={isolation.open}
disabled={isPending}
testID="workspace-create-isolation-trigger"
/>
) : null;
const baseField = base.showRefPicker ? (
<FieldTrigger
ref={base.anchorRef}
variant={variant}
label={t("newWorkspace.fields.branch")}
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
tooltip={t("newWorkspace.tooltips.startingRef")}
icon={refIcon}
value={base.triggerLabel}
onPress={base.open}
disabled={isPending || !base.selectedSourceDirectory}
testID="new-workspace-ref-picker-trigger"
/>
) : null;
const launchField = (
<LaunchControl
variant={variant}
serverId={launch.serverId}
target={launch.target}
onChange={launch.onChange}
profiles={launch.profiles}
disabled={launch.disabled}
/>
);
const pickers = (
<>
<Combobox
options={project.options}
value={project.selectedOptionId}
onSelect={project.onSelect}
searchable
searchPlaceholder="Search projects"
title={t("newWorkspace.fields.project")}
open={project.openState}
onOpenChange={project.onOpenChange}
desktopPlacement="bottom-start"
desktopMinWidth={360}
anchorRef={project.anchorRef}
emptyText="No projects available."
renderOption={projectPicker.renderOption}
footer={projectPicker.footer}
/>
<Combobox
options={isolationPicker.options}
value={isolation.effectiveIsolation}
onSelect={isolationPicker.onSelect}
title={t("newWorkspace.isolation.label")}
open={isolation.openState}
onOpenChange={isolation.onOpenChange}
desktopPlacement="bottom-start"
anchorRef={isolation.anchorRef}
renderOption={isolationPicker.renderOption}
/>
<Combobox
options={base.options}
value={base.selectedOptionId}
onSelect={base.onSelect}
searchable
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
title={t("newWorkspace.refPicker.title")}
open={base.openState}
onOpenChange={base.onOpenChange}
onSearchQueryChange={base.setSearchQuery}
desktopPlacement="bottom-start"
anchorRef={base.anchorRef}
emptyText={base.emptyText}
renderOption={renderRefOption}
/>
</>
);
if (isCompact) {
return (
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
{projectField}
{hostField}
{isolationField}
{baseField}
{launchField}
{/* Isolation and Base come and go with the project; their rows stay reserved so the
stack keeps one height and nothing above the composer jumps. */}
{isolationField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
{pickers}
</View>
);
}
return (
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
<View style={styles.desktopControl}>{projectField}</View>
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
<View style={styles.launchSpacer} pointerEvents="none" />
{launchField}
{pickers}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
compactStack: {
marginBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
reservedField: {
height: FIELD_HEIGHT,
},
desktopRow: {
flexDirection: "row",
alignItems: "center",
minHeight: FIELD_BADGE_HEIGHT,
// Matches the gap between the composer and the Import session pill below it.
marginBottom: theme.spacing[4],
// The badge adds its own left padding; offset it so the project icon's left
// edge lands exactly on the "New workspace" title's left edge. The trailing
// inset mirrors it so the launch chip stops on the composer's inner content
// rather than running out to the composer's border.
paddingHorizontal: theme.spacing[4],
gap: theme.spacing[2],
},
desktopControl: {
minWidth: 0,
flexShrink: 1,
},
// Pushes the launch control to the trailing edge of the desktop meta row.
launchSpacer: {
flex: 1,
},
}));
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useRouter } from "expo-router";
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
import { MessageCircle, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -30,14 +30,17 @@ import {
resolveLaunchProfile,
terminalLaunchTarget,
type LaunchTarget,
} from "./target";
} from "@/new-workspace-launch/target";
import {
FieldTriggerContent,
useFieldTriggerStyle,
type FieldTriggerVariant,
} from "./field-trigger";
const ThemedMessageCircle = withUnistyles(MessageCircle);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled?: boolean;
/**
* The meta row's shared badge style. Passed in rather than redeclared so this
* trigger stays pixel-identical to the project, host, and branch triggers it
* sits beside; a local copy drifts the moment one of them changes.
*/
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
variant: FieldTriggerVariant;
}
function TriggerIcon({
@@ -108,8 +106,8 @@ function TriggerIcon({
}
/**
* The meta-row control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other chip on that row, opening
* The form control choosing what New workspace submits to: the chat agent,
* or a terminal. One trigger shaped like every other field trigger, opening
* a menu whose Chat and Terminal groups are what keep a terminal profile named
* "Codex" from reading as a chat Codex agent.
*/
@@ -119,13 +117,18 @@ export function LaunchControl({
onChange,
profiles,
disabled = false,
badgePressableStyle,
variant,
}: LaunchControlProps) {
const { t } = useTranslation();
const router = useRouter();
const [open, setOpen] = useState(false);
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
const triggerStyle = useFieldTriggerStyle(variant, disabled);
const triggerIcon = useMemo(
() => <TriggerIcon target={target} profile={selectedProfile} />,
[selectedProfile, target],
);
// A profile's own name and logo say nothing about where it runs: a row
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
@@ -149,32 +152,26 @@ export function LaunchControl({
return (
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
{/* The trigger is the Pressable itself, styled by the meta row's shared
badge style, so it lands pixel-identical to the project, host, and
branch chips it sits beside. */}
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
tooltip anchors to a wrapping View instead. Suppressed while the menu
is open so the two surfaces never overlap. */}
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
testID="new-workspace-launch-trigger"
disabled={disabled}
style={badgePressableStyle}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("newWorkspace.tooltips.launch")}
>
<View style={styles.iconBox}>
<TriggerIcon target={target} profile={selectedProfile} />
</View>
<Text style={styles.label} numberOfLines={1}>
{triggerLabel}
{selectedProfile ? (
<Text style={styles.profileName}> {selectedProfile.name}</Text>
) : null}
</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
<FieldTriggerContent
variant={variant}
label={t("newWorkspace.launch.title")}
icon={triggerIcon}
value={triggerLabel}
detail={selectedProfile?.name}
/>
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
@@ -232,27 +229,10 @@ export function LaunchControl({
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
label: {
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
profileName: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundExtraMuted,
},
command: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
@@ -0,0 +1,166 @@
import { useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
interface OptionRowState {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}
function IconBox({ children }: { children: ReactElement }): ReactElement {
return <View style={styles.iconBox}>{children}</View>;
}
export function ProjectOption({
option,
selected,
active,
onPress,
project,
iconDataUri,
selectedServerId,
disabled,
}: OptionRowState & {
project: HostProjectListItem | undefined;
iconDataUri: string | null;
selectedServerId: string;
disabled: boolean;
}): ReactElement {
const leadingSlot = useMemo(
() =>
project ? (
<IconBox>
<ProjectIcon
projectViewKey={project.viewKey}
projectName={project.projectName}
iconDataUri={iconDataUri}
/>
</IconBox>
) : undefined,
[iconDataUri, project],
);
if (!project) return <View key={option.id} />;
return (
<ComboboxItem
testID={`new-workspace-project-picker-option-${project.viewKey}`}
label={project.projectName}
description={
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
const { t } = useTranslation();
const openProjectKeys = useShortcutKeys("new-agent");
const shortcut = useMemo(
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
[openProjectKeys],
);
return (
<ComboboxItem
testID="new-workspace-project-picker-add-project"
label={t("sidebar.actions.addProject")}
onPress={onPress}
leadingSlot={addProjectIcon}
trailingSlot={shortcut}
/>
);
}
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
const localLeading = <IconBox>{folderIcon}</IconBox>;
export function IsolationOption({
option,
selected,
active,
onPress,
disabled,
}: OptionRowState & { disabled: boolean }): ReactElement {
return (
<ComboboxItem
testID={`workspace-create-isolation-${option.id}`}
label={option.label}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
/>
);
}
const branchLeading = <IconBox>{branchIcon}</IconBox>;
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
export function RefOption({
option,
selected,
active,
onPress,
item,
disabled,
}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
const { t } = useTranslation();
const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
const trailingSlot = useMemo(
() =>
divergenceLabel ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
[divergenceLabel],
);
if (!item) return <View key={option.id} />;
const isBranch = item.kind === "branch";
return (
<ComboboxItem
testID={
isBranch
? `new-workspace-ref-picker-branch-${item.name}`
: `new-workspace-ref-picker-pr-${item.item.number}`
}
label={pickerItemLabel(item)}
description={
!isBranch && item.item.baseRefName
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
: undefined
}
selected={selected}
active={active}
disabled={disabled}
onPress={onPress}
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
trailingSlot={trailingSlot}
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
/>
);
}
const styles = StyleSheet.create((theme) => ({
iconBox: {
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
divergenceLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
}));
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
* The new workspace screen's way into Import session: a ghost button at the top on compact
* The new workspace screen's way into Import session: a ghost button in the header on compact
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
*/
export function ImportSessionButton({
@@ -17,7 +17,7 @@ export function ImportSessionButton({
}) {
const { t } = useTranslation();
return (
<View style={compact ? styles.compact : styles.wide}>
<View style={compact ? null : styles.wide}>
<Button
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
variant="ghost"
@@ -34,9 +34,6 @@ export function ImportSessionButton({
}
const styles = StyleSheet.create((theme) => ({
compact: {
alignItems: "flex-start",
},
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
// border lines up with the composer's border.
wide: {
+16
View File
@@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js";
describe("canonical CLI surface", () => {
it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => {
const cli = createCli();
const owner =
path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!;
const send = owner.commands.find((command) => command.name() === "send")!;
send.action(() => {});
await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" });
expect(send.opts().behavior).toBe("interrupt");
await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" });
expect(send.opts().behavior).toBe("steer");
send.exitOverride().configureOutput({ writeErr: () => {} });
await expect(
send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }),
).rejects.toThrow();
});
it("offers daemon host selection as a global option", () => {
expect(createCli().helpInformation()).toContain("--host <host>");
});
+12 -2
View File
@@ -1,4 +1,5 @@
import { type Command } from "commander";
import { type Command, Option } from "commander";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import { collectMultiple } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
@@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
};
export interface AgentSendOptions extends CommandOptions {
behavior?: ActiveTurnBehavior;
wait?: boolean;
image?: string[];
prompt?: string;
@@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command {
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
.addOption(
new Option(
"--behavior <behavior>",
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
).choices(["interrupt", "steer"]),
)
.option("--no-wait", "Return immediately without waiting for completion");
}
@@ -191,10 +199,12 @@ export async function runSendCommand(
const images =
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, {
images,
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
sourceAgentId,
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
});
// If --no-wait, return immediately
@@ -1,661 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { FakePi } from "./providers/pi/test-utils/fake-pi.js";
import type {
AgentCapabilityFlags,
AgentClient,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
ProviderCatalog,
} from "./agent-sdk-types.js";
import type { ProviderSubagentInputEvent } from "./provider-subagents/store.js";
/**
* Stuck-running provider subagent cards are a lifecycle problem, not a presentation one: a row is
* born `running` and only a provider update can settle it. These cover the manager's two finalize
* points — the turn boundary and history replay — and, above all, that neither touches a row whose
* run can still report.
*/
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
/** Long enough that no poll could have re-reported the row inside it. */
const QUIET_FOR_MS = 60_000;
class TestAgentSession implements AgentSession {
readonly capabilities = TEST_CAPABILITIES;
readonly id = "test-session";
private historyEvents: AgentStreamEvent[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
constructor(readonly provider: AgentProvider) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(_prompt: AgentPromptInput): Promise<{ turnId: string }> {
return { turnId: "turn-1" };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
setHistory(events: AgentStreamEvent[]): void {
this.historyEvents = [...events];
}
pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) callback(event);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
for (const event of this.historyEvents) yield event;
}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class TestAgentClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
session?: TestAgentSession;
constructor(readonly provider: AgentProvider) {}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.session = new TestAgentSession(config.provider);
return this.session;
}
async resumeSession(): Promise<AgentSession> {
this.session ??= new TestAgentSession(this.provider);
return this.session;
}
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
return true;
}
}
interface Harness {
manager: AgentManager;
session: TestAgentSession;
agentId: string;
cleanup: () => void;
}
async function createHarness(provider: AgentProvider): Promise<Harness> {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-subagents-"));
const client = new TestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: client },
idFactory: () => AGENT_ID,
logger: createTestLogger(),
});
await manager.createAgent({ provider, cwd: workdir }, AGENT_ID, { workspaceId: undefined });
if (!client.session) throw new Error("Test client did not create a session");
return {
manager,
session: client.session,
agentId: AGENT_ID,
cleanup: () => rmSync(workdir, { recursive: true, force: true }),
};
}
function subagentEvent(
provider: AgentProvider,
event: ProviderSubagentInputEvent,
): AgentStreamEvent {
return { type: "provider_subagent", provider, event };
}
function turnCompleted(provider: AgentProvider): AgentStreamEvent {
return { type: "turn_completed", provider, turnId: "turn-1" };
}
async function drainSessionEvents(): Promise<void> {
for (let index = 0; index < 10_000; index++) await Promise.resolve();
}
async function pushEvent(harness: Harness, event: AgentStreamEvent): Promise<void> {
harness.session.pushEvent(event);
await drainSessionEvents();
}
function status(harness: Harness, subagentId: string) {
return harness.manager.getProviderSubagent(harness.agentId, subagentId)?.status;
}
/** Moves the clock past the liveness window without touching timers the manager runs on. */
function advanceClock(ms: number): void {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(Date.now() + ms));
}
/** Waits on real time: the Pi adapter polls on a real interval. */
async function waitFor(condition: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 250; attempt++) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("Timed out waiting for the Pi adapter poll");
}
afterEach(() => {
vi.useRealTimers();
});
describe("provider subagent finalize", () => {
test("a turn end cancels a running Pi row that nothing has re-reported", async () => {
const harness = await createHarness("pi");
try {
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-1",
title: "scout",
status: "running",
liveness: "poll",
}),
);
expect(status(harness, "call-1")).toBe("running");
advanceClock(QUIET_FOR_MS);
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("a re-check armed by one turn leaves a later turn's synchronous child alone", async () => {
const harness = await createHarness("pi");
try {
vi.useFakeTimers();
// A spawn call interrupted before it reported: the turn end arms a re-check for the moment its
// silence becomes proof.
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-orphan",
title: "scout",
status: "running",
liveness: "poll",
}),
);
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-orphan")).toBe("running");
// The next turn launches a synchronous child. That call is poll-marked at spawn and gets no
// poll until its result lands, so it stays quiet for longer than the window while it works.
await vi.advanceTimersByTimeAsync(2_000);
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-sync",
title: "reviewer",
status: "running",
liveness: "poll",
}),
);
expect(status(harness, "call-sync")).toBe("running");
// The armed re-check settles the row it was armed for. Judging the next turn's fresh row here
// would cancel a child whose foreground call is still running.
await vi.advanceTimersByTimeAsync(3_001);
expect(status(harness, "call-orphan")).toBe("canceled");
expect(status(harness, "call-sync")).toBe("running");
// Nothing captured is left to prove, so nothing is left armed: the fresh row survives a whole
// further window and no timer of ours is pending.
await vi.advanceTimersByTimeAsync(QUIET_FOR_MS);
expect(status(harness, "call-sync")).toBe("running");
expect(vi.getTimerCount()).toBe(0);
// The call's result lands and settles its own row normally.
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-sync",
title: "reviewer",
status: "completed",
}),
);
expect(status(harness, "call-sync")).toBe("completed");
} finally {
harness.cleanup();
}
});
test("a turn end inside the window re-checks an orphaned placeholder once its silence proves it", async () => {
const harness = await createHarness("pi");
try {
vi.useFakeTimers();
// A spawn call interrupted before it reported leaves this placeholder behind: nothing polls it,
// so no later report refreshes it either.
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-1",
title: "scout",
status: "running",
liveness: "poll",
}),
);
// The stop's turn end lands inside the liveness window, where the sweep cannot tell this row
// from a live poll's.
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
// The parent stays idle, so this re-check is the only thing that will ever look again.
await vi.advanceTimersByTimeAsync(5_001);
expect(status(harness, "call-1")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("a poll that re-dates the row inside the sweep window re-arms the re-check", async () => {
const harness = await createHarness("pi");
try {
vi.useFakeTimers();
const poll = () =>
pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-1",
title: "scout",
status: "running",
liveness: "poll",
}),
);
await poll();
// The turn end lands inside the window, so it arms a re-check instead of canceling.
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
// The live poll re-dates the row before that re-check fires: the scheduled look no longer
// proves anything, and cancelling here would kill a run that is still reporting.
await vi.advanceTimersByTimeAsync(2_000);
await poll();
await vi.advanceTimersByTimeAsync(3_001);
expect(status(harness, "call-1")).toBe("running");
// The re-check re-armed itself, so the row settles once the poll really does go quiet.
await vi.advanceTimersByTimeAsync(2_000);
expect(status(harness, "call-1")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("a turn end leaves a Pi row that is still reporting alone", async () => {
const harness = await createHarness("pi");
try {
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-1",
title: "scout",
status: "running",
liveness: "poll",
}),
);
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
} finally {
harness.cleanup();
}
});
test("a turn end leaves a quiet non-polling Pi-extension row alone", async () => {
const harness = await createHarness("pi");
try {
await pushEvent(
harness,
subagentEvent("pi", { type: "upsert", id: "call-1", title: "scout", status: "running" }),
);
advanceClock(QUIET_FOR_MS);
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
} finally {
harness.cleanup();
}
});
test("a poll-backed row survives consecutive turn ends while its poll keeps reporting", async () => {
const harness = await createHarness("pi");
try {
const poll = () =>
pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-1",
title: "scout",
status: "running",
liveness: "poll",
}),
);
await poll();
advanceClock(QUIET_FOR_MS);
await poll();
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
advanceClock(QUIET_FOR_MS);
await poll();
await pushEvent(harness, turnCompleted("pi"));
expect(status(harness, "call-1")).toBe("running");
} finally {
harness.cleanup();
}
});
test(
"a turn end during a status-file miss streak leaves the live poll's row running",
{ timeout: 15_000 },
async () => {
// The real Pi adapter, so the miss ticks come from the poll that protects the row instead of
// from a hand-written upsert.
const pi = new FakePi();
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
// The injected runtime owns execution; use an existing executable for the real availability
// probe so this test does not depend on a Pi installation or replace a client method.
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-subagent-poll-"));
const manager = new AgentManager({
clients: { pi: client },
idFactory: () => AGENT_ID,
logger: createTestLogger(),
});
try {
await manager.createAgent({ provider: "pi", cwd: workdir }, AGENT_ID, {
workspaceId: undefined,
});
const piSession = pi.latestSession();
// An async spawn whose status directory never appears: every poll tick is a miss.
piSession.emit({
type: "tool_execution_start",
toolCallId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
});
piSession.emit({
type: "tool_execution_end",
toolCallId: "call-1",
toolName: "subagent",
result: {
details: {
mode: "single",
runId: "run-1",
asyncId: "run-1",
asyncDir: join(workdir, "gone"),
results: [],
},
},
});
const row = () => manager.getProviderSubagent(AGENT_ID, "call-1");
await waitFor(() => row()?.status === "running" && row()?.liveness === "poll");
for (let turn = 0; turn < 2; turn++) {
// A miss tick re-reports the row, then the turn ends inside the miss budget: the silence is
// a file the run has not written yet, not a dead poll.
const reportedAt = row()?.updatedAt;
await waitFor(() => row()?.updatedAt !== reportedAt);
piSession.emit({ type: "agent_start" });
piSession.emit({ type: "agent_end" });
await drainSessionEvents();
expect(row()?.status).toBe("running");
}
} finally {
try {
await manager.closeAgent(AGENT_ID);
} catch {
// A createAgent failure leaves nothing to close; the workdir cleanup must still run.
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
},
);
test("a turn end leaves another provider's quiet row alone: it has no poll to prove life", async () => {
const harness = await createHarness("claude");
try {
await pushEvent(
harness,
subagentEvent("claude", {
type: "upsert",
id: "task-1",
title: "general-purpose",
status: "running",
}),
);
advanceClock(QUIET_FOR_MS);
await pushEvent(harness, turnCompleted("claude"));
expect(status(harness, "task-1")).toBe("running");
} finally {
harness.cleanup();
}
});
test("replay settles a poll-backed running row: replay has no poll to adopt it", async () => {
const harness = await createHarness("pi");
try {
harness.session.setHistory([
subagentEvent("pi", {
type: "upsert",
id: "call-polled",
title: "scout",
status: "running",
liveness: "poll",
}),
]);
await harness.manager.hydrateTimelineFromProvider(harness.agentId, { force: true });
expect(status(harness, "call-polled")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("the prime on agent load settles a poll-backed running row: a restart has no poll either", async () => {
const harness = await createHarness("pi");
try {
// A daemon restart or an agent reload re-reads provider history once, without force. Nothing
// re-creates the live session's poll there either, so the row must not come back every reload.
harness.session.setHistory([
subagentEvent("pi", {
type: "upsert",
id: "call-prime",
title: "scout",
status: "running",
liveness: "poll",
}),
]);
await harness.manager.reloadAgentSession(harness.agentId, undefined, {
rehydrateFromDisk: true,
});
expect(harness.manager.getAgent(harness.agentId)?.historyPrimed).toBe(false);
await harness.manager.hydrateTimelineFromProvider(harness.agentId, { broadcast: true });
expect(status(harness, "call-prime")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("replay settles a row that was already canceled: the replay proves no life either", async () => {
const harness = await createHarness("pi");
try {
// A turn-end sweep canceled this row while its poll was quiet, and a rewind replays the same
// async spawn: replay maps it back to running with the poll marker, while no live poll is
// behind it. Settling on id presence alone would leave a spinner nothing can update, so the
// row settles again.
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-canceled",
title: "scout",
status: "canceled",
liveness: "poll",
}),
);
harness.session.setHistory([
subagentEvent("pi", {
type: "upsert",
id: "call-canceled",
title: "scout",
status: "running",
liveness: "poll",
}),
]);
await harness.manager.hydrateTimelineFromProvider(harness.agentId, { force: true });
expect(status(harness, "call-canceled")).toBe("canceled");
// Nothing is left that could update it, so the row stays settled once the replay completes.
await drainSessionEvents();
expect(status(harness, "call-canceled")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("replay leaves a row the live poll already owns alone", async () => {
const harness = await createHarness("pi");
try {
// The live session's poll is re-reporting this row when a rewind replays the same spawn. The
// replay must not cancel it: its poll is still running and would flip it back to running.
await pushEvent(
harness,
subagentEvent("pi", {
type: "upsert",
id: "call-live",
title: "scout",
status: "running",
liveness: "poll",
}),
);
harness.session.setHistory([
subagentEvent("pi", {
type: "upsert",
id: "call-live",
title: "scout",
status: "running",
liveness: "poll",
}),
subagentEvent("pi", {
type: "upsert",
id: "call-replayed",
title: "reviewer",
status: "running",
liveness: "poll",
}),
]);
await harness.manager.hydrateTimelineFromProvider(harness.agentId, { force: true });
expect(status(harness, "call-live")).toBe("running");
// The replay's own row has no poll behind it, so it still settles.
expect(status(harness, "call-replayed")).toBe("canceled");
} finally {
harness.cleanup();
}
});
test("replay leaves an unmarked running row: its reporter settles itself", async () => {
const harness = await createHarness("pi");
try {
harness.session.setHistory([
subagentEvent("pi", { type: "upsert", id: "call-live", title: "scout", status: "running" }),
subagentEvent("pi", {
type: "upsert",
id: "call-done",
title: "reviewer",
status: "completed",
}),
]);
await harness.manager.hydrateTimelineFromProvider(harness.agentId, { force: true });
expect(status(harness, "call-live")).toBe("running");
expect(status(harness, "call-done")).toBe("completed");
} finally {
harness.cleanup();
}
});
});
@@ -726,17 +726,6 @@ function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatc
const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000;
const RETIRED_INTERNAL_AGENT_LIMIT = 500;
/**
* How recently a poll-backed Pi subagent row must have been updated to count as alive. Only the
* pi-subagents extension polls: it re-upserts every live row on each 250 ms follow tick and marks
* those upserts `liveness: "poll"`, so a marked row untouched for a whole window can never change
* again. The other Pi extensions (gotgenes/tintinweb pi-subagents and friends) are event-driven
* and may stay silent while a background child still runs, and Claude
* (providers/claude/agent.ts:4779,4805) and OpenCode (providers/opencode/v2/children.ts:64-68)
* settle their own rows at turn or child end. No row without the marker is ever swept.
*/
const PI_SUBAGENT_LIVENESS_MS = 5_000;
export interface RetiredInternalAgent {
record: ArchivedStoredAgentRecord;
lastMessage: string | null;
@@ -754,7 +743,6 @@ export class AgentManager {
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly providerSubagentSweepTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
@@ -1824,157 +1812,15 @@ export class AgentManager {
if (subagent.status !== "running") {
continue;
}
this.applyProviderSubagentStatus(parentAgentId, subagent.provider, subagent.id, "canceled");
const event = this.providerSubagents.apply(parentAgentId, subagent.provider, {
type: "upsert",
id: subagent.id,
status: "canceled",
});
this.dispatch({ type: "provider_subagent", event });
}
}
private applyProviderSubagentStatus(
parentAgentId: string,
provider: AgentProvider,
subagentId: string,
status: ProviderSubagentDescriptor["status"],
): void {
const event = this.providerSubagents.apply(parentAgentId, provider, {
type: "upsert",
id: subagentId,
status,
});
this.dispatch({ type: "provider_subagent", event });
}
/**
* Poll-backed Pi subagent rows are born `running` and leave that state only through a provider
* update. A row whose poll is gone would keep its spinner forever, so a turn boundary settles
* only the rows that prove they had a poll and stopped reporting, as canceled — never completed,
* because nothing here proves they succeeded. A row a live poll keeps re-reporting is fresh and
* left alone, and a row without the marker belongs to an event-driven reporter that may still be
* running its child, so it is never swept.
*
* Gate: the poll marker plus no report inside the liveness window. The window is wall clock, so an
* event-loop stall, a GC pause, or a resume longer than it makes a live run's row look silent; this
* path is the only one that reads time — the replay settle (finalizeReplayedProviderSubagents) gates
* on the store's pre-replay state instead.
*
* A turn end can arrive inside that window, and for a spawn call a stop interrupted before it
* reported there is no later turn end to look again: the placeholder would stay `running` while the
* parent sits idle. So a row still inside the window schedules one re-check for the moment its
* silence becomes proof, instead of waiting for an event that may never come.
*
* That re-check is scoped to the rows it was armed for. A row born after it was armed belongs to a
* turn that is still running — a synchronous `subagent` call is poll-marked at spawn and only gets
* its poll when the result lands — so the timer must not judge it: that turn's own end sweeps it.
*/
// ponytail: staleness is wall-clock — upgrade to a poll heartbeat (sequence/epoch from the adapter) if stalls prove real
private finalizeUnprovenProviderSubagents(
agentId: string,
scheduledRowIds?: ReadonlySet<string>,
): void {
const now = Date.now();
let recheckAt: number | undefined;
const recheckRowIds: string[] = [];
for (const subagent of this.providerSubagents.list(agentId)) {
if (scheduledRowIds && !scheduledRowIds.has(subagent.id)) continue;
if (subagent.status !== "running" || subagent.liveness !== "poll") continue;
const updatedAt = Date.parse(subagent.updatedAt);
if (updatedAt >= now - PI_SUBAGENT_LIVENESS_MS) {
// A live poll may still own this row, so it stays. Look again once its silence is old enough.
recheckAt = Math.min(
recheckAt ?? Number.POSITIVE_INFINITY,
updatedAt + PI_SUBAGENT_LIVENESS_MS,
);
recheckRowIds.push(subagent.id);
continue;
}
this.applyProviderSubagentStatus(agentId, subagent.provider, subagent.id, "canceled");
}
this.scheduleProviderSubagentSweep(agentId, recheckAt, recheckRowIds);
}
/**
* The one pending re-check per agent: a turn end while a poll-backed row was still fresh reschedules
* it for the rows that were fresh then, and a sweep that finds nothing left to prove clears it. The
* timer stops itself once the agent is gone.
*/
private scheduleProviderSubagentSweep(
agentId: string,
recheckAt: number | undefined,
recheckRowIds: readonly string[],
): void {
const pending = this.providerSubagentSweepTimers.get(agentId);
if (pending) clearTimeout(pending);
this.providerSubagentSweepTimers.delete(agentId);
if (recheckAt === undefined) return;
const scheduledRowIds = new Set(recheckRowIds);
const timer = setTimeout(
() => {
this.providerSubagentSweepTimers.delete(agentId);
if (!this.agents.has(agentId)) return;
this.finalizeUnprovenProviderSubagents(agentId, scheduledRowIds);
},
Math.max(0, recheckAt - Date.now()) + 1,
);
timer.unref();
this.providerSubagentSweepTimers.set(agentId, timer);
}
/**
* Replay re-creates a row for every spawn in the transcript, including runs that never finished.
* Replay re-runs the pi-subagents adapter mapping, so a replayed running pi-subagents row carries
* the poll marker. Replay builds its host without `follow()`
* (providers/pi/extensions/host.ts:61-69), and the live host's `callsByRun` is per-adapter-session,
* so no live poll is behind that row and nothing would ever update it again: left running, it is a
* spinner that returns on every reload.
*
* Gate: the poll marker plus the row was not already `running` in the store before the replay. A
* row that was running pre-replay belongs to the live session's poll (rewind force-hydrates without
* recreating the session) and is left with it, or the settle would cancel a live run and the next
* tick would flip it back to running. Every other replayed running row settles as canceled, never
* completed: a row that was already canceled or completed before the replay settles again as
* canceled, because replay re-reports every unfinished run as running and the transcript holds no
* proof that it survived. That is the verdict for unproven rows, and it is still better than the
* spinner that stays until the next reload.
*
* Known limitation: a row replayed for a run that no live poll owns may still describe work that
* is writing status.json, and this cancel is final — such a run keeps working but its card can
* never recover. Making replay resumable needs the live host to adopt replayed runs, which this
* change does not attempt.
*
* Rows without the marker belong to a reporter that settles its own children — Claude
* (providers/claude/agent.ts:4779,4805), OpenCode (providers/opencode/v2/children.ts:64-68), the
* event-driven Pi extensions — so replay leaves them alone. A live turn is the other case where a
* replayed tool call may still be in flight, so it is left alone too. Normal callers clear the
* turn before hydrating, but the interrupt-timeout rescue (cancelAgentRunNow) settles the
* foreground run without a terminal event, so `activeTurnId` can linger; those rows are left to
* the next turn-end sweep rather than to a poll.
*/
// ponytail: replay has no live poll; proper fix is letting the live host adopt replayed runs — revisit when host replay shares adapter state
private finalizeReplayedProviderSubagents(
agent: ActiveManagedAgent,
replayedRunningIds: ReadonlySet<string>,
preexistingRunningIds: ReadonlySet<string>,
): void {
if (agent.activeForegroundTurnId != null || agent.activeTurnId != null) return;
for (const id of replayedRunningIds) {
if (preexistingRunningIds.has(id)) continue;
const subagent = this.providerSubagents.get(agent.id, id);
if (!subagent || subagent.status !== "running" || subagent.liveness !== "poll") continue;
this.applyProviderSubagentStatus(agent.id, subagent.provider, id, "canceled");
}
}
/**
* Ids the store reports running now. Before a replay applies, a running row belongs to the live
* session's poll; a terminal row belongs to nobody and is replayed evidence like any other.
*/
private providerSubagentRunningIds(agentId: string): Set<string> {
return new Set(
this.providerSubagents
.list(agentId)
.filter((subagent) => subagent.status === "running")
.map((subagent) => subagent.id),
);
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
@@ -4300,20 +4146,13 @@ export class AgentManager {
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
agent.historyPrimed = true;
// Rows the store already reports running are the live session's: a rewind replays history
// without recreating the session, so their poll keeps running.
const preexistingRunningRowIds = this.providerSubagentRunningIds(agent.id);
for (const event of this.providerSubagents.deleteParent(agent.id)) {
if (broadcast) {
this.dispatch({ type: "provider_subagent", event });
}
}
const replayedRunningIds = new Set<string>();
for (const event of providerSubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (event.event.type === "upsert" && event.event.status === "running") {
replayedRunningIds.add(event.event.id);
}
if (broadcast) {
this.dispatch({ type: "provider_subagent", event: update });
}
@@ -4332,7 +4171,6 @@ export class AgentManager {
});
}
}
this.finalizeReplayedProviderSubagents(agent, replayedRunningIds, preexistingRunningRowIds);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -4379,15 +4217,8 @@ export class AgentManager {
row: AgentTimelineRow;
}> = [];
const providerSubagentEvents: AgentManagerEvent[] = [];
const replayedRunningIds = new Set<string>();
// A row the store still reports running is not a fresh resume: it belongs to the live
// session's poll.
const preexistingRunningRowIds = this.providerSubagentRunningIds(agent.id);
for (const event of historySubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (event.event.type === "upsert" && event.event.status === "running") {
replayedRunningIds.add(event.event.id);
}
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
if (deferredBroadcast) {
providerSubagentEvents.push(managerEvent);
@@ -4414,7 +4245,6 @@ export class AgentManager {
agent.historyPrimed = true;
if (typeof broadcast !== "function" || !broadcast()) {
this.finalizeReplayedProviderSubagents(agent, replayedRunningIds, preexistingRunningRowIds);
return;
}
for (const event of providerSubagentEvents) {
@@ -4427,7 +4257,6 @@ export class AgentManager {
timestamp: row.timestamp,
});
}
this.finalizeReplayedProviderSubagents(agent, replayedRunningIds, preexistingRunningRowIds);
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
@@ -4513,7 +4342,6 @@ export class AgentManager {
if (isForegroundEvent) {
this.finalizeForegroundTurn(agent, eventTurnId);
}
this.finalizeUnprovenProviderSubagents(agent.id);
}
if (flags.shouldDispatchEvent) {
@@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams {
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
dispatchResult?: Promise<PromptDispatchDisposition | false>;
/** A replacement must observe its new start, not the displaced running snapshot. */
waitForTurnStart?: boolean;
logger: Logger;
@@ -466,10 +468,17 @@ interface NotifySafelyOptions {
permissionRequest?: AgentPermissionRequest;
}
// A caller waits on a child through one armed notification. Arming again, such as a
// follow-up prompt while the child still runs, replaces the earlier one so the child's
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
// every earlier observation for the caller/child pair; a failure removes only itself.
interface ArmedFinishNotification {
stop(): void;
supersedeWhen(accepted: Promise<boolean>): void;
}
const armedFinishNotifications = new WeakMap<
AgentManager,
Map<string, Set<ArmedFinishNotification>>
>();
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
@@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
armedByManager.get(armedKey)?.();
armedByManager.set(armedKey, stop);
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
armedByManager.set(armedKey, registrations);
const previous = [...registrations];
let superseded = Promise.resolve(false);
let dispatchPending = Boolean(params.dispatchResult);
const registration: ArmedFinishNotification = {
stop,
supersedeWhen(accepted) {
superseded = superseded.then(
async (alreadySuperseded) => alreadySuperseded || (await accepted),
);
},
};
registrations.add(registration);
if (params.dispatchResult) {
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
void params.dispatchResult.then((accepted) => {
dispatchPending = false;
if (accepted) {
for (const observer of previous) observer.stop();
if (stopped || accepted === "turn_started") stop();
} else {
stop();
}
return undefined;
});
} else {
for (const observer of previous) {
observer.supersedeWhen(Promise.resolve(true));
observer.stop();
}
}
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
if (armedByManager.get(armedKey) === stop) {
armedByManager.delete(armedKey);
// Pending observations remain visible to overlapping sends until acknowledgement.
if (!dispatchPending) {
registrations.delete(registration);
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
armedByManager.delete(armedKey);
}
}
}
@@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
if (params.dispatchResult) {
const result = await params.dispatchResult;
if (result === false || result === "turn_started") return;
}
if (await superseded) return;
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
@@ -1,3 +1,4 @@
import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js";
import { parseAgentMessage } from "./agent-messages/index.js";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -242,6 +243,7 @@ function buildAgentManagerSpies() {
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
subscribe: vi.fn().mockReturnValue(() => {}),
streamAgent: vi.fn(() => (async function* noop() {})()),
@@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = {
} as const;
interface HeldTurnOptions {
finishDuringSteer?: boolean;
rejectSteer?: boolean;
steerResult?: "accepted" | "unavailable";
/** Run the whole turn before the turn-start request returns. */
finishTurnsDuringStart?: boolean;
}
@@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession {
readonly capabilities = HELD_TURN_CAPABILITIES;
readonly id = randomUUID();
readonly prompts: string[] = [];
readonly steeredPrompts: AgentPromptInput[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
private turnStartGate: Promise<void> | null = null;
@@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession {
return prompt === "/side-command" ? { run: async () => {} } : null;
}
async steerActiveTurn(prompt: AgentPromptInput) {
const status = this.options.steerResult ?? "unavailable";
if (status === "accepted") this.steeredPrompts.push(prompt);
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
if (this.options.rejectSteer) throw new Error("Steer rejected");
return { status };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
await this.turnStartGate;
@@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient {
}
describe("send_agent_prompt MCP tool", () => {
it.each([
{ agentScoped: true, behavior: undefined, available: true, steered: true },
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
{ agentScoped: true, behavior: "steer", available: true, steered: true },
{ agentScoped: false, behavior: undefined, available: true, steered: false },
{ agentScoped: false, behavior: "steer", available: true, steered: true },
{ agentScoped: true, behavior: undefined, available: false, steered: false },
] as const)(
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
async ({ agentScoped, behavior, available, steered }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: available ? "accepted" : "unavailable",
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: agentScoped ? parent.id : undefined,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Original work",
notifyOnFinish: false,
});
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "New message",
notifyOnFinish: false,
...(behavior ? { activeTurnBehavior: behavior } : {}),
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: steered ? "steered" : "turn_started",
});
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
} finally {
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
{ rejected: false, priorNotification: false },
{ rejected: true, priorNotification: false },
{ rejected: false, priorNotification: true },
])(
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
async ({ rejected, priorNotification }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: true,
rejectSteer: rejected,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: priorNotification,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
if (rejected) {
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
// Flush asynchronous notification work before checking that a rejected
// dispatch did not notify. Then prove a later successful send still can.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
await send({
agentId: child.id,
prompt: "Retry after rejection",
});
client.sessions[1]!.finishTurn();
} else {
const response = await sending;
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "steered",
});
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([false, true])(
"preserves the original notification after rejection (completion during send: %s)",
async (finishDuringSteer) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
const sending = send({
agentId: child.id,
prompt: "New message",
notifyOnFinish: true,
});
expect(await sending).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
});
if (!finishDuringSteer) {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(0);
client.sessions[1]!.finishTurn();
}
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
it.each([
[false, true],
[true, false],
[false, false],
[true, true],
])(
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
async (firstAccepted, secondAccepted) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldTurnAgentClient("codex", true, {
steerResult: "accepted",
finishDuringSteer: false,
rejectSteer: true,
});
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let mcpClient: Client | undefined;
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const child = await manager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{
workspaceId: "wks_parent",
},
);
const server = await createAgentMcpServer({
agentManager: manager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
mcpClient = await connectInMemoryMcpClient(server);
const send = (input: Record<string, unknown>) =>
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
await send({
agentId: child.id,
prompt: "Original work",
notifyOnFinish: true,
});
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
firstResolve = resolve;
});
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
secondResolve = resolve;
});
for (const dispatchResult of [first, second]) {
setupFinishNotification({
agentManager: manager,
agentStorage: storage,
childAgentId: child.id,
callerAgentId: parent.id,
dispatchResult,
logger,
});
}
firstResolve(firstAccepted ? "steered" : false);
await first;
secondResolve(secondAccepted ? "steered" : false);
await second;
client.sessions[1]!.finishTurn();
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(client.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
});
} finally {
await mcpClient?.close();
await removeAgentStateDir(manager, storage, workdir);
}
},
);
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => {
const response = await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.subscribe).toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
@@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => {
},
);
it.each(["out_of_band", "startup_failure"] as const)(
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
"preserves %s dispatch notification semantics",
async (scenario) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
@@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => {
prompt: "Ongoing task",
notifyOnFinish: false,
});
if (scenario === "out_of_band") {
if (scenario !== "startup_failure") {
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "/side-command",
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
});
expect(response.structuredContent).toMatchObject({
success: true,
@@ -60,23 +60,6 @@ describe("ProviderSubagentStore", () => {
expect(subagents.list("parent-b")).toHaveLength(1);
});
test("keeps a poll liveness marker across presentation-only upserts", () => {
const subagents = new ProviderSubagentStore();
subagents.apply("parent-a", "pi", {
type: "upsert",
id: "child-1",
status: "running",
liveness: "poll",
});
subagents.apply("parent-a", "pi", { type: "upsert", id: "child-1", title: "scout" });
expect(subagents.get("parent-a", "child-1")).toMatchObject({
title: "scout",
status: "running",
liveness: "poll",
});
});
test("limits oversized provider child tool output before storage", () => {
const subagents = new ProviderSubagentStore();
const output = "x".repeat(70 * 1024);
@@ -23,11 +23,6 @@ export interface ProviderSubagentDescriptor {
toolCallId: string | null;
cwd: string | null;
subtitle: string | null;
/**
* Set when a live poll re-reports this row on every tick, so silence proves the poll is gone.
* Rows without it are event-driven and may stay quiet while their child still runs.
*/
liveness?: "poll";
}
export type ProviderSubagentInputEvent =
@@ -45,8 +40,6 @@ export type ProviderSubagentInputEvent =
cwd?: string | null;
subtitle?: string | null;
parentSubagentId?: string | null;
/** Only a poll-backed reporter may set this; it is what makes a stale row sweepable. */
liveness?: "poll";
timestamp?: string;
}
| {
@@ -120,7 +113,6 @@ export class ProviderSubagentStore {
this.timelines.initialize(key);
}
const timestamp = event.timestamp ?? new Date().toISOString();
const liveness = event.liveness ?? previous?.liveness;
const subagent: ProviderSubagentDescriptor = {
id: event.id,
parentAgentId,
@@ -134,7 +126,6 @@ export class ProviderSubagentStore {
cwd: stickyField(event.cwd, previous?.cwd),
subtitle: stickyField(event.subtitle, previous?.subtitle),
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
...(liveness === undefined ? {} : { liveness }),
};
this.descriptors.set(key, subagent);
return { type: "upsert", subagent };
@@ -1,153 +0,0 @@
{
"provenance": {
"package": "pi-subagents",
"version": "0.78.0",
"sourceCommit": "reconstructed-workflow-launch",
"piVersion": "1.2.0",
"captureDate": "2026-10-17"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_wf1",
"toolName": "subagent",
"args": {
"workflow": "tmp/rigor-ui-test.js",
"async": true
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_wf1",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Run fan-out: 1/64 used, 63 remaining\nAsync workflow: [eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1]\n\nThe async run is detached and running in the background.\nMission: 3d6d9bb6-1ea3-4b8d-9c56-6a1f0e3b6f88 (active)"
}
],
"details": {
"mode": "workflow",
"runId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"results": [],
"asyncId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"workflowGraph": {
"lanes": [{ "key": "alpha", "agent": "scout" }]
},
"context": "fresh",
"timeoutMs": 1800000,
"deadlineAt": 1790199499998
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent-incremental-child-notify",
"content": "Workflow child completed: **alpha**\nWorkflow run: eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\nChild run: 4cb1c0a2-2f2e-4c31-9c53-0f0d2ad0f3f1\nOutput: /tmp/paseo-pi-extension-phase3/workspace/tmp/alpha.md\nStatus: workflow still running",
"display": false,
"details": {
"childKey": "alpha",
"outcome": "completed"
},
"timestamp": 1790197711046
}
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent-notify",
"content": "Background task completed: **workflow**\n\nWorkflow receipt: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1/receipt.json\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-10-17T21-08-13-722Z_01a0d018/0f2c1b1e-9d1c-4b0e-8d5b-8e6f27a1c2d3/run-0/session.jsonl",
"display": false,
"timestamp": 1790197722046
}
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_wf1",
"name": "subagent",
"arguments": {
"workflow": "tmp/rigor-ui-test.js",
"async": true
}
}
],
"api": "openai-completions",
"provider": "openrouter",
"model": "google/gemini-3.6-flash",
"usage": {
"input": 3303,
"output": 114,
"cacheRead": 7053,
"cacheWrite": 0,
"reasoning": 81,
"totalTokens": 10470,
"cost": {
"input": 0.00247725,
"output": 0.00042750000000000004,
"cacheRead": 0.000528975,
"cacheWrite": 0,
"total": 0.003433725
}
},
"stopReason": "toolUse",
"timestamp": 1790197697608,
"responseId": "gen-1790197697-lxluaTrDkdaxjSXsO957",
"rawStopReason": "tool_calls"
},
{
"role": "toolResult",
"toolCallId": "call_wf1",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Run fan-out: 1/64 used, 63 remaining\nAsync workflow: [eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1]\n\nThe async run is detached and running in the background.\nMission: 3d6d9bb6-1ea3-4b8d-9c56-6a1f0e3b6f88 (active)"
}
],
"details": {
"mode": "workflow",
"runId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"results": [],
"asyncId": "eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1",
"workflowGraph": {
"lanes": [{ "key": "alpha", "agent": "scout" }]
},
"context": "fresh",
"timeoutMs": 1800000,
"deadlineAt": 1790199499998
},
"isError": false,
"timestamp": 1790197700011
},
{
"role": "custom",
"customType": "subagent-incremental-child-notify",
"content": "Workflow child completed: **alpha**\nWorkflow run: eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\nChild run: 4cb1c0a2-2f2e-4c31-9c53-0f0d2ad0f3f1\nOutput: /tmp/paseo-pi-extension-phase3/workspace/tmp/alpha.md\nStatus: workflow still running",
"display": false,
"details": {
"childKey": "alpha",
"outcome": "completed"
},
"timestamp": 1790197711046
},
{
"role": "custom",
"customType": "subagent-notify",
"content": "Background task completed: **workflow**\n\nWorkflow receipt: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1/receipt.json\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/eac895df-6a71-4b4f-9f0a-2ba2b7f0f4a1\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-10-17T21-08-13-722Z_01a0d018/0f2c1b1e-9d1c-4b0e-8d5b-8e6f27a1c2d3/run-0/session.jsonl",
"display": false,
"timestamp": 1790197722046
}
]
}
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { mkdtemp, writeFile, appendFile, rm } from "node:fs/promises";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createPiExtensionHost } from "../index.js";
@@ -45,18 +45,6 @@ function hasCompletedChild(events: AgentStreamEvent[]) {
);
}
function subagentUpserts(events: AgentStreamEvent[]) {
return events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
}
function reportedUpsert(events: AgentStreamEvent[], id: string, status?: string) {
return subagentUpserts(events).some(
(event) => event.id === id && (status === undefined || event.status === status),
);
}
describe("pi-subagents adapter", () => {
test("follows async status and transcript without bg_wait", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-"));
@@ -285,867 +273,4 @@ describe("pi-subagents adapter", () => {
},
]);
});
test("maps a reconstructed workflowScript run to a live spawn row and its children", async () => {
const fixture = readSubagentFixture(new URL("./fixtures/workflow.json", import.meta.url));
const events = await verifySubagentFixture(fixture);
const upserts = events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event : null));
expect(upserts.map((event) => [event?.id, event?.status])).toEqual([
["call_wf1", "running"],
["call_wf1", "running"],
["call_wf1:alpha", "completed"],
["call_wf1", "completed"],
]);
// The workflow call carries no agent/task, so the script file names the run.
expect(upserts[0]?.title).toBe("rigor-ui-test.js");
expect(upserts[1]?.liveness).toBe("poll");
expect(upserts[2]?.title).toBeUndefined();
});
test("maps a reported workflowScript run to its reviewer row and saved timeline", async () => {
// Reported in #5837: `subagent({ workflow: true })` reaches the adapter as `workflowScript`,
// not `workflow`, so a launch check keyed on `workflow` alone drops the whole run.
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-script-"));
const host = createPiExtensionHost();
try {
const sessionFile = join(dir, "child.jsonl");
await writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "README summary" }] },
}) + "\n",
);
const args = {
workflowScript:
"return await runs.run('review', { agent: 'reviewer', task: 'Read README.md' });",
async: false,
};
expect(
host.mapToolCall({
callId: "reported-workflow",
toolName: "subagent",
args,
status: "running",
result: null,
})?.subagents,
).toEqual([
expect.objectContaining({ id: "reported-workflow", title: "workflow", status: "running" }),
]);
const output = host.mapToolCall({
callId: "reported-workflow",
toolName: "subagent",
args,
status: "completed",
result: {
details: {
mode: "workflow",
runId: "reported-run",
results: [{ index: 0, agent: "reviewer", exitCode: 0, sessionFile }],
},
},
});
expect(output?.subagents).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "upsert", title: "reviewer", status: "completed" }),
]),
);
expect(hasChildTimeline(await output!.hydration)).toBe(true);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("keys workflow child rows by their childKey while the run reports progress", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
await expect.poll(() => reportedUpsert(received, "call-wf:beta")).toBe(true);
expect(
subagentUpserts(received).map((event) => [event.id, event.status, event.liveness]),
).toEqual(
expect.arrayContaining([
["call-wf:alpha", "running", "poll"],
["call-wf:beta", "running", "poll"],
["call-wf", "running", "poll"],
]),
);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "complete",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "complete" },
],
}),
);
await expect.poll(() => reportedUpsert(received, "call-wf", "completed")).toBe(true);
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:alpha")?.status,
).toBe("completed");
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("keeps a missing status directory's row alive until the miss budget expires", async () => {
vi.useFakeTimers();
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-lost",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: {
mode: "single",
runId: "run-1",
asyncId: "run-1",
asyncDir: "/nonexistent",
results: [],
},
},
});
await vi.advanceTimersByTimeAsync(250 * 5);
// The run is alive even while its file is missing: every miss tick re-reports the row, or a
// turn end inside the miss budget would cancel live work.
const duringMisses = subagentUpserts(received);
expect(duringMisses.length).toBeGreaterThanOrEqual(5);
expect(duringMisses.every((event) => event.status === "running")).toBe(true);
expect(duringMisses.every((event) => event.liveness === "poll")).toBe(true);
// 60 s of missing ticks, the budget before a status directory counts as gone.
await vi.advanceTimersByTimeAsync(250 * 236);
// The launch row comes back through the caller; polling reports the give-up exactly once.
const afterGiveUp = subagentUpserts(received);
expect(afterGiveUp.findLast((event) => event.id === "call-lost")).toEqual({
type: "upsert",
id: "call-lost",
status: "canceled",
liveness: "poll",
});
expect(afterGiveUp.filter((event) => event.status === "canceled")).toHaveLength(1);
} finally {
host.close();
vi.useRealTimers();
}
});
test("a completion notice stops polling so a stale status file cannot revert the card", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-stale-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({ state: "running", steps: [{ agent: "scout", status: "running" }] }),
);
const statuses = () =>
received.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [event.event.status]
: [],
);
await expect.poll(() => statuses().length).toBeGreaterThan(1);
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task completed: **scout**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
});
const settled = statuses().length;
// The run's directory is still on disk with a running status; polling must stay off.
await new Promise((resolve) => setTimeout(resolve, 400));
expect(statuses().length).toBe(settled);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("a bg_wait result keeps its verdict while the run's status names no child yet", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-settled-owner-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.follow((event) => received.push(event));
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
// A single unnamed child takes the owner's id, so this result settles the launch row while the
// run itself keeps polling.
expect(
host.mapToolCall({
callId: "wait-1",
toolName: "bg_wait",
args: { runId: "run-1" },
status: "completed",
result: {
details: {
mode: "single",
results: [],
completions: [
{ runId: "run-1", results: [{ agent: "scout", success: true, exitCode: 0 }] },
],
},
},
})?.subagents,
).toEqual([expect.objectContaining({ id: "call-1", status: "completed" })]);
// The queue has not started the child yet, so the status file names no step: that tick must
// not re-report the owner as running over the verdict it already has.
await writeFile(join(dir, "status.json"), JSON.stringify({ state: "running", steps: [] }));
const beforeTick = received.length;
await vi.advanceTimersByTimeAsync(250 * 2);
expect(subagentUpserts(received.slice(beforeTick))).toEqual([]);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a paused run settles as canceled", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-paused-"));
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-1",
toolName: "subagent",
args: { agent: "scout", task: "Inspect", async: true },
status: "completed",
result: {
details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] },
},
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task paused: **scout**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
})?.subagents,
).toEqual([{ type: "upsert", id: "call-1", status: "canceled" }]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("a paused workflow child notice settles its row as canceled", () => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: "/nonexistent",
results: [],
},
},
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child paused (needs attention): **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "canceled" }]);
});
test("partial and rejected runs settle every child instead of leaving a spinner", async () => {
for (const [state, expected] of [
["partial", ["completed", "completed", "canceled"]],
["rejected", ["completed", "completed", "failed"]],
] as const) {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-partial-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state,
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
// WorkflowNodeStatus spells the same outcome `completed`.
{ agent: "auditor", workflowKey: "gamma", status: "completed" },
{
agent: "reviewer",
workflowKey: "beta",
status: state === "partial" ? "running" : "failed",
},
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(subagentUpserts(received).map((event) => [event.id, event.status])).toEqual(
expect.arrayContaining([
["call-wf:alpha", expected[0]],
["call-wf:gamma", expected[1]],
["call-wf:beta", expected[2]],
["call-wf", "canceled"],
]),
);
expect(
subagentUpserts(received).filter((event) => event.status === "running"),
).toHaveLength(0);
// The run is terminal, so the poll stops instead of re-reporting fresh rows.
const settled = received.length;
await vi.advanceTimersByTimeAsync(250 * 4);
expect(received.length).toBe(settled);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
}
});
test("a child notice keeps its status while the run is still reporting", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-notice-pin-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
// The stale step entry still says running; the child's notice is its verdict.
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
const beforeNotice = received.length;
await vi.advanceTimersByTimeAsync(250 * 3);
expect(
subagentUpserts(received.slice(beforeNotice)).filter(
(event) => event.id === "call-wf:alpha",
),
).toEqual([]);
// The rest of the run keeps re-reporting, so this is not a poll that simply stopped.
expect(reportedUpsert(received.slice(beforeNotice), "call-wf:beta")).toBe(true);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a child notice's verdict survives the run's terminal tick", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-notice-terminal-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
host.follow((event) => received.push(event));
// The child's notice settles it before the run itself ends.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
// The run then ends without a verdict for alpha: its step entry still says running, which the
// terminal tick would read as canceled if the notice were not the child's verdict.
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "stopped",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "running" },
{ agent: "reviewer", workflowKey: "beta", status: "stopped" },
],
}),
);
await vi.advanceTimersByTimeAsync(250);
expect(
subagentUpserts(received).filter((event) => event.id === "call-wf:alpha" && event.status),
).toEqual([expect.objectContaining({ id: "call-wf:alpha", status: "completed" })]);
// The child that never reported a verdict of its own is still settled by that tick.
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:beta")?.status,
).toBe("canceled");
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("gives up without rewriting a child that already settled", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-giveup-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "complete" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(reportedUpsert(received, "call-wf:beta")).toBe(true);
// The workflow keeps running, so polling continues after the child's own notice.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
await rm(join(dir, "status.json"));
await vi.advanceTimersByTimeAsync(250 * 241);
expect(
subagentUpserts(received).some(
(event) => event.id === "call-wf:alpha" && event.status === "canceled",
),
).toBe(false);
expect(
subagentUpserts(received).findLast((event) => event.id === "call-wf:beta")?.status,
).toBe("canceled");
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a run's final notice cancels the children it stopped with", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-stopped-children-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [
{ agent: "scout", workflowKey: "alpha", status: "running" },
{ agent: "reviewer", workflowKey: "beta", status: "running" },
],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
expect(reportedUpsert(received, "call-wf:beta")).toBe(true);
// alpha reports its own verdict before the run stops.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
// The run then stops with beta unfinished. The notice ends the poll, so no later tick will
// ever report beta: it settles as canceled, never completed. alpha keeps its own verdict.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-notify",
content: [
"Background task stopped: **rigor-ui-test.js**",
"",
`Retention-managed async directory: ${dir}`,
].join("\n"),
})?.subagents,
).toEqual([
{ type: "upsert", id: "call-wf", status: "canceled" },
{ type: "upsert", id: "call-wf:beta", status: "canceled" },
]);
// Polling is off; the status file still says running and must not bring the rows back.
const settled = received.length;
await vi.advanceTimersByTimeAsync(250 * 3);
expect(received.length).toBe(settled);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("names a child a notice settled before the first poll saw it", async () => {
vi.useFakeTimers();
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-fast-child-"));
const received: AgentStreamEvent[] = [];
const host = createPiExtensionHost();
try {
host.mapToolCall({
callId: "call-wf",
toolName: "subagent",
args: { workflow: "tmp/rigor-ui-test.js", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "run-wf",
asyncId: "run-wf",
asyncDir: dir,
results: [],
},
},
});
// A fast child reports before any poll tick, so its row is born without a name.
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **alpha**\nWorkflow run: run-wf",
})?.subagents,
).toEqual([{ type: "upsert", id: "call-wf:alpha", status: "completed" }]);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [{ agent: "scout", workflowKey: "alpha", status: "running" }],
}),
);
host.follow((event) => received.push(event));
await vi.advanceTimersByTimeAsync(250);
// The poll leaves the settled status alone but still fills the name status.json carries.
const alphaUpserts = () =>
subagentUpserts(received).filter((event) => event.id === "call-wf:alpha");
expect(alphaUpserts()).toEqual([
{
type: "upsert",
id: "call-wf:alpha",
title: "scout",
status: "completed",
liveness: "poll",
},
]);
// Reported once, not on every tick.
await vi.advanceTimersByTimeAsync(250 * 2);
expect(alphaUpserts()).toHaveLength(1);
} finally {
host.close();
vi.useRealTimers();
await rm(dir, { recursive: true, force: true });
}
});
test("a workflow that reported rows replaces its placeholder with them", () => {
const host = createPiExtensionHost();
const args = { workflow: true };
// The in-flight placeholder is poll-marked, so a turn end can settle it if its call never
// reports back.
expect(
host.mapToolCall({
callId: "fanout",
toolName: "subagent",
args,
status: "running",
result: null,
})?.subagents,
).toEqual([expect.objectContaining({ id: "fanout", status: "running", liveness: "poll" })]);
expect(
host.mapToolCall({
callId: "fanout",
toolName: "subagent",
args,
status: "completed",
result: {
details: {
mode: "workflow",
results: [
{ index: 0, agent: "scout", workflowKey: "alpha", success: true, exitCode: 0 },
{ index: 1, agent: "reviewer", workflowKey: "beta", success: true, exitCode: 0 },
],
},
},
})?.subagents,
).toEqual([
{ type: "remove", id: "fanout" },
expect.objectContaining({ id: "fanout:alpha", title: "scout", status: "completed" }),
expect.objectContaining({ id: "fanout:beta", title: "reviewer", status: "completed" }),
]);
});
test("replays keyed async workflow child transcripts without a live poll", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-workflow-replay-"));
const host = createPiExtensionHost();
try {
const sessionFile = join(dir, "child.jsonl");
await writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "Saved reviewer answer" }],
},
}) + "\n",
);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "complete",
steps: [{ agent: "reviewer", workflowKey: "review", status: "complete", sessionFile }],
}),
);
const output = host.mapToolCall({
callId: "replayed-workflow",
toolName: "subagent",
args: { workflowScript: "return await runs.all(items);", async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "saved-run",
asyncId: "saved-run",
asyncDir: dir,
results: [],
},
},
});
const events = await output!.hydration;
expect(
events.some(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "replayed-workflow:review",
),
).toBe(true);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("stops following a child whose completion arrived before its transcript was attached", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-finished-reader-"));
const host = createPiExtensionHost();
const received: AgentStreamEvent[] = [];
try {
const sessionFile = join(dir, "child.jsonl");
const message = (text: string) =>
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text }],
},
}) + "\n";
await writeFile(sessionFile, message("Finished child"));
host.follow((event) => received.push(event));
const spawn = host.mapToolCall({
callId: "fast-workflow",
toolName: "subagent",
args: { workflow: true, async: true },
status: "completed",
result: {
details: {
mode: "workflow",
runId: "fast-run",
asyncId: "fast-run",
asyncDir: dir,
results: [],
},
},
});
await spawn!.hydration;
const notice = host.mapCustomMessage({
role: "custom",
customType: "subagent-incremental-child-notify",
content: "Workflow child completed: **review**\nWorkflow run: fast-run",
});
await notice!.hydration;
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
steps: [{ agent: "reviewer", workflowKey: "review", status: "running", sessionFile }],
}),
);
await expect.poll(() => hasChildTimeline(received)).toBe(true);
const timelineCount = () =>
received.filter(
(event) => event.type === "provider_subagent" && event.event.type === "timeline",
).length;
const count = timelineCount();
await appendFile(sessionFile, message("Later content must not be followed"));
await new Promise((resolve) => setTimeout(resolve, 600));
expect(timelineCount()).toBe(count);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
});
@@ -2,20 +2,12 @@ import { z } from "zod";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiAgentMessage } from "../../rpc-types.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
import type { PiExtension, PiExtensionToolCall, PiExtensionToolMapping } from "../contract.js";
type CustomMessage = Extract<PiAgentMessage, { role: "custom" }>;
type SubagentUpsert = Extract<ProviderSubagentInputEvent, { type: "upsert" }>;
const Args = z
.object({
agent: z.string().trim().min(1).optional(),
task: z.string().trim().min(1).optional(),
workflow: z.union([z.string().trim().min(1), z.literal(true)]).optional(),
// `subagent({ workflow: true })` launches the reply's script; Pi delivers it as this field.
workflowScript: z.string().trim().min(1).optional(),
async: z.boolean().optional(),
action: z.unknown().optional(),
})
@@ -24,7 +16,6 @@ const Row = z
.object({
index: z.number().int().nonnegative().optional(),
agent: z.string().trim().min(1),
workflowKey: z.string().trim().min(1).optional(),
exitCode: z.number().optional(),
success: z.boolean().optional(),
sessionFile: z.string().trim().min(1).optional(),
@@ -50,7 +41,6 @@ const AsyncStatus = z
z
.object({
agent: z.string().trim().min(1),
workflowKey: z.string().trim().min(1).optional(),
status: z.string().optional(),
sessionFile: z.string().trim().min(1).optional(),
})
@@ -59,116 +49,21 @@ const AsyncStatus = z
})
.passthrough();
/**
* Run states that will not change again: `paused` waits on the user, and `partial`/`rejected`
* still end the run even when some steps never produced a result of their own.
*/
const TERMINAL_RUN_STATES = new Set([
"complete",
"failed",
"stopped",
"paused",
"partial",
"rejected",
]);
/** The run's status file, or undefined while it is missing or not yet readable. */
function readAsyncStatus(dir: string): z.infer<typeof AsyncStatus> | undefined {
try {
const parsed = AsyncStatus.safeParse(
JSON.parse(readFileSync(join(dir, "status.json"), "utf8")),
);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
}
/** One async run the poll follows, keyed by its async directory. */
interface AsyncRun {
owner: string;
polling: boolean;
misses: number;
deadline: number;
rows: Set<string>;
}
/**
* Missing ticks before a status directory counts as gone: 60 s at the 250 ms follow cadence. A live
* run can briefly lose its file while the concurrency queue starts it or retention rotates it.
*/
const POLL_MISS_LIMIT = 240;
/**
* A run that never reaches a terminal state in its status file must not keep a card spinning
* forever. pi-subagents' own default run timeout is 30 minutes; this is the backstop.
*/
const POLL_TTL_MS = 6 * 60 * 60 * 1000;
function asyncChildStatus(runState: string, stepStatus?: string) {
if (!TERMINAL_RUN_STATES.has(runState)) return "running";
// A partial or rejected run says nothing about its individual children: read their own outcome,
// and treat a step that never reached one as canceled rather than still running.
if (runState === "partial" || runState === "rejected") {
// Two vocabularies: status.json steps say `complete`, WorkflowNodeStatus says `completed`.
if (stepStatus === "complete" || stepStatus === "completed") return "completed";
if (stepStatus === "failed" || stepStatus === "error") return "failed";
return "canceled";
}
if (runState === "stopped" || runState === "paused" || stepStatus === "stopped")
return "canceled";
if (!["complete", "failed", "stopped"].includes(runState)) return "running";
if (runState === "stopped" || stepStatus === "stopped") return "canceled";
if (runState === "failed" || stepStatus === "failed" || stepStatus === "error") return "failed";
return "completed";
}
/** The status of the run itself, used for the row a launch opened before its children reported. */
function asyncRunStatus(runState: string) {
if (runState === "complete") return "completed" as const;
if (runState === "failed") return "failed" as const;
return "canceled" as const;
}
/**
* The `subagent-notify` / `subagent-incremental-child-notify` header. A grouped notice reports
* every run as completed; a paused run is waiting on the user, which the track shows as canceled.
*/
/** The `subagent-notify` header; a grouped notice reports every run as completed. */
function noticeStatus(header: string) {
if (/^Background tasks completed \(\d+\):/.test(header)) return "completed";
const outcome = header.match(
/^(?:Background task|Detached foreground task|Workflow child) (completed|failed|paused|stopped)(?: \([^)]*\))?: \*\*/,
/^(?:Background task|Detached foreground task) (completed|failed|stopped): \*\*/,
)?.[1];
if (!outcome) return undefined;
return outcome === "paused" || outcome === "stopped"
? ("canceled" as const)
: (outcome as "completed" | "failed");
}
/**
* A workflow launch carries no `agent`/`task`: the children are declared by the script. Its label
* is the script file name, or the bare `workflow` of the reply-block form.
*/
function workflowLabel(workflow: string | true | undefined) {
if (workflow === undefined) return undefined;
if (workflow === true) return "workflow";
const segments = workflow.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? workflow;
}
/**
* A workflow launch carries no agent/task; its children are declared by the script. A reply-scoped
* script arrives as `workflowScript` instead of a `workflow` path, so it takes the generic label.
*/
function workflowTitle(args: { workflow?: string | true; workflowScript?: string }) {
const label = workflowLabel(args.workflow);
if (label !== undefined) return label;
return args.workflowScript === undefined ? undefined : "workflow";
}
/**
* A row keeps one id from launch to settle: the owner alone for a single unnamed child, otherwise
* `owner:key`. Workflow children carry their `workflowKey`, so progress updates, child notices and
* final results all land on the same row.
*/
function rowId(owner: string, count: number, key: string | undefined, index: number) {
return count === 1 && !key ? owner : `${owner}:${key ?? index}`;
if (outcome === "stopped") return "canceled";
return outcome as "completed" | "failed" | undefined;
}
export const piSubagents: PiExtension = {
@@ -176,64 +71,13 @@ export const piSubagents: PiExtension = {
createSession: () => {
const callsByRun = new Map<string, string>();
const readSessions = new Set<string>();
/** Rows already reported terminal; a late give-up must not rewrite one of them. */
const settledRows = new Map<string, NonNullable<SubagentUpsert["status"]>>();
/** Rows whose poll already reported the step's agent. A settled row is still named once. */
const namedRows = new Set<string>();
const recordTerminal = (id: string, status: NonNullable<SubagentUpsert["status"]>) => {
if (status !== "running") settledRows.set(id, status);
};
/** Keyed by async directory, which both status.json and the completion notice carry. */
const asyncRuns = new Map<string, AsyncRun>();
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
const rememberAsyncRun = (details: z.infer<typeof Details>, call: PiExtensionToolCall) => {
if (details.asyncDir && call.status !== "failed") {
asyncRuns.set(details.asyncDir, {
owner: call.callId,
polling: true,
misses: 0,
deadline: Date.now() + POLL_TTL_MS,
rows: new Set<string>(),
});
asyncRuns.set(details.asyncDir, { owner: call.callId, polling: true });
}
};
/**
* The run is over and nothing will ever update its rows again, so settle them rather than leave a
* spinner. A row that already reported a terminal status keeps it: this is not its verdict.
*/
const giveUpRun = (run: AsyncRun) => {
run.polling = false;
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
for (const id of new Set([run.owner, ...run.rows])) {
if (settledRows.has(id)) continue;
subagents.push({ type: "upsert", id, status: "canceled", liveness: "poll" });
}
return subagents;
};
/**
* The notice is the run's last word: it ends the poll, settles the launch row, and cancels the
* children the run stopped with. No later tick will report those, and nothing here proves they
* succeeded, so they settle as canceled. A child that already reported its own verdict keeps it.
*/
const settleRunNotice = (run: AsyncRun, status: "completed" | "failed" | "canceled") => {
// A stale status.json must not revert this card either.
run.polling = false;
recordTerminal(run.owner, status);
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [
{ type: "upsert", id: run.owner, status },
];
for (const id of run.rows) {
if (settledRows.has(id)) continue;
subagents.push({ type: "upsert", id, status: "canceled" });
}
return subagents;
};
/**
* A row id can settle twice: a completion notice records a verdict in `settledRows`, and a
* `subagent_wait` result repeats the run's rows with the run's own verdict. Both are terminal, so
* a disagreement moves a row terminal-to-terminal (completed → failed) and never resurrects it;
* the owner row is what carries the run outcome.
*/
// ponytail: child verdicts can diverge between notice and bg_wait result — unify when one source of truth exists
const collectRows = (
owner: string,
rows: z.infer<typeof Row>[],
@@ -243,19 +87,17 @@ export const piSubagents: PiExtension = {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
for (const [index, row] of rows.entries()) {
const id = rowId(owner, rows.length, row.workflowKey, row.index ?? index);
const status =
failed || row.success === false || (row.success !== true && row.exitCode !== 0)
? ("failed" as const)
: ("completed" as const);
recordTerminal(id, status);
const id = rows.length === 1 ? owner : `${owner}:${row.index ?? index}`;
subagents.push({
type: "upsert",
id,
title: row.agent,
description,
toolCallId: owner,
status,
status:
failed || row.success === false || (row.success !== true && row.exitCode !== 0)
? "failed"
: "completed",
});
if (row.sessionFile && !readSessions.has(row.sessionFile)) {
readSessions.add(row.sessionFile);
@@ -281,169 +123,66 @@ export const piSubagents: PiExtension = {
}
return subagents.length ? { subagents, childSessions } : undefined;
};
/** Project a saved status both when replaying a launch and when its live poll advances. */
const projectAsyncStatus = (run: AsyncRun, status: z.infer<typeof AsyncStatus>) => {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
const runTerminal = TERMINAL_RUN_STATES.has(status.state);
for (const [index, step] of status.steps.entries()) {
const id = rowId(run.owner, status.steps.length, step.workflowKey, index);
run.rows.add(id);
// A child's notice supplies its verdict even when its saved status is stale. Reuse that
// verdict when attaching a transcript or filling its name after the notice arrived.
const settledStatus = settledRows.get(id);
const childSession =
step.sessionFile && !readSessions.has(step.sessionFile)
? { id, file: step.sessionFile }
: undefined;
if (!settledStatus) {
const childStatus = asyncChildStatus(status.state, step.status);
recordTerminal(id, childStatus);
namedRows.add(id);
subagents.push({
type: "upsert",
id,
title: step.agent,
status: childStatus,
liveness: "poll" as const,
});
} else if (!namedRows.has(id) || childSession) {
// Attach a late transcript with its known verdict, so the host reads it once instead of
// starting a follower that no later terminal update will release.
namedRows.add(id);
subagents.push({
type: "upsert",
id,
title: step.agent,
status: settledStatus,
liveness: "poll" as const,
});
}
if (childSession) {
readSessions.add(childSession.file);
childSessions.push(childSession);
}
}
if (runTerminal) run.polling = false;
// The launch row stands for the whole run, and a single unnamed child already took its
// id. Re-reporting it every tick is also what proves to the manager that this run is alive.
// A row that already settled keeps that verdict: a result that landed before the run's
// steps named its children must not be reverted to running by the next tick.
if (!run.rows.has(run.owner) && !settledRows.has(run.owner)) {
const ownerStatus = run.polling ? ("running" as const) : asyncRunStatus(status.state);
recordTerminal(run.owner, ownerStatus);
subagents.push({
type: "upsert",
id: run.owner,
status: ownerStatus,
liveness: "poll",
});
}
return { subagents, childSessions };
};
/** A settled spawn: Pi either reports the rows it ran, or an async directory the poll follows. */
const mapSettledSpawn = (
call: PiExtensionToolCall,
base: SubagentUpsert,
description: string | undefined,
asyncRequested: boolean,
): Pick<PiExtensionToolMapping, "subagents" | "childSessions"> => {
const failed = call.status === "failed";
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
if (!details.success || details.data.mode === "management") {
if (!failed) return { subagents: [] };
recordTerminal(call.callId, "failed");
return { subagents: [{ ...base, status: "failed" as const }] };
}
if (details.data.runId) callsByRun.set(details.data.runId, call.callId);
if (details.data.results.length === 0) {
if (failed) {
recordTerminal(call.callId, "failed");
return { subagents: [{ ...base, status: "failed" as const }] };
}
if (!details.data.asyncId && !asyncRequested) {
// ponytail: inferred completion — revisit if pi can return success while a child outlives the call.
recordTerminal(call.callId, "completed");
return { subagents: [{ ...base, status: "completed" as const }] };
}
// The placeholder row is now backed by a per-tick poll, which is what proves it alive.
rememberAsyncRun(details.data, call);
const dir = details.data.asyncDir;
const run = dir ? asyncRuns.get(dir) : undefined;
const status = dir ? readAsyncStatus(dir) : undefined;
if (run && status) return projectAsyncStatus(run, status);
return { subagents: [{ ...base, status: "running" as const, liveness: "poll" as const }] };
}
const collected = collectRows(call.callId, details.data.results, description, failed);
// A run that reported rows of its own never keeps the placeholder card it opened at launch.
if (!collected.subagents.some((event) => event.id === call.callId)) {
collected.subagents.unshift({ type: "remove", id: call.callId });
}
return collected;
};
const mapSpawn = (call: PiExtensionToolCall) => {
const args = Args.safeParse(call.args);
if (!args.success || args.data.action !== undefined) return undefined;
const single = Boolean(args.data.agent && args.data.task);
let workflow: string | undefined;
if (!single) workflow = workflowTitle(args.data);
if (!single && workflow === undefined) return undefined;
const base = {
type: "upsert" as const,
id: call.callId,
title: (single ? args.data.agent : workflow) ?? "workflow",
description: args.data.task,
toolCallId: call.callId,
};
if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task)
return undefined;
const detail = {
type: "sub_agent" as const,
subAgentType: base.title,
subAgentType: args.data.agent,
description: args.data.task,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
};
// The in-flight placeholder is poll-marked: a turn interrupted before this call reports
// never delivers a terminal mapping, and only a marked row can be settled by the sweep.
// ponytail: replay re-emits running before the settled rows land — one-pass client flip, upgrade when replay maps calls with their results first
const base = {
type: "upsert" as const,
id: call.callId,
title: args.data.agent,
description: args.data.task,
toolCallId: call.callId,
};
if (call.status === "running")
return { detail, subagents: [{ ...base, status: "running" as const }] };
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
if (!details.success || details.data.mode === "management")
return call.status === "failed"
? { detail, subagents: [{ ...base, status: "failed" as const }] }
: { detail };
if (details.data.runId) callsByRun.set(details.data.runId, call.callId);
if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) {
rememberAsyncRun(details.data, call);
return {
detail,
subagents: [{ ...base, status: "running" as const, liveness: "poll" as const }],
subagents: [
{
...base,
status: call.status === "failed" ? ("failed" as const) : ("running" as const),
},
],
};
}
if (details.data.results.length === 0 && call.status === "failed")
return { detail, subagents: [{ ...base, status: "failed" as const }] };
return {
detail,
...mapSettledSpawn(call, base, args.data.task, Boolean(args.data.async)),
...collectRows(call.callId, details.data.results, args.data.task, call.status === "failed"),
};
};
const noticeText = (message: CustomMessage) =>
typeof message.content === "string"
? message.content
: extractTextFromToolResult({ content: message.content });
const mapWorkflowChildNotice = (message: CustomMessage) => {
const lines = noticeText(message)?.split("\n") ?? [];
const status = noticeStatus(lines[0] ?? "");
const key = lines[0]?.match(
/^Workflow child (?:completed|failed|paused|stopped)(?: \([^)]*\))?: \*\*(.+?)\*\*/,
)?.[1];
const runId = lines.find((line) => line.startsWith("Workflow run: "))?.slice(14);
const owner = runId ? callsByRun.get(runId) : undefined;
if (!status || !key || !owner) return undefined;
const id = `${owner}:${key}`;
recordTerminal(id, status);
return { subagents: [{ type: "upsert" as const, id, status }] };
};
return {
mapToolCall(call) {
if (call.toolName === "bg_wait") return mapWait(call);
if (call.toolName === "subagent") return mapSpawn(call);
return undefined;
},
// The notice is the only completion Pi persists, so replay settles async runs from it.
mapCustomMessage(message) {
if (message.customType === "subagent-incremental-child-notify")
return mapWorkflowChildNotice(message);
if (message.customType !== "subagent-notify") return undefined;
const lines = noticeText(message)?.split("\n") ?? [];
const text =
typeof message.content === "string"
? message.content
: extractTextFromToolResult({ content: message.content });
const lines = text?.split("\n") ?? [];
const status = noticeStatus(lines[0] ?? "");
if (!status) return undefined;
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
@@ -453,10 +192,8 @@ export const piSubagents: PiExtension = {
for (const line of lines) {
const dir = line.match(/^Retention-managed async directory: (.+)$/)?.[1];
if (dir) {
const run = asyncRuns.get(dir);
owner = run?.owner;
if (!run || !owner) continue;
subagents.push(...settleRunNotice(run, status));
owner = asyncRuns.get(dir)?.owner;
if (owner) subagents.push({ type: "upsert", id: owner, status });
continue;
}
const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined;
@@ -472,38 +209,36 @@ export const piSubagents: PiExtension = {
poll() {
const subagents: NonNullable<PiExtensionToolMapping["subagents"]> = [];
const childSessions: NonNullable<PiExtensionToolMapping["childSessions"]> = [];
const now = Date.now();
for (const [dir, run] of asyncRuns) {
if (!run.polling) continue;
if (now >= run.deadline) {
subagents.push(...giveUpRun(run));
let raw: unknown;
try {
raw = JSON.parse(readFileSync(join(dir, "status.json"), "utf8"));
} catch {
continue;
}
const status = readAsyncStatus(dir);
if (!status) {
run.misses += 1;
if (run.misses >= POLL_MISS_LIMIT) {
subagents.push(...giveUpRun(run));
continue;
const parsed = AsyncStatus.safeParse(raw);
if (!parsed.success) continue;
const terminal = ["complete", "failed", "stopped"].includes(parsed.data.state);
for (const [index, step] of parsed.data.steps.entries()) {
const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`;
const status = asyncChildStatus(parsed.data.state, step.status);
subagents.push({ type: "upsert", id, title: step.agent, status });
if (step.sessionFile && !readSessions.has(step.sessionFile)) {
readSessions.add(step.sessionFile);
childSessions.push({ id, file: step.sessionFile });
}
// The run is still alive even though its file is gone for now. Keep re-reporting its
// unsettled rows: silence would freeze their timestamp and a turn end would cancel a
// live run inside the miss budget.
for (const id of new Set([run.owner, ...run.rows])) {
if (settledRows.has(id)) continue;
}
if (terminal) {
if (parsed.data.steps.length === 0) {
subagents.push({
type: "upsert",
id,
status: "running" as const,
liveness: "poll" as const,
id: run.owner,
status: parsed.data.state === "complete" ? "completed" : "failed",
});
}
continue;
run.polling = false;
}
run.misses = 0;
const output = projectAsyncStatus(run, status);
subagents.push(...output.subagents);
childSessions.push(...output.childSessions);
}
return subagents.length || childSessions.length ? { subagents, childSessions } : undefined;
},
@@ -6,7 +6,7 @@ import type { Logger } from "pino";
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentManager } from "../agent-manager.js";
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import {
AgentFeatureSchema,
@@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
.object(legacyTopLevelCreateAgentInputSchema)
.strict();
const commonSendAgentPromptInputSchema = {
activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe(
'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.',
),
agentId: z.string(),
prompt: z.string(),
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
@@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
inputSchema: sendAgentPromptInputSchema,
outputSchema: {
success: z.boolean(),
disposition: z.enum(["turn_started", "out_of_band"]).optional(),
disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(),
status: AgentStatusEnum,
guidance: z.string().optional(),
},
},
async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => {
async ({
agentId,
prompt,
sessionMode,
notifyOnFinish = Boolean(callerAgentId),
activeTurnBehavior = callerAgentId ? "steer" : "interrupt",
}) => {
let stopNotification: (() => void) | undefined;
function armFinishNotification(disposition: PromptDispatchDisposition): void {
if (!callerAgentId || !notifyOnFinish) {
return;
}
let acceptDispatch!: (result: PromptDispatchDisposition | false) => void;
const dispatchResult = new Promise<PromptDispatchDisposition | false>((resolve) => {
acceptDispatch = resolve;
});
const notifying = Boolean(callerAgentId && notifyOnFinish);
if (callerAgentId && notifyOnFinish) {
stopNotification = setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
waitForTurnStart: disposition === "turn_started",
waitForTurnStart: false,
dispatchResult,
logger: childLogger,
});
}
function confirmDispatch(disposition: PromptDispatchDisposition): void {
// A new turn must ignore outcomes from the displaced turn. Its iterator
// has not started yet, so rearm here; steering keeps the early observer.
if (disposition !== "turn_started") {
acceptDispatch(disposition);
return;
}
acceptDispatch(disposition);
if (!callerAgentId || !notifyOnFinish) return;
stopNotification?.();
stopNotification = setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
waitForTurnStart: true,
logger: childLogger,
});
}
const notifying = Boolean(callerAgentId && notifyOnFinish);
const { disposition } = await sendPromptToAgent({
agentManager,
agentStorage,
@@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
prompt,
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
sessionMode,
onDispatch: armFinishNotification,
activeTurnBehavior,
onDispatch: confirmDispatch,
logger: childLogger,
}).catch((error) => {
acceptDispatch(false);
stopNotification?.();
if (error instanceof AgentNotFoundError) {
throw new PaseoToolInputError(error.message);
+4 -1
View File
@@ -289,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab](
```bash
paseo send <id> "now run the tests"
paseo send <id> --image screenshot.png "what's wrong here?"
paseo send <id> --no-wait "queue this task"
paseo send <id> --no-wait "run this task"
paseo send <id> --behavior interrupt "stop and do this instead"
```
Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced.
## Viewing logs
```bash
+1 -1
View File
@@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre
> Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.”
The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too.
The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too.
Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send <id>`](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine).
+1 -1
View File
@@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.