omp-multi-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/package.json +76 -0
- package/scripts/cli.ts +164 -0
- package/scripts/setup/claude.ts +41 -0
- package/scripts/setup/codex.ts +35 -0
- package/scripts/setup/omp.ts +167 -0
- package/scripts/setup/toolchain.ts +81 -0
- package/scripts/setup/types.ts +76 -0
- package/scripts/setup.ts +116 -0
- package/src/agents/availability.ts +106 -0
- package/src/agents/claude-events.ts +125 -0
- package/src/agents/claude.ts +226 -0
- package/src/agents/codex-events.ts +149 -0
- package/src/agents/codex.ts +236 -0
- package/src/agents/types.ts +81 -0
- package/src/commands/agents.ts +140 -0
- package/src/commands/delegate-command.ts +159 -0
- package/src/commands/harness-setup.ts +94 -0
- package/src/commands/sessions.ts +394 -0
- package/src/config/load.ts +78 -0
- package/src/config/schema.ts +249 -0
- package/src/index.ts +129 -0
- package/src/process/executable.ts +49 -0
- package/src/process/jsonl.ts +124 -0
- package/src/process/process-error.ts +178 -0
- package/src/process/redact.ts +120 -0
- package/src/process/spawn-agent.ts +218 -0
- package/src/routing/handoff.ts +59 -0
- package/src/routing/prompt.ts +72 -0
- package/src/routing/route.ts +286 -0
- package/src/runs/lock.ts +158 -0
- package/src/runs/registry.ts +379 -0
- package/src/runs/ring-buffer.ts +81 -0
- package/src/runs/types.ts +141 -0
- package/src/sessions/resume.ts +163 -0
- package/src/sessions/store.ts +273 -0
- package/src/tools/agent-runs.ts +169 -0
- package/src/tools/ask-agent.ts +230 -0
- package/src/tools/delegate.ts +196 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_codex` / `ask_claude`. Both tools are the same surface over a different adapter, so
|
|
3
|
+
* they are built from one factory (_spec/06-tools.md).
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
6
|
+
import { MODE_DEFAULT_READ_ONLY, type AgentMode, type AgentName } from "../agents/types.ts";
|
|
7
|
+
import type { MultiHarnessConfig } from "../config/schema.ts";
|
|
8
|
+
import type { RunRegistry, RunView } from "../runs/types.ts";
|
|
9
|
+
import { AgentError, agentDisabled } from "../process/process-error.ts";
|
|
10
|
+
import { buildHandoff, summarize, truncateMiddle } from "../routing/handoff.ts";
|
|
11
|
+
import { resolveWorkerModel } from "../routing/route.ts";
|
|
12
|
+
|
|
13
|
+
const MODES = ["analyze", "plan", "implement", "debug", "review", "test"] as const;
|
|
14
|
+
|
|
15
|
+
const DESCRIPTION: Record<AgentName, string> = {
|
|
16
|
+
codex:
|
|
17
|
+
"Delegate a coding task to the Codex CLI, which runs in this repository with its own tools and its own session. " +
|
|
18
|
+
"Best for implementation, debugging, refactoring, tests, and targeted code review. " +
|
|
19
|
+
"State the task as a self-contained instruction: the worker cannot see this conversation. " +
|
|
20
|
+
"Prefer a read-only review before giving any agent write access to the same files.",
|
|
21
|
+
claude:
|
|
22
|
+
"Delegate a task to the Claude Code CLI, which runs in this repository with its own tools and its own session. " +
|
|
23
|
+
"Best for architecture analysis, planning, design review, broad repository reasoning, and second opinions. " +
|
|
24
|
+
"State the task as a self-contained instruction: the worker cannot see this conversation. " +
|
|
25
|
+
"Prefer a read-only review before giving any agent write access to the same files.",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** What the schema below accepts. OMP's Static<> inference does not survive the
|
|
29
|
+
* `pi.zod` builder handed to an extension, so `execute` casts to this once. */
|
|
30
|
+
interface AskAgentParams {
|
|
31
|
+
task: string;
|
|
32
|
+
mode?: AgentMode;
|
|
33
|
+
context?: string;
|
|
34
|
+
readOnly?: boolean;
|
|
35
|
+
continueSession?: boolean;
|
|
36
|
+
model?: string;
|
|
37
|
+
background?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AskAgentDeps {
|
|
41
|
+
pi: ExtensionAPI;
|
|
42
|
+
agent: AgentName;
|
|
43
|
+
getConfig: () => MultiHarnessConfig;
|
|
44
|
+
/** Taken as a getter so this module never imports the registry implementation. */
|
|
45
|
+
getRegistry: () => RunRegistry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerAskAgentTool({ pi, agent, getConfig, getRegistry }: AskAgentDeps): void {
|
|
49
|
+
const z = pi.zod;
|
|
50
|
+
|
|
51
|
+
pi.registerTool({
|
|
52
|
+
name: `ask_${agent}`,
|
|
53
|
+
label: agent === "codex" ? "Ask Codex" : "Ask Claude",
|
|
54
|
+
description: DESCRIPTION[agent],
|
|
55
|
+
// NOTE: OMP's ToolDefinition has no promptSnippet/promptGuidelines (those are upstream
|
|
56
|
+
// pi's API). Routing guidance is carried by the description instead — see T-503.
|
|
57
|
+
approval: "exec",
|
|
58
|
+
parameters: z.object({
|
|
59
|
+
task: z.string().describe("A self-contained instruction. The worker cannot see this conversation."),
|
|
60
|
+
mode: z.enum(MODES).optional().describe("Shapes the instruction and the default read-only setting."),
|
|
61
|
+
context: z.string().optional().describe("Compact handoff context. Never paste the whole conversation."),
|
|
62
|
+
readOnly: z.boolean().optional().describe("Force read-only. Defaults from mode."),
|
|
63
|
+
continueSession: z.boolean().optional().describe("Reuse this session's worker session. Default true."),
|
|
64
|
+
model: z.string().optional().describe("Worker model override. Omit to use the CLI's own configuration."),
|
|
65
|
+
background: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe("Return a run id immediately instead of waiting. Check it with agent_runs or /sessions."),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx: ExtensionContext) {
|
|
71
|
+
const params = rawParams as AskAgentParams;
|
|
72
|
+
const config = getConfig();
|
|
73
|
+
const agentConfig = config[agent];
|
|
74
|
+
|
|
75
|
+
if (!config.enabled || !agentConfig.enabled) {
|
|
76
|
+
const error = agentDisabled(agent);
|
|
77
|
+
return { content: [{ type: "text" as const, text: error.message }], isError: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const mode = params.mode;
|
|
81
|
+
const readOnly = params.readOnly ?? (mode ? MODE_DEFAULT_READ_ONLY[mode] : false);
|
|
82
|
+
|
|
83
|
+
const task = buildHandoff({
|
|
84
|
+
task: params.task,
|
|
85
|
+
mode,
|
|
86
|
+
context: params.context,
|
|
87
|
+
maxChars: config.limits.maxHandoffChars,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Both paths go through the registry, not straight to the adapter: that is what gives
|
|
91
|
+
// every call the workspace write lock, the concurrency cap, and a row in `/sessions`.
|
|
92
|
+
const registry = getRegistry();
|
|
93
|
+
const started = registry.start({
|
|
94
|
+
agent,
|
|
95
|
+
task,
|
|
96
|
+
summary: summarize(params.task),
|
|
97
|
+
cwd: ctx.cwd,
|
|
98
|
+
mode,
|
|
99
|
+
readOnly,
|
|
100
|
+
model: params.model,
|
|
101
|
+
continueSession: params.continueSession,
|
|
102
|
+
background: params.background === true,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// T-507 precedence lives in one place; this tool must not re-derive it.
|
|
106
|
+
const resolvedModel = resolveWorkerModel(params.model, agentConfig.model).model ?? null;
|
|
107
|
+
|
|
108
|
+
if (params.background === true) {
|
|
109
|
+
// Detached by design. `.catch` is mandatory — an unhandled rejection here would
|
|
110
|
+
// escape into the OMP session (_spec/01 §2).
|
|
111
|
+
registry
|
|
112
|
+
.wait(started.id)
|
|
113
|
+
.then((finished) => {
|
|
114
|
+
if (finished) deliverBackgroundResult({ pi, ctx, run: finished, config });
|
|
115
|
+
})
|
|
116
|
+
.catch((e) => pi.logger.warn?.(`[multi-harness] background run ${started.id}: ${(e as Error).message}`));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text" as const,
|
|
122
|
+
text:
|
|
123
|
+
`Started ${agent} run \`${started.id}\` in the background (${started.status}).\n` +
|
|
124
|
+
`Check it with \`agent_runs\` (action: "status" or "wait") or \`/sessions\`.`,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
details: { agent, runId: started.id, status: started.status, background: true, model: resolvedModel },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const abort = signal ?? new AbortController().signal;
|
|
133
|
+
const onAbort = () => void registry.cancel(started.id);
|
|
134
|
+
abort.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
|
|
136
|
+
const unsubscribe = registry.subscribe((run) => {
|
|
137
|
+
if (run.id === started.id) ctx.ui.setStatus("multi-harness", `${agent}: ${run.phase}`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const finished = await registry.wait(started.id);
|
|
142
|
+
if (!finished) {
|
|
143
|
+
return { content: [{ type: "text" as const, text: `Run ${started.id} disappeared from the registry.` }], isError: true };
|
|
144
|
+
}
|
|
145
|
+
if (finished.status !== "done") {
|
|
146
|
+
const detail = finished.errorMessage ?? `run ${finished.status}`;
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: "text" as const, text: `${finished.errorCode ?? finished.status.toUpperCase()}: ${detail}` }],
|
|
149
|
+
isError: true,
|
|
150
|
+
details: { agent, runId: finished.id, status: finished.status },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { text, truncated } = truncateMiddle(finished.output ?? "", config.limits.maxOutputChars);
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text" as const, text }],
|
|
157
|
+
details: {
|
|
158
|
+
agent,
|
|
159
|
+
runId: finished.id,
|
|
160
|
+
sessionId: finished.workerSessionId,
|
|
161
|
+
durationMs: finished.elapsedMs,
|
|
162
|
+
// The adapter's claim, derived from the argv it actually built — not the
|
|
163
|
+
// caller's request. Absent metadata means the adapter could not prove it.
|
|
164
|
+
readOnlyRequested: readOnly,
|
|
165
|
+
readOnlyEnforced: finished.metadata?.readOnlyEnforced === true,
|
|
166
|
+
readOnlyMechanism: finished.metadata?.readOnlyMechanism ?? null,
|
|
167
|
+
truncated,
|
|
168
|
+
model: resolvedModel,
|
|
169
|
+
routedBy: "explicit" as const,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
} finally {
|
|
173
|
+
unsubscribe();
|
|
174
|
+
abort.removeEventListener("abort", onAbort);
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {
|
|
177
|
+
const message =
|
|
178
|
+
e instanceof AgentError
|
|
179
|
+
? `${e.code}: ${e.message}`
|
|
180
|
+
: `Unexpected failure delegating to ${agent}: ${(e as Error).message}`;
|
|
181
|
+
return { content: [{ type: "text" as const, text: message }], isError: true };
|
|
182
|
+
} finally {
|
|
183
|
+
ctx.ui.setStatus("multi-harness", undefined);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface BackgroundDeliveryDeps {
|
|
190
|
+
pi: ExtensionAPI;
|
|
191
|
+
ctx: ExtensionContext;
|
|
192
|
+
run: RunView;
|
|
193
|
+
config: MultiHarnessConfig;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Called when a `background: true` run reaches a terminal status. The supervisor is very
|
|
198
|
+
* likely mid-turn on something else by now, so how loudly this lands is a policy choice —
|
|
199
|
+
* see the TODO below.
|
|
200
|
+
*/
|
|
201
|
+
export function deliverBackgroundResult({ pi, ctx, run, config }: BackgroundDeliveryDeps): void {
|
|
202
|
+
// Always durable first: spec 08 requires a finished background run to survive a reload,
|
|
203
|
+
// and `appendEntry` is state-only (never sent to the LLM), so it is safe unconditionally.
|
|
204
|
+
pi.appendEntry("multi-harness-run", {
|
|
205
|
+
runId: run.id,
|
|
206
|
+
agent: run.agent,
|
|
207
|
+
status: run.status,
|
|
208
|
+
workerSessionId: run.workerSessionId,
|
|
209
|
+
summary: run.summary,
|
|
210
|
+
elapsedMs: run.elapsedMs,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Deliberately a notification, not a `pi.sendUserMessage` injection. A background run
|
|
214
|
+
// finishes at an arbitrary moment — very likely while the supervisor is mid-turn on
|
|
215
|
+
// something unrelated — and injecting the worker's output there would derail that turn.
|
|
216
|
+
// The caller was handed a run id and told to poll, so `agent_runs` and `/sessions` are
|
|
217
|
+
// the retrieval path; this only has to make sure a finished run is never *missed*.
|
|
218
|
+
const label = `${run.agent} run ${run.id}`;
|
|
219
|
+
if (run.status === "done") {
|
|
220
|
+
const { text } = truncateMiddle(run.output ?? "", Math.min(240, config.limits.maxOutputChars));
|
|
221
|
+
const preview = text.replace(/\s+/g, " ").trim();
|
|
222
|
+
ctx.ui.notify(`${label} finished. ${preview || "(no output)"}`, "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A silent failure costs more than a silent success: the caller may still be waiting on
|
|
227
|
+
// work that will never arrive, so failures and cancellations always say why.
|
|
228
|
+
const reason = run.errorMessage ?? run.status;
|
|
229
|
+
ctx.ui.notify(`${label} ${run.status}: ${run.errorCode ? `${run.errorCode} — ` : ""}${reason}`, "error");
|
|
230
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `delegate` (T-501) — one entry point that picks the agent for you.
|
|
3
|
+
*
|
|
4
|
+
* Same surface as `ask_codex` / `ask_claude` plus `agent: "auto" | "codex" | "claude"` and
|
|
5
|
+
* `background`. Routing is done by `routing/route.ts` and always reported back in `details`
|
|
6
|
+
* (`routedBy` + the reason), so no delegation is a black box (_spec/06-tools.md).
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
9
|
+
import { detect } from "../agents/availability.ts";
|
|
10
|
+
import { MODE_DEFAULT_READ_ONLY, type AgentAvailability, type AgentMode, type AgentName } from "../agents/types.ts";
|
|
11
|
+
import type { MultiHarnessConfig } from "../config/schema.ts";
|
|
12
|
+
import { buildHandoff, summarize } from "../routing/handoff.ts";
|
|
13
|
+
import { createModelRouter, resolveWorkerModel, route, usabilityFrom, type RouterModelFn } from "../routing/route.ts";
|
|
14
|
+
import type { RunRegistry } from "../runs/types.ts";
|
|
15
|
+
import { renderRunView } from "./agent-runs.ts";
|
|
16
|
+
|
|
17
|
+
const AGENTS = ["auto", "codex", "claude"] as const;
|
|
18
|
+
const MODES = ["analyze", "plan", "implement", "debug", "review", "test"] as const;
|
|
19
|
+
|
|
20
|
+
const DESCRIPTION =
|
|
21
|
+
"Delegate one self-contained task to an external coding CLI running in this repository. " +
|
|
22
|
+
"agent: \"auto\" lets the harness route it — Codex for implementation, debugging, refactoring and tests, " +
|
|
23
|
+
"Claude for architecture analysis, planning, review and second opinions — and the result says how it routed. " +
|
|
24
|
+
"Use ask_codex / ask_claude when you already know which one you want. " +
|
|
25
|
+
"With background: true it returns a run id immediately; join it later with agent_runs.";
|
|
26
|
+
|
|
27
|
+
/** What the schema below accepts — see the cast note in `ask-agent.ts`. */
|
|
28
|
+
interface DelegateParams {
|
|
29
|
+
agent?: "auto" | AgentName;
|
|
30
|
+
task: string;
|
|
31
|
+
mode?: AgentMode;
|
|
32
|
+
context?: string;
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
continueSession?: boolean;
|
|
35
|
+
background?: boolean;
|
|
36
|
+
model?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DelegateDeps {
|
|
40
|
+
pi: ExtensionAPI;
|
|
41
|
+
getConfig: () => MultiHarnessConfig;
|
|
42
|
+
/** Taken as a getter so this module never imports the registry implementation. */
|
|
43
|
+
getRegistry: () => RunRegistry;
|
|
44
|
+
/** Injected so tests never spawn a process. Defaults to the cached detector. */
|
|
45
|
+
getAvailability?: (agent: AgentName, config: MultiHarnessConfig, cwd: string) => Promise<AgentAvailability | undefined>;
|
|
46
|
+
/** Injected so tests never make a network call. Returning undefined forces the rules path. */
|
|
47
|
+
createRouter?: (ctx: ExtensionContext, config: MultiHarnessConfig) => RouterModelFn | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const defaultAvailability = async (agent: AgentName, config: MultiHarnessConfig, cwd: string) =>
|
|
51
|
+
// Auth is not probed here: it costs a process spawn per call, and an unauthenticated
|
|
52
|
+
// worker fails loudly on its own with an actionable error.
|
|
53
|
+
detect(agent, config[agent], { cwd, skipAuth: true });
|
|
54
|
+
|
|
55
|
+
const defaultRouter = (ctx: ExtensionContext, config: MultiHarnessConfig): RouterModelFn | undefined =>
|
|
56
|
+
config.routing.mode === "model"
|
|
57
|
+
? createModelRouter(ctx, [config.routing.model, ...config.routing.modelFallbacks])
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
export function registerDelegateTool({ pi, getConfig, getRegistry, getAvailability, createRouter }: DelegateDeps): void {
|
|
61
|
+
const z = pi.zod;
|
|
62
|
+
const availabilityOf = getAvailability ?? defaultAvailability;
|
|
63
|
+
const routerFor = createRouter ?? defaultRouter;
|
|
64
|
+
|
|
65
|
+
pi.registerTool({
|
|
66
|
+
name: "delegate",
|
|
67
|
+
label: "Delegate",
|
|
68
|
+
description: DESCRIPTION,
|
|
69
|
+
// NOTE: OMP's ToolDefinition has no promptSnippet/promptGuidelines (upstream pi only).
|
|
70
|
+
// Broader guidance lives in routing/prompt.ts — see T-503.
|
|
71
|
+
approval: "exec",
|
|
72
|
+
parameters: z.object({
|
|
73
|
+
agent: z.enum(AGENTS).optional().describe("Which agent to use. Default \"auto\" — the harness routes it."),
|
|
74
|
+
task: z.string().describe("A self-contained instruction. The worker cannot see this conversation."),
|
|
75
|
+
mode: z.enum(MODES).optional().describe("Shapes the instruction, the default read-only setting, and routing."),
|
|
76
|
+
context: z.string().optional().describe("Compact handoff context. Never paste the whole conversation."),
|
|
77
|
+
readOnly: z.boolean().optional().describe("Force read-only. Defaults from mode."),
|
|
78
|
+
continueSession: z.boolean().optional().describe("Reuse this session's worker session. Default true."),
|
|
79
|
+
background: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Return a run id immediately instead of waiting. Join it with agent_runs."),
|
|
83
|
+
model: z.string().optional().describe("Worker model override. Omit to use the CLI's own configuration."),
|
|
84
|
+
}),
|
|
85
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx: ExtensionContext) {
|
|
86
|
+
const params = rawParams as DelegateParams;
|
|
87
|
+
const config = getConfig();
|
|
88
|
+
|
|
89
|
+
if (!config.enabled) {
|
|
90
|
+
return { content: [{ type: "text" as const, text: "multi-harness is disabled (multiHarness.enabled: false)." }], isError: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const detected: Partial<Record<AgentName, AgentAvailability>> = {};
|
|
94
|
+
for (const agent of ["codex", "claude"] as const) {
|
|
95
|
+
if (!config[agent].enabled) continue;
|
|
96
|
+
try {
|
|
97
|
+
detected[agent] = await availabilityOf(agent, config, ctx.cwd);
|
|
98
|
+
} catch {
|
|
99
|
+
// Detection is advisory; a failure here must not stop a delegation.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const decision = await route(
|
|
104
|
+
{ agent: params.agent, task: params.task, mode: params.mode },
|
|
105
|
+
{
|
|
106
|
+
config,
|
|
107
|
+
usability: usabilityFrom(config, detected),
|
|
108
|
+
askModel: routerFor(ctx, config),
|
|
109
|
+
signal: signal ?? undefined,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (!decision.ok) {
|
|
114
|
+
return { content: [{ type: "text" as const, text: decision.reason }], isError: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const agent = decision.agent;
|
|
118
|
+
const mode = params.mode;
|
|
119
|
+
const readOnly = params.readOnly ?? (mode ? MODE_DEFAULT_READ_ONLY[mode] : false);
|
|
120
|
+
const worker = resolveWorkerModel(params.model, config[agent].model);
|
|
121
|
+
if (worker.warning) pi.logger.warn?.(`[multi-harness] ${worker.warning}`);
|
|
122
|
+
|
|
123
|
+
const task = buildHandoff({
|
|
124
|
+
task: params.task,
|
|
125
|
+
mode,
|
|
126
|
+
context: params.context,
|
|
127
|
+
maxChars: config.limits.maxHandoffChars,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const routing = { routedBy: decision.routedBy, routingReason: decision.reason };
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const started = getRegistry().start({
|
|
134
|
+
agent,
|
|
135
|
+
task,
|
|
136
|
+
summary: summarize(params.task),
|
|
137
|
+
cwd: ctx.cwd,
|
|
138
|
+
mode,
|
|
139
|
+
readOnly,
|
|
140
|
+
model: worker.model,
|
|
141
|
+
continueSession: params.continueSession,
|
|
142
|
+
background: params.background === true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (params.background === true) {
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "text" as const,
|
|
150
|
+
text:
|
|
151
|
+
`[${agent} · running · ${started.id}] routed by ${decision.routedBy}: ${decision.reason}\n` +
|
|
152
|
+
`Started in the background. Join it with agent_runs (action "wait", runId "${started.id}").`,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
details: { runId: started.id, status: "running" as const, agent, model: worker.model ?? null, ...routing },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Foreground: a caller-side abort must stop the child, not just stop waiting.
|
|
160
|
+
const onAbort = () => void getRegistry().cancel(started.id);
|
|
161
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
162
|
+
try {
|
|
163
|
+
const view = (await getRegistry().wait(started.id)) ?? getRegistry().get(started.id);
|
|
164
|
+
if (!view) {
|
|
165
|
+
return { content: [{ type: "text" as const, text: `Run ${started.id} disappeared before it produced a result.` }], isError: true };
|
|
166
|
+
}
|
|
167
|
+
const rendered = renderRunView(view, config.limits.maxOutputChars);
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text" as const, text: `${rendered.text}\n\nrouted by ${decision.routedBy}: ${decision.reason}` }],
|
|
170
|
+
details: {
|
|
171
|
+
runId: view.id,
|
|
172
|
+
agent,
|
|
173
|
+
sessionId: view.workerSessionId,
|
|
174
|
+
status: view.status,
|
|
175
|
+
durationMs: view.elapsedMs,
|
|
176
|
+
readOnlyEnforced: readOnly,
|
|
177
|
+
truncated: rendered.truncated,
|
|
178
|
+
model: worker.model ?? null,
|
|
179
|
+
...routing,
|
|
180
|
+
},
|
|
181
|
+
isError: view.status === "failed",
|
|
182
|
+
};
|
|
183
|
+
} finally {
|
|
184
|
+
signal?.removeEventListener("abort", onAbort);
|
|
185
|
+
ctx.ui.setStatus("multi-harness", undefined);
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text" as const, text: `Could not delegate to ${agent}: ${(e as Error).message}` }],
|
|
190
|
+
details: { agent, ...routing },
|
|
191
|
+
isError: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|