Adding support for herdr - agents in new tabs

This commit is contained in:
jb
2026-08-26 10:57:45 -04:00
parent c3e8b53c07
commit b3884f6c35
14 changed files with 660 additions and 331 deletions
+54 -11
View File
@@ -24,7 +24,9 @@ import {
closeSurface,
shellEscape,
readScreen,
} from "./tmux.ts";
getMuxBackend,
wrapWithCompletionFile,
} from "./surface.ts";
import {
countSessionEntryLines,
@@ -497,10 +499,15 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string {
* (for example direnv/devenv), so the delay is configurable for users who hit
* dropped commands. Keep the historical default at 500ms.
*/
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
return backend === "herdr" ? 1000 : 500;
}
function getShellReadyDelayMs(): number {
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return defaultShellReadyDelayMs(getMuxBackend());
}
function muxUnavailableResult() {
@@ -508,10 +515,10 @@ function muxUnavailableResult() {
content: [
{
type: "text" as const,
text: `Subagents require tmux. ${muxSetupHint()}`,
text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
},
],
details: { error: "tmux not available" },
details: { error: "terminal multiplexer not available" },
};
}
@@ -618,6 +625,7 @@ interface RunningSubagent {
abortController?: AbortController;
cli?: string;
sentinelFile?: string;
completionFile?: string;
statusState: SubagentStatusState;
/**
* When true, status transitions (stalled/recovered) do not wake the parent
@@ -1008,7 +1016,7 @@ function steerSubagent(
} catch (error: any) {
return {
error:
`Failed to deliver message to subagent "${running.name}" via tmux: ` +
`Failed to deliver message to subagent "${running.name}" via the terminal surface: ` +
`${error?.message ?? String(error)}`,
};
}
@@ -1121,6 +1129,7 @@ function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolea
export const __test__ = {
borderLine,
defaultShellReadyDelayMs,
getShellReadyDelayMs,
renderSubagentWidgetLines,
loadAgentDefaults,
@@ -1141,6 +1150,7 @@ export const __test__ = {
handleSubagentSteer,
resolveResultPresentation,
resolveResumeLaunchBehavior,
wrapWithCompletionFile,
runningSubagents,
formatElapsed,
formatTokens,
@@ -1160,7 +1170,7 @@ function startWidgetRefresh() {
}
/**
* Launch a subagent: creates the multiplexer pane, builds the command, and
* Launch a subagent: creates a terminal surface, builds the command, and
* sends it. Returns a RunningSubagent — does NOT poll.
*
* Call watchSubagent() on the returned object to observe completion.
@@ -1184,6 +1194,8 @@ async function launchSubagent(
if (!sessionFile) throw new Error("No session file");
const sessionId = ctx.sessionManager.getSessionId();
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
mkdirSync(dirname(completionFile), { recursive: true });
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
@@ -1201,14 +1213,15 @@ async function launchSubagent(
].join("-");
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
// Use pre-created surface (parallel mode) or create a new one.
// For new surfaces, pause briefly so the shell is ready before sending the command.
// Use a pre-created surface or create a new one. New shells need a brief
// startup window before terminal input is reliable.
const surfacePreCreated = !!options?.surface;
const surface = options?.surface ?? createSurface(params.name);
if (!surfacePreCreated) {
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
}
try {
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
if (launchBehavior.seededSessionMode) {
@@ -1271,7 +1284,7 @@ async function launchSubagent(
cmdParts.push(shellEscape(params.task));
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
const command = wrapWithCompletionFile(`${cdPrefix}${cmdParts.join(" ")}`, completionFile);
const launchScriptName = `${(params.name || "subagent")
.toLowerCase()
@@ -1301,6 +1314,7 @@ async function launchSubagent(
launchScriptFile,
cli: "claude",
sentinelFile,
completionFile,
interactive: effectiveInteractive,
statusState: createStatusState({
source: "claude",
@@ -1414,7 +1428,7 @@ async function launchSubagent(
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
const piCommand = cdPrefix + envPrefix + parts.join(" ");
const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
const command = wrapWithCompletionFile(piCommand, completionFile);
const launchScriptName = `${(params.name || "subagent")
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
@@ -1442,6 +1456,7 @@ async function launchSubagent(
sessionFile: subagentSessionFile,
launchScriptFile,
activityFile,
completionFile,
interactive: effectiveInteractive,
statusState: createStatusState({
source: "pi",
@@ -1451,6 +1466,17 @@ async function launchSubagent(
runningSubagents.set(id, running);
return running;
} catch (error) {
runningSubagents.delete(id);
if (!surfacePreCreated) {
try {
closeSurface(surface);
} catch {
// Preserve the launch error; cleanup is best-effort.
}
}
throw error;
}
}
/**
@@ -1532,6 +1558,7 @@ async function watchSubagent(
interval: 1000,
sessionFile,
sentinelFile: running.sentinelFile,
completionFile: running.completionFile,
onTick() {
observeRunningSubagent(running);
deliverPendingQuestion(running);
@@ -2152,6 +2179,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const surface = createSurface(name);
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
try {
// Build pi resume command
const parts = ["pi", "--session", shellEscape(sessionPath)];
@@ -2162,7 +2190,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const sessionId = ctx.sessionManager.getSessionId();
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
const activityFile = getSubagentActivityFile(artifactDir, id);
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
mkdirSync(dirname(activityFile), { recursive: true });
mkdirSync(dirname(completionFile), { recursive: true });
// Replay the model, identity, and default-deny tool/extension sandbox.
applySandboxToParts(parts, loadout, { artifactDir, name });
@@ -2212,7 +2242,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// operate where they did before.
const resumeCdPrefix = loadout.cwd ? `cd ${shellEscape(loadout.cwd)} && ` : "";
const command = `${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
const command = wrapWithCompletionFile(
`${resumeCdPrefix}${resumeEnvPrefix}${parts.join(" ")}`,
completionFile,
);
const launchScriptFile = join(
artifactDir,
"subagent-scripts",
@@ -2244,6 +2277,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
sessionFile: sessionPath,
launchScriptFile,
activityFile,
completionFile,
interactive,
statusState: createStatusState({
source: "pi",
@@ -2316,6 +2350,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
status: "started",
},
};
} catch (error) {
runningSubagents.delete(id);
try {
closeSurface(surface);
} catch {
// Preserve the resume error; cleanup is best-effort.
}
throw error;
}
},
});