Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
- Fixed interactive steering stalling during post-prompt unwind when the terminal transcript message is a bash or Python execution; the auto-continue gate now treats all three terminal roles consistently and regression coverage pins delivery for assistant, bash, and Python tails (#4739).
- Restored live subagent progress in the `subagent await` panel through a bounded public DTO. #4748 dropped the `progress` field believing it exposed model-generated deltas and tool output to the parent model; the renderer needs live status, tool names, output counts, fast mode, and retry state, but raw progress must not enter tool-result, ACP, or telemetry envelopes. The await signature now tracks only those approved fields and ignores countdown-only churn, so the panel updates without exposing model deltas, tool arguments, arbitrary output, or nested task payloads.
- Custom-provider `apiKeyEnv` credentials sourced from the trusted agent `.env` now follow rotations on the next request instead of remaining pinned to the process-start value. Long-running sessions previously kept sending a revoked OAuth access token after an external account synchronizer atomically replaced the agent `.env`; the registry now re-reads only that trusted regular file and updates or removes the config credential override, failing closed for a symlink or read error after agent ownership is established. Presence in the agent file makes it authoritative over an older value inherited from the launching shell, while names absent from the agent file keep their existing environment precedence. Explicit `apiKey` values remain authoritative when both forms are configured, and caller-project `.env` files remain excluded from credential resolution.
- Coordinator event journal rows can now be pushed to one opt-in webhook (#4706). External orchestrators that cannot stay attached to `gjc_coordinator_watch_events` long-poll (a 300s `await_turn` timeout is not session death) had no push of **existing** journal rows; they can now set `GJC_COORDINATOR_MCP_EVENT_WEBHOOK_URL` to receive each row as an authenticated POST whose body is the exact native `watch_events` record — same `seq`, same stable `id`, at-least-once so sinks dedupe on `id`. The feature is env-only and default-off (no MCP tool can set or read it), destinations are allowlisted (`https:` anywhere, `http:` loopback only, no redirects), the bearer token comes from a secret file path rather than env, an optional session-id scope restricts delivery to authorized sessions, and delivery runs through a durable per-row outbox off the journal append path with bounded attempts, exponential backoff, and a bounded request timeout — a dead sink never delays or rewrites terminal turn/session persistence. The five `GJC_COORDINATOR_MCP_EVENT_WEBHOOK_*` variables resolve through the trusted credential environment (`$credentialEnv`, the same provenance as the crash-relay DSN) rather than raw `process.env`, so a checkout's `.env` cannot select the egress destination or the token file. `watch_events` long-poll is unchanged and remains the source of truth; `gjc coordinator doctor` reports the resolved webhook state.
- Runtime skill discovery now scans `skills.customDirectories`. Session startup already loaded those directories through `loadSkills`, but `discoverRuntimeSkills` and `findRuntimeSkillByName` searched only the canonical project and user roots, so a configured custom skill was invocable by exact name yet absent from every `skill_discovery` search -- usable only by someone who already knew it existed. Both discovery entry points now scan the configured directories at user level (so project-scoped queries exclude them), deduplicated and tilde-expanded the same way `loadSkills` does. Naming a directory is explicit consent, so custom directories are not gated on `skills.trustUserSkills` -- matching the startup rule -- while the `skills.enabled` master switch still suppresses them.
Expand Down
107 changes: 98 additions & 9 deletions packages/coding-agent/src/tools/subagent-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@ import type { Component } from "@gajae-code/tui";
import { Text } from "@gajae-code/tui";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import {
collectProviderDegradationGroups,
hasActiveProviderRetryInProgress,
providerProgressAgeLabel,
providerRetryPhaseLabel,
} from "../task/provider-retry-status";
import { renderSubagentLiveProgress } from "../task/render";
import { providerRetryPhaseLabel } from "../task/provider-retry-status";
import { Ellipsis, Hasher, renderStatusLine } from "../tui";
import {
formatDuration,
Expand All @@ -27,7 +21,12 @@ import {
type ToolUIStatus,
truncateToWidth,
} from "./render-utils";
import { type SubagentSnapshot, type SubagentToolDetails, subagentAwaitRenderedStateSignature } from "./subagent";
import {
type SubagentLiveProgress,
type SubagentSnapshot,
type SubagentToolDetails,
subagentAwaitRenderedStateSignature,
} from "./subagent";

export { subagentAwaitRenderedStateSignature } from "./subagent";

Expand Down Expand Up @@ -89,6 +88,30 @@ function snapshotHasActiveRetry(snapshot: SubagentSnapshot): boolean {
return hasActiveProviderRetryInProgress(snapshot.progress);
}

function hasActiveProviderRetryInProgress(progress: SubagentLiveProgress): boolean {
return progress.status === "running" && progress.retryState !== undefined;
}

function providerProgressAgeLabel(progress: SubagentLiveProgress["retryState"], nowMs: number): string {
if (progress?.lastProviderProgressAtMs === undefined) return "no provider events yet";
const ageSeconds = Math.max(0, Math.floor((nowMs - progress.lastProviderProgressAtMs) / 1000));
return `last provider progress ${ageSeconds}s ago`;
}

function collectProviderDegradationGroups(
progress: readonly SubagentLiveProgress[],
): Array<{ provider: string; count: number }> {
const counts = new Map<string, number>();
for (const item of progress) {
if (item.status !== "running" || !item.retryState) continue;
const provider = item.retryState.provider ?? "provider";
counts.set(provider, (counts.get(provider) ?? 0) + 1);
}
return Array.from(counts, ([provider, count]) => ({ provider, count }))
.filter(group => group.count > 1)
.sort((a, b) => b.count - a.count || a.provider.localeCompare(b.provider));
}

function boundSubagentBodyLines(lines: string[], width: number): string[] {
return lines.map(line => (line.length > 0 ? truncateToWidth(replaceTabs(line), width, Ellipsis.Omit) : ""));
}
Expand Down Expand Up @@ -166,6 +189,72 @@ function renderSubagentStatusLine(snapshot: SubagentSnapshot, theme: Theme, spin
return `${icon} ${id} ${status} ${duration}`;
}

function renderSubagentLiveProgress(
progress: SubagentLiveProgress,
expanded: boolean,
theme: Theme,
spinnerFrame?: number,
staticTime = false,
): string[] {
const lines: string[] = [];
const prefix = theme.fg("dim", theme.tree.last);
const iconColor = progress.status === "failed" || progress.status === "aborted" ? "error" : "accent";
const icon = formatStatusIcon(
progress.status === "completed" ? "success" : progress.status === "failed" ? "error" : "info",
theme,
progress.status === "running" ? spinnerFrame : undefined,
);
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", progress.id)}`;
if (progress.fastMode && theme.icon.fast) statusLine += ` ${theme.icon.fast}`;
if (progress.retryState && progress.status === "running") {
statusLine += ` ${theme.fg("warning", "provider degraded")}`;
}
lines.push(statusLine);

const continuePrefix = " ";
if (progress.status === "running") {
const tool = progress.currentTool ?? progress.recentTool;
if (tool) lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("muted", tool)}`);
}
if (progress.recentOutputSummary) {
const count = progress.recentOutputSummary.lineCount;
lines.push(
`${continuePrefix}${theme.tree.hook} ${theme.fg("dim", `recent output available (${count} ${count === 1 ? "line" : "lines"})`)}`,
);
}
if (progress.retryState && progress.status === "running") {
const retry = progress.retryState;
const attemptLabel = retry.unbounded
? `attempt ${retry.attempt}, unbounded`
: `attempt ${retry.attempt} of ${retry.maxAttempts}, bounded`;
const progressAge = staticTime ? "" : ` · ${providerProgressAgeLabel(retry, Date.now())}`;
let waitLabel = "";
if (!staticTime) {
const remainingMs = Math.max(0, retry.startedAtMs + retry.delayMs - Date.now());
waitLabel = remainingMs > 0 ? ` in ${formatDuration(remainingMs)}` : " now";
}
lines.push(
`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", `${providerRetryPhaseLabel(retry.kind)} · retrying ${attemptLabel}${waitLabel}${progressAge}`)}`,
);
}
if (progress.retryFailure && progress.status !== "running") {
const attempts = progress.retryFailure.attempt;
lines.push(
`${continuePrefix}${theme.tree.hook} ${theme.fg("error", `auto-retry gave up after ${attempts} attempt${attempts === 1 ? "" : "s"}`)}`,
);
}
if (
expanded &&
progress.status === "running" &&
!progress.currentTool &&
!progress.recentTool &&
!progress.recentOutputSummary
) {
lines.push(`${continuePrefix}${theme.fg("dim", "running, no approved activity summary yet")}`);
}
return lines;
}

// Heavy per-subagent body. The cache path uses staticTime=true; the bounded dynamic
// path opts into wall-clock displays when an active retry exists in the nested tree.
function renderSubagentSnapshotBody(
Expand Down Expand Up @@ -220,7 +309,7 @@ function renderSubagentSnapshotBody(
lines.push(` ${pl}`);
}
} else if (snapshot.liveProgressAvailable && (snapshot.status === "running" || snapshot.status === "queued")) {
lines.push(` ${theme.fg("dim", "running, no activity yet")}`);
lines.push(` ${theme.fg("dim", "running, no approved activity summary yet")}`);
}

const preview = snapshot.errorText?.trim() || snapshot.resultText?.trim();
Expand Down
147 changes: 69 additions & 78 deletions packages/coding-agent/src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { formatDuration, logger, prompt } from "@gajae-code/utils";
import * as z from "zod/v4";
import { type AsyncJob, AsyncJobManager, jobElapsedMs, type SubagentRecord } from "../async";
import subagentDescription from "../prompts/tools/subagent.md" with { type: "text" };
import type { AgentProgress, AgentSource, TaskToolDetails } from "../task/types";
import type { AgentProgress, AgentSource } from "../task/types";
import { Ellipsis, truncateToWidth } from "../tui";
import type { ToolSession } from "./index";
import { replaceTabs } from "./render-utils";
Expand Down Expand Up @@ -92,8 +92,8 @@ export interface SubagentSnapshot {
steerMessage?: string;
steerState?: "queued" | "resume_queued" | "resume_started";
steerPauseRequested?: boolean;
/** Live streaming progress for the awaited subagent (await panel only; UI detail). */
progress?: AgentProgress;
/** Bounded live progress approved for the await panel and public tool details. */
progress?: SubagentLiveProgress;
/** True when a live in-session progress producer exists for this subagent. */
liveProgressAvailable?: boolean;
/** Model the subagent actually runs on (after any auth fallback). */
Expand All @@ -106,6 +106,63 @@ export interface SubagentSnapshot {
fastMode?: boolean;
}

/**
* Public await-panel progress. This is deliberately not `AgentProgress`: raw
* progress contains model deltas, tool arguments, arbitrary output, and nested
* task payloads that must never enter tool-result, ACP, or telemetry envelopes.
*/
export interface SubagentLiveProgress {
id: string;
status: AgentProgress["status"];
currentTool?: string;
recentTool?: string;
recentOutputSummary?: { lineCount: number };
fastMode?: boolean;
retryState?: {
attempt: number;
maxAttempts: number;
unbounded?: boolean;
kind: NonNullable<AgentProgress["retryState"]>["kind"];
provider?: string;
lastProviderProgressAtMs?: number;
delayMs: number;
startedAtMs: number;
};
retryFailure?: { attempt: number };
}

function toSubagentLiveProgress(progress: AgentProgress): SubagentLiveProgress {
return {
id: progress.id,
status: progress.status,
...(progress.currentTool ? { currentTool: progress.currentTool } : {}),
...(progress.currentTool === undefined && progress.recentTools[0]
? { recentTool: progress.recentTools[0].tool }
: {}),
...(progress.recentOutput.length > 0
? { recentOutputSummary: { lineCount: Math.min(progress.recentOutput.length, 6) } }
: {}),
...(progress.fastMode ? { fastMode: true } : {}),
...(progress.retryState
? {
retryState: {
attempt: progress.retryState.attempt,
maxAttempts: progress.retryState.maxAttempts,
...(progress.retryState.unbounded ? { unbounded: true } : {}),
kind: progress.retryState.kind,
...(progress.retryState.provider ? { provider: progress.retryState.provider } : {}),
...(progress.retryState.lastProviderProgressAtMs !== undefined
? { lastProviderProgressAtMs: progress.retryState.lastProviderProgressAtMs }
: {}),
delayMs: progress.retryState.delayMs,
startedAtMs: progress.retryState.startedAtMs,
},
}
: {}),
...(progress.retryFailure ? { retryFailure: { attempt: progress.retryFailure.attempt } } : {}),
};
}

export type SubagentAwaitOutcome = "completed" | "timed_out" | "interrupted";

const AWAIT_INTERRUPTED_GUIDANCE =
Expand Down Expand Up @@ -767,11 +824,11 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
if (!attachLiveProgress) return {};
const liveProgressAvailable = manager.hasLiveSubagent(record.subagentId);
if (!liveProgressAvailable) return { liveProgressAvailable: false };
// AgentProgress includes model-generated deltas, tool arguments, nested
// task details, and arbitrary tool output. None is an approved public
// subagent payload, so await receipts expose only liveness. Terminal public
// output continues through the bounded result/error receipt and agent://.
return { liveProgressAvailable: true };
const progress = manager.getSubagentProgress(record.subagentId);
return {
liveProgressAvailable: true,
...(progress ? { progress: toSubagentLiveProgress(progress) } : {}),
};
}

#recordSnapshot(
Expand Down Expand Up @@ -1063,36 +1120,15 @@ function canonicalizeSnapshotForSignature(snapshot: SubagentSnapshot): unknown {
};
}

function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
function canonicalizeProgressForSignature(progress: SubagentLiveProgress): unknown {
return {
id: progress.id,
agent: progress.agent,
agentSource: progress.agentSource,
status: progress.status,
task: progress.task,
assignment: progress.assignment ?? null,
description: progress.description ?? null,
lastIntent: progress.lastIntent ?? null,
currentTool: progress.currentTool ?? null,
currentToolArgs: progress.currentToolArgs ?? null,
// currentToolStartMs intentionally excluded (only drives elapsed rendering).
recentTools: progress.recentTools.map(tool => ({ tool: tool.tool, args: tool.args })),
recentOutput: progress.recentOutput,
toolCount: progress.toolCount,
tokens: progress.tokens,
contextTokens: progress.contextTokens ?? null,
contextWindow: progress.contextWindow ?? null,
cost: progress.cost,
modelOverride: progress.modelOverride ?? null,
modelSubstitutionWarning: progress.modelSubstitutionWarning ?? null,
// The nested task panel renders this, so it must reach the signature or a
// fast-mode-only change would render differently while comparing byte-identical,
// suppressing the very update that introduces the glyph.
recentTool: progress.recentTool ?? null,
recentOutputSummary: progress.recentOutputSummary ?? null,
fastMode: progress.fastMode ?? false,
// durationMs intentionally excluded (time-derived).
extractedToolData: progress.extractedToolData
? canonicalizeExtractedToolDataForSignature(progress.extractedToolData)
: null,
retryFailure: progress.retryFailure ?? null,
retryState: progress.retryState
? {
attempt: progress.retryState.attempt,
Expand All @@ -1101,52 +1137,7 @@ function canonicalizeProgressForSignature(progress: AgentProgress): unknown {
kind: progress.retryState.kind,
provider: progress.retryState.provider ?? null,
delayMs: progress.retryState.delayMs,
errorMessage: progress.retryState.errorMessage,
// startedAtMs intentionally excluded (drives countdown only).
}
: null,
retryFailure: progress.retryFailure ?? null,
inflightTaskDetails: progress.inflightTaskDetails
? canonicalizeTaskDetailsForSignature(progress.inflightTaskDetails)
: null,
};
}

/**
* Nested `task` data (`extractedToolData.task` and `inflightTaskDetails`) is the
* one place the await signature reaches into a live, ticking structure: nested
* `AgentProgress` carries the same time-derived fields excluded above, and
* `TaskToolDetails` adds `totalDurationMs` / per-result `durationMs`. Signing it
* wholesale would defeat idle gating whenever an awaited subagent is itself inside
* a live `task` call, so these helpers canonicalize the rendered, non-time subset
* recursively (mutually recursive with `canonicalizeProgressForSignature`).
*/
function canonicalizeExtractedToolDataForSignature(data: Record<string, unknown[]>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(data)) {
// Only the `task` key holds time-ticking `TaskToolDetails`; other handler
// data (yield/report_finding/generic) is stable and passes through as-is.
out[key] = key === "task" ? (data[key] as TaskToolDetails[]).map(canonicalizeTaskDetailsForSignature) : data[key];
}
return out;
}

function canonicalizeTaskDetailsForSignature(details: TaskToolDetails): unknown {
// `extractedToolData` is an untyped boundary (`Record<string, unknown[]>`), so
// guard each field instead of trusting the `TaskToolDetails` cast.
return {
// totalDurationMs intentionally excluded (time-derived).
results: Array.isArray(details.results) ? details.results.map(canonicalizeTaskResultForSignature) : null,
progress: Array.isArray(details.progress) ? details.progress.map(canonicalizeProgressForSignature) : null,
async: details.async
? { state: details.async.state, jobId: details.async.jobId, type: details.async.type }
: null,
};
}

function canonicalizeTaskResultForSignature(result: TaskToolDetails["results"][number]): unknown {
// Completed results do not tick, but drop `durationMs` so the only time-derived
// field in the receipt can never reintroduce idle churn.
const { durationMs: _durationMs, ...rest } = result;
return rest;
}
Loading
Loading