@bermudi/pi-delegate 0.1.14 → 0.1.16
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/agents.ts +1 -1
- package/constants.ts +10 -0
- package/delegate.ts +1 -0
- package/dispatch.ts +22 -7
- package/extension.ts +50 -1
- package/format.ts +28 -1
- package/lifecycle.ts +4 -0
- package/manual.ts +5 -3
- package/package.json +1 -1
- package/render-branches.ts +18 -6
- package/schema.ts +149 -82
- package/task-resolution.ts +55 -21
- package/ticket-format.ts +11 -6
- package/tickets.ts +3 -1
- package/types.ts +37 -4
- package/workspace.ts +93 -10
package/agents.ts
CHANGED
|
@@ -120,7 +120,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
|
|
|
120
120
|
[DEFAULT_AGENT_NAME]: {
|
|
121
121
|
name: DEFAULT_AGENT_NAME,
|
|
122
122
|
description:
|
|
123
|
-
"
|
|
123
|
+
"General-purpose subagent mirroring the live parent model, thinking level, native tools, and base prompt. Prefer it for general work; pick a specialist only when its role, tool limits, or scratch workspace genuinely fit.",
|
|
124
124
|
tools: DEFAULT_TOOLS,
|
|
125
125
|
systemPrompt: "",
|
|
126
126
|
builtin: true,
|
package/constants.ts
CHANGED
|
@@ -59,3 +59,13 @@ export const VALID_THINKING_LEVELS = [
|
|
|
59
59
|
* `Set<VALID_THINKING_LEVELS[number]>`) so the `.has(string)` call sites in
|
|
60
60
|
* task-resolution.ts and agents.ts stay ergonomic under strict typing. */
|
|
61
61
|
export const VALID_THINKING: Set<string> = new Set(VALID_THINKING_LEVELS);
|
|
62
|
+
|
|
63
|
+
/** Session-control actions: RPC against the session pool, not work. Callers
|
|
64
|
+
* spell them at the TOP level (`sessionAction`, one action per call — #32).
|
|
65
|
+
* This predicate remains the one shared spelling of the control set for the
|
|
66
|
+
* session-mode intent check and validator in schema.ts and the pipeline
|
|
67
|
+
* guards over internally bridged tasks (task-resolution.ts). Per-action
|
|
68
|
+
* behavior lives at the branch sites (e.g. lifecycle applySessionAction). */
|
|
69
|
+
export function isSessionControlAction(action: unknown): boolean {
|
|
70
|
+
return action === "close" || action === "list";
|
|
71
|
+
}
|
package/delegate.ts
CHANGED
package/dispatch.ts
CHANGED
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
DelegateToolResult,
|
|
49
49
|
ParentAgentDefaults,
|
|
50
50
|
ResolvedTask,
|
|
51
|
+
DispatchableTask,
|
|
51
52
|
TaskDef,
|
|
52
53
|
TaskProgress,
|
|
53
54
|
TaskResult,
|
|
@@ -58,7 +59,7 @@ const UNSAFE_SHARED_WRITES_WARNING =
|
|
|
58
59
|
"UNSAFE SHARED WRITES ENABLED: shared-write admission is bypassed. Delegate provides no isolation or rollback.";
|
|
59
60
|
|
|
60
61
|
interface ActiveSyncDispatch {
|
|
61
|
-
tasks:
|
|
62
|
+
tasks: DispatchableTask[];
|
|
62
63
|
resolved: ResolvedTask[];
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -112,6 +113,7 @@ export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
|
|
|
112
113
|
id: t.id,
|
|
113
114
|
index: i,
|
|
114
115
|
agent: t.agentName,
|
|
116
|
+
resumedFrom: t.resumeFromDisplay,
|
|
115
117
|
task: trunc(sanitizeTerminalLine(t.prompt || t.sessionAction || ""), 50),
|
|
116
118
|
status: "pending" as const,
|
|
117
119
|
durationMs: 0,
|
|
@@ -156,7 +158,7 @@ export function makeFireUpdater(
|
|
|
156
158
|
export interface AsyncDispatchInput {
|
|
157
159
|
pi: ExtensionAPI;
|
|
158
160
|
ctx: DelegateToolCtx;
|
|
159
|
-
tasks:
|
|
161
|
+
tasks: DispatchableTask[];
|
|
160
162
|
resolved: ResolvedTask[];
|
|
161
163
|
progress: TaskProgress[];
|
|
162
164
|
parentModelId: string | undefined;
|
|
@@ -168,7 +170,7 @@ export interface AsyncDispatchInput {
|
|
|
168
170
|
/** Inputs needed by the sync (blocking) dispatch path. */
|
|
169
171
|
export interface SyncDispatchInput {
|
|
170
172
|
ctx: DelegateToolCtx;
|
|
171
|
-
tasks:
|
|
173
|
+
tasks: DispatchableTask[];
|
|
172
174
|
resolved: ResolvedTask[];
|
|
173
175
|
progress: TaskProgress[];
|
|
174
176
|
parentModelId: string | undefined;
|
|
@@ -192,7 +194,7 @@ export interface DelegateDispatchInput {
|
|
|
192
194
|
callSpan?: CallSpan;
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
function taskReference(task:
|
|
197
|
+
function taskReference(task: DispatchableTask, index: number): string {
|
|
196
198
|
return `Task ${index + 1}${task.id ? `#${task.id}` : ""}`;
|
|
197
199
|
}
|
|
198
200
|
|
|
@@ -206,7 +208,7 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
|
|
|
206
208
|
}
|
|
207
209
|
|
|
208
210
|
function sharedWriteRejection(
|
|
209
|
-
tasks:
|
|
211
|
+
tasks: DispatchableTask[],
|
|
210
212
|
parentModelId: string | undefined,
|
|
211
213
|
conflicts: SharedWriteConflict[],
|
|
212
214
|
references: readonly string[] = tasks.map(taskReference),
|
|
@@ -235,7 +237,7 @@ function sharedWriteRejection(
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
function sharedWriteSafetyFailure(
|
|
238
|
-
tasks:
|
|
240
|
+
tasks: DispatchableTask[],
|
|
239
241
|
parentModelId: string | undefined,
|
|
240
242
|
error: unknown,
|
|
241
243
|
): DelegateToolResult {
|
|
@@ -288,13 +290,26 @@ export async function dispatchDelegate(
|
|
|
288
290
|
return validationError;
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
const
|
|
293
|
+
const resolveResult = resolveTasks(
|
|
292
294
|
tasks,
|
|
293
295
|
ctx,
|
|
294
296
|
agents,
|
|
295
297
|
parentDefaults,
|
|
296
298
|
dispatchConfig,
|
|
297
299
|
);
|
|
300
|
+
if (resolveResult.error !== undefined) {
|
|
301
|
+
callSpan?.finish({
|
|
302
|
+
status: "failed",
|
|
303
|
+
totalTokens: 0,
|
|
304
|
+
totalCost: 0,
|
|
305
|
+
wallMs: Date.now() - callSpan.startedAt,
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text", text: resolveResult.error }],
|
|
309
|
+
details: { tasks, results: [], progress: [], parentModel: parentModelId },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const resolved = resolveResult.tasks;
|
|
298
313
|
if (params.async && resolved.some((task) => task.workspace === "isolated")) {
|
|
299
314
|
callSpan?.finish({
|
|
300
315
|
status: "failed",
|
package/extension.ts
CHANGED
|
@@ -44,7 +44,11 @@ import {
|
|
|
44
44
|
prepareTelemetryForSession,
|
|
45
45
|
sealTelemetryWrites,
|
|
46
46
|
} from "./telemetry.ts";
|
|
47
|
-
import type {
|
|
47
|
+
import type {
|
|
48
|
+
DelegateArguments,
|
|
49
|
+
DelegateDetails,
|
|
50
|
+
DispatchableTask,
|
|
51
|
+
} from "./types.ts";
|
|
48
52
|
|
|
49
53
|
const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
|
|
50
54
|
let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
@@ -52,6 +56,17 @@ let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
|
|
|
52
56
|
type ShutdownDrainResult =
|
|
53
57
|
{ drained: true; failures: unknown[] } | { drained: false; failures: [] };
|
|
54
58
|
|
|
59
|
+
/** Bridge the promoted top-level session RPC (`sessionAction` + `sessionId`)
|
|
60
|
+
* to the internal single-entry batch the runner executes. Internal only — the
|
|
61
|
+
* public task schema has no `sessionAction`; validation guarantees close
|
|
62
|
+
* carries a sessionId by this point. */
|
|
63
|
+
function bridgeSessionControlTask(params: DelegateArguments): DispatchableTask {
|
|
64
|
+
return {
|
|
65
|
+
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
|
66
|
+
sessionAction: params.sessionAction,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/** Wait for shutdown workers without allowing a stuck provider/tool to hold
|
|
56
71
|
* Pi's reload or exit hostage. The allSettled promise is intentionally left
|
|
57
72
|
* attached after timeout so a late rejection cannot become unhandled. */
|
|
@@ -160,6 +175,11 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
160
175
|
let mode: string;
|
|
161
176
|
if (params.ticketAction) {
|
|
162
177
|
mode = params.ticketAction;
|
|
178
|
+
} else if (params.sessionAction) {
|
|
179
|
+
// Session RPC is its own mode, not a task shape — label it before the
|
|
180
|
+
// tasks-based branches so close/list spans stop misreporting as
|
|
181
|
+
// "sync" with a zero task count.
|
|
182
|
+
mode = "session";
|
|
163
183
|
} else if (tasks.length === 0) {
|
|
164
184
|
mode = "manual";
|
|
165
185
|
} else if (params.async) {
|
|
@@ -246,6 +266,35 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
246
266
|
// silently swap the agent roster.
|
|
247
267
|
const agents = discoverAgents(ctx.cwd);
|
|
248
268
|
|
|
269
|
+
// ── Session RPC (top-level sessionAction, #32 promotion) ─────────
|
|
270
|
+
// Session control is no longer spelled as a task, but it still runs
|
|
271
|
+
// through the one runner: bridge it to an internal single-entry batch so
|
|
272
|
+
// busy-index validation, per-session locking (lifecycle's literal
|
|
273
|
+
// per-action branches), progress rows, and result formatting all keep
|
|
274
|
+
// their existing behavior. Never async — validation rejects that.
|
|
275
|
+
// Must precede the help short-circuit: session RPC carries no tasks and
|
|
276
|
+
// must not be mistaken for an empty help request.
|
|
277
|
+
if (params.sessionAction) {
|
|
278
|
+
invalidateHostDepsCache();
|
|
279
|
+
// Keep the footer-status pipeline in step exactly as a normal
|
|
280
|
+
// dispatch would (deduped no-op when nothing is running).
|
|
281
|
+
syncDelegateStatus(ctx);
|
|
282
|
+
return await dispatchDelegate({
|
|
283
|
+
pi,
|
|
284
|
+
params: { ...params, tasks: [bridgeSessionControlTask(params)] },
|
|
285
|
+
ctx,
|
|
286
|
+
agents,
|
|
287
|
+
parentModelId,
|
|
288
|
+
parentDefaults: {
|
|
289
|
+
thinking: pi.getThinkingLevel(),
|
|
290
|
+
tools: pi.getActiveTools(),
|
|
291
|
+
},
|
|
292
|
+
signal,
|
|
293
|
+
onUpdate,
|
|
294
|
+
callSpan,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
249
298
|
// ── Help mode ─────────────────────────────────────────────────
|
|
250
299
|
if (!tasks.length) {
|
|
251
300
|
succeedCall();
|
package/format.ts
CHANGED
|
@@ -200,6 +200,33 @@ export function formatTaskId(id: string | undefined): string {
|
|
|
200
200
|
return safeId ? ` #${safeId}` : "";
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Short identity tag for a resumed transcript, derived from its session file
|
|
205
|
+
* path (pi names sessions `<timestamp>_<uuid>.jsonl`, so the UUID prefix is
|
|
206
|
+
* the stable part). Best-effort display identity only — never parsed back.
|
|
207
|
+
*/
|
|
208
|
+
export function formatResumeTag(resumeFrom: string): string {
|
|
209
|
+
const stem = sanitizeTerminalLine(resumeFrom).replace(/\.jsonl$/i, "");
|
|
210
|
+
const base = stem.split("/").pop() ?? stem;
|
|
211
|
+
const unique = base.includes("_") ? (base.split("_").pop() ?? base) : base;
|
|
212
|
+
return (unique.slice(0, 8) || "resumed").trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Plain-text revival marker for a progress row that continued an earlier
|
|
217
|
+
* transcript. Empty when the row is not a resume, or when the agent name
|
|
218
|
+
* already carries the resume identity (`resume:<tag>`, set at task resolution
|
|
219
|
+
* for omitted-agent resumes) — the marker must not duplicate it.
|
|
220
|
+
*/
|
|
221
|
+
export function resumeMarker(p: {
|
|
222
|
+
agent: string;
|
|
223
|
+
resumedFrom?: string;
|
|
224
|
+
}): string {
|
|
225
|
+
return p.resumedFrom && p.agent !== `resume:${p.resumedFrom}`
|
|
226
|
+
? ` ↻${p.resumedFrom}`
|
|
227
|
+
: "";
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
/**
|
|
204
231
|
* Extract a single-line preview of agent output for collapsed final display.
|
|
205
232
|
*
|
|
@@ -463,7 +490,7 @@ export function formatCompletedTask(
|
|
|
463
490
|
// `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
|
|
464
491
|
// empty. Async prompt tasks always set prompt, so this is a no-op there.
|
|
465
492
|
parts.push(
|
|
466
|
-
`=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
|
|
493
|
+
`=== ${result.agent}${resumeMarker(result)}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
|
|
467
494
|
);
|
|
468
495
|
if (task.warnings?.length) {
|
|
469
496
|
for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
|
package/lifecycle.ts
CHANGED
|
@@ -118,6 +118,7 @@ function failTask(
|
|
|
118
118
|
return {
|
|
119
119
|
id: task.id,
|
|
120
120
|
agent: task.agentName,
|
|
121
|
+
resumedFrom: task.resumeFromDisplay,
|
|
121
122
|
output: "",
|
|
122
123
|
error,
|
|
123
124
|
failureKind,
|
|
@@ -200,6 +201,7 @@ function completeSessionAction(
|
|
|
200
201
|
return {
|
|
201
202
|
id: task.id,
|
|
202
203
|
agent: task.agentName,
|
|
204
|
+
resumedFrom: task.resumeFromDisplay,
|
|
203
205
|
output,
|
|
204
206
|
durationMs: elapsedMs ?? 0,
|
|
205
207
|
tokens: 0,
|
|
@@ -1237,6 +1239,7 @@ function deadlineExceededResult(
|
|
|
1237
1239
|
return {
|
|
1238
1240
|
id: task.id,
|
|
1239
1241
|
agent: task.agentName,
|
|
1242
|
+
resumedFrom: task.resumeFromDisplay,
|
|
1240
1243
|
output: prior?.output ?? "",
|
|
1241
1244
|
error: formatDeadlineExceededError(budgetMs),
|
|
1242
1245
|
failureKind: "deadline_exceeded",
|
|
@@ -1566,6 +1569,7 @@ async function runTaskAttempt(
|
|
|
1566
1569
|
return propagateSessionQuarantine(r, {
|
|
1567
1570
|
id: task.id,
|
|
1568
1571
|
agent: task.agentName,
|
|
1572
|
+
resumedFrom: task.resumeFromDisplay,
|
|
1569
1573
|
output: r.output,
|
|
1570
1574
|
error: r.error,
|
|
1571
1575
|
// Classify the failure: the runner sets `stalled` for the
|
package/manual.ts
CHANGED
|
@@ -138,6 +138,8 @@ export function getSubagentManualMarkdown(
|
|
|
138
138
|
"",
|
|
139
139
|
...builtinLines,
|
|
140
140
|
"",
|
|
141
|
+
"Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only, and `reviewer` runs in a disposable scratch workspace — its writes are discarded by design. Choose a specialist for its role, not its name.",
|
|
142
|
+
"",
|
|
141
143
|
"Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
|
|
142
144
|
"",
|
|
143
145
|
"## Available Custom Agents",
|
|
@@ -177,10 +179,10 @@ export function getSubagentManualMarkdown(
|
|
|
177
179
|
'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
|
|
178
180
|
"",
|
|
179
181
|
"// Clean up when done",
|
|
180
|
-
'delegate({
|
|
182
|
+
'delegate({ sessionAction: "close", sessionId: "auth-research" })',
|
|
181
183
|
"```",
|
|
182
184
|
"",
|
|
183
|
-
'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
|
|
185
|
+
'Pooled sessions remain live until a top-level `sessionAction: "close"` or parent Pi session shutdown.',
|
|
184
186
|
"",
|
|
185
187
|
"## Resuming Previous Sessions",
|
|
186
188
|
"",
|
|
@@ -239,7 +241,7 @@ export function getSubagentManualMarkdown(
|
|
|
239
241
|
'- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
|
|
240
242
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
241
243
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
242
|
-
'-
|
|
244
|
+
'- Prefer `agent: "default"` — the parent\'s live model/thinking/native tools/base prompt — for general tasks. Pick `scout`/`coder`/`reviewer` only when the role, tool limits, or scratch workspace fit; they follow delegate.json tiers and may not run the parent model. Omitting `agent` creates an ad-hoc task (parent model, full tools).',
|
|
243
245
|
"- Omit `thinking` for named agents unless the user asks or the task clearly needs escalation — task-level `thinking` overrides every configured tier (`agentOverrides`, `agentOverridesByParentModel`, Markdown frontmatter, `:level` model suffix), so a casual value silently defeats the configured budget. Ad-hoc tasks default to the parent's thinking.",
|
|
244
246
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
245
247
|
"- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
|
package/package.json
CHANGED
package/render-branches.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
previewOutputLine,
|
|
15
15
|
taskTokenLabel,
|
|
16
16
|
waitingLabel,
|
|
17
|
+
resumeMarker,
|
|
17
18
|
} from "./format.ts";
|
|
18
19
|
import {
|
|
19
20
|
resolveCarriageReturn,
|
|
@@ -128,6 +129,11 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
128
129
|
const task = sanitizeTerminalLine(p.task);
|
|
129
130
|
const taskId = formatTaskId(p.id);
|
|
130
131
|
const taskIdTag = taskId ? theme.fg("accent", taskId) : "";
|
|
132
|
+
// Revival marker: a resumed row must never read as a fresh spawn. Empty
|
|
133
|
+
// when the identity already carries the resume label (omitted-agent
|
|
134
|
+
// resumes resolve to `resume:<tag>` at task resolution).
|
|
135
|
+
const resumeMarkRaw = resumeMarker(p);
|
|
136
|
+
const resumeMark = resumeMarkRaw ? theme.fg("warning", resumeMarkRaw) : "";
|
|
131
137
|
const runParts: string[] = [];
|
|
132
138
|
if (p.toolUses > 0)
|
|
133
139
|
runParts.push(`${p.toolUses} tool${p.toolUses > 1 ? "s" : ""}`);
|
|
@@ -137,7 +143,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
137
143
|
case "done":
|
|
138
144
|
lines.push(
|
|
139
145
|
truncLine(
|
|
140
|
-
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? `${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)])}` : ""}`,
|
|
146
|
+
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(agent)}${resumeMark}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? `${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)])}` : ""}`,
|
|
141
147
|
w,
|
|
142
148
|
),
|
|
143
149
|
);
|
|
@@ -158,7 +164,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
158
164
|
case "failed":
|
|
159
165
|
lines.push(
|
|
160
166
|
truncLine(
|
|
161
|
-
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? modelLabel(p) : ""}${p.error ? theme.fg("error", ` · ${sanitizeTerminalLine(p.error)}`) : ""}`,
|
|
167
|
+
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(agent)}${resumeMark}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? modelLabel(p) : ""}${p.error ? theme.fg("error", ` · ${sanitizeTerminalLine(p.error)}`) : ""}`,
|
|
162
168
|
w,
|
|
163
169
|
),
|
|
164
170
|
);
|
|
@@ -192,7 +198,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
192
198
|
const glyph = theme.fg("warning", spinnerFrame());
|
|
193
199
|
lines.push(
|
|
194
200
|
truncLine(
|
|
195
|
-
`${tree(i, total)} ${glyph} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? `${modelLabel(p)}${statJoin(runParts)}` : ""}${issueTag}${theme.fg("dim", ageTag)}`,
|
|
201
|
+
`${tree(i, total)} ${glyph} ${theme.bold(agent)}${resumeMark}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? `${modelLabel(p)}${statJoin(runParts)}` : ""}${issueTag}${theme.fg("dim", ageTag)}`,
|
|
196
202
|
w,
|
|
197
203
|
),
|
|
198
204
|
);
|
|
@@ -271,7 +277,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
271
277
|
);
|
|
272
278
|
lines.push(
|
|
273
279
|
truncLine(
|
|
274
|
-
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(agent)}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? modelLabel(p) : ""}${queuedTag}`,
|
|
280
|
+
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(agent)}${resumeMark}${taskIdTag}${theme.fg("muted", ` — ${task}`)}${expanded ? modelLabel(p) : ""}${queuedTag}`,
|
|
275
281
|
w,
|
|
276
282
|
),
|
|
277
283
|
);
|
|
@@ -431,6 +437,11 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
431
437
|
const taskId = formatTaskId(p.id);
|
|
432
438
|
const taskIdTag = taskId ? theme.fg("accent", taskId) : "";
|
|
433
439
|
const taskIdWidth = taskId.length;
|
|
440
|
+
// Revival marker: a resumed row must never read as a fresh spawn. Empty
|
|
441
|
+
// when the identity already carries the resume label (omitted-agent
|
|
442
|
+
// resumes resolve to `resume:<tag>` at task resolution).
|
|
443
|
+
const resumeMarkRaw = resumeMarker(p);
|
|
444
|
+
const resumeMark = resumeMarkRaw ? theme.fg("warning", resumeMarkRaw) : "";
|
|
434
445
|
const previewBudget = Math.max(1, w - 30 - taskIdWidth);
|
|
435
446
|
const taskPreview = theme.fg("muted", ` — ${trunc(task, previewBudget)}`);
|
|
436
447
|
const isLive =
|
|
@@ -445,7 +456,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
445
456
|
: "";
|
|
446
457
|
lines.push(
|
|
447
458
|
truncLine(
|
|
448
|
-
`${tree(i, total)} ${icon} ${theme.bold(agent)}${taskIdTag}${taskPreview}${expanded ? modelLabel(p) : ""}${isLive ? liveTail : cancelledTail || (expanded ? statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)]) : "")}`,
|
|
459
|
+
`${tree(i, total)} ${icon} ${theme.bold(agent)}${resumeMark}${taskIdTag}${taskPreview}${expanded ? modelLabel(p) : ""}${isLive ? liveTail : cancelledTail || (expanded ? statJoin([fmtDuration(p.durationMs), taskTokenLabel(p)]) : "")}`,
|
|
449
460
|
w,
|
|
450
461
|
),
|
|
451
462
|
);
|
|
@@ -459,7 +470,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
459
470
|
);
|
|
460
471
|
}
|
|
461
472
|
|
|
462
|
-
// Warnings (e.g.
|
|
473
|
+
// Warnings (e.g. scratch-workspace notices, ignored model suffix) — muted
|
|
474
|
+
// line under the task.
|
|
463
475
|
pushWarnings(p, ind);
|
|
464
476
|
|
|
465
477
|
// Tool activities: compact summary only in expanded mode, terminal tasks only.
|
package/schema.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Type, type SchemaOptions } from "@sinclair/typebox";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
VALID_THINKING_LEVELS,
|
|
4
|
+
isSessionControlAction,
|
|
5
|
+
} from "./constants.ts";
|
|
3
6
|
import type { DelegateArguments } from "./types.ts";
|
|
4
7
|
|
|
5
8
|
// JSON Schema string enum that keeps the literal union in `Static<>`.
|
|
@@ -30,19 +33,19 @@ export const delegateTaskSchema = Type.Object({
|
|
|
30
33
|
minLength: 1,
|
|
31
34
|
maxLength: 64,
|
|
32
35
|
description:
|
|
33
|
-
"Optional
|
|
36
|
+
"Optional correlation key; duplicates rejected; omit for index.",
|
|
34
37
|
}),
|
|
35
38
|
),
|
|
36
39
|
prompt: Type.Optional(
|
|
37
40
|
Type.String({
|
|
38
41
|
description:
|
|
39
|
-
"Self-contained task prompt; fresh context cannot see this chat. Omit only for
|
|
42
|
+
"Self-contained task prompt; fresh context cannot see this chat. Omit only for resumeFrom.",
|
|
40
43
|
}),
|
|
41
44
|
),
|
|
42
45
|
agent: Type.Optional(
|
|
43
46
|
Type.String({
|
|
44
47
|
description:
|
|
45
|
-
"
|
|
48
|
+
"default mirrors the parent's tools; scout/coder/reviewer specialists. Ad-hoc tasks get * tools even when the parent is narrower.",
|
|
46
49
|
}),
|
|
47
50
|
),
|
|
48
51
|
cwd: Type.Optional(
|
|
@@ -86,13 +89,6 @@ export const delegateTaskSchema = Type.Object({
|
|
|
86
89
|
"Live pool key for multi-turn reuse; omit for one-shot tasks.",
|
|
87
90
|
}),
|
|
88
91
|
),
|
|
89
|
-
sessionAction: Type.Optional(
|
|
90
|
-
StringEnum(["prompt", "close", "list"], {
|
|
91
|
-
description:
|
|
92
|
-
"Session action; close needs sessionId; list shows active pooled sessions.",
|
|
93
|
-
default: "prompt",
|
|
94
|
-
}),
|
|
95
|
-
),
|
|
96
92
|
resumeFrom: Type.Optional(
|
|
97
93
|
Type.String({
|
|
98
94
|
description:
|
|
@@ -108,7 +104,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
108
104
|
workspace: Type.Optional(
|
|
109
105
|
StringEnum(["shared", "scratch", "isolated"], {
|
|
110
106
|
description:
|
|
111
|
-
"shared; scratch
|
|
107
|
+
"shared edits source; scratch discards; isolated orders Git worktree proposals; none confine access.",
|
|
112
108
|
}),
|
|
113
109
|
),
|
|
114
110
|
});
|
|
@@ -124,6 +120,18 @@ export const delegateArgumentsSchema = Type.Object(
|
|
|
124
120
|
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
125
121
|
}),
|
|
126
122
|
),
|
|
123
|
+
sessionAction: Type.Optional(
|
|
124
|
+
StringEnum(["close", "list"], {
|
|
125
|
+
description:
|
|
126
|
+
'"close" ends the named pooled session; "list" lists active ones. Runs instead of tasks.',
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
sessionId: Type.Optional(
|
|
130
|
+
Type.String({
|
|
131
|
+
description:
|
|
132
|
+
'With "close": the pooled session to end. Alone, a sessionId folds into a task as reuse.',
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
127
135
|
async: Type.Optional(
|
|
128
136
|
Type.Boolean({
|
|
129
137
|
description:
|
|
@@ -162,7 +170,10 @@ export const delegateArgumentsSchema = Type.Object(
|
|
|
162
170
|
);
|
|
163
171
|
|
|
164
172
|
/** Fields that belong to a task entry. Models sometimes place these at the top
|
|
165
|
-
* level of the arguments; the
|
|
173
|
+
* level of the arguments; the normalizer folds them back into a single task.
|
|
174
|
+
* `sessionAction` is NOT here: it was promoted to a top-level field (#32), so
|
|
175
|
+
* top-level presence means session-RPC intent, not task intent — the classifier
|
|
176
|
+
* and its mode validators own it now. */
|
|
166
177
|
const TASK_FIELD_NAMES = [
|
|
167
178
|
"id",
|
|
168
179
|
"prompt",
|
|
@@ -174,7 +185,6 @@ const TASK_FIELD_NAMES = [
|
|
|
174
185
|
"tools",
|
|
175
186
|
"thinking",
|
|
176
187
|
"sessionId",
|
|
177
|
-
"sessionAction",
|
|
178
188
|
"resumeFrom",
|
|
179
189
|
"deadlineMs",
|
|
180
190
|
"workspace",
|
|
@@ -187,7 +197,18 @@ const TASK_FIELD_NAMES = [
|
|
|
187
197
|
* the work while the call in fact ran synchronously). */
|
|
188
198
|
const VALID_TASK_KEYS = new Set<string>(TASK_FIELD_NAMES);
|
|
189
199
|
|
|
190
|
-
/**
|
|
200
|
+
/** Task fields that are known spellings of top-level fields — rejected inside
|
|
201
|
+
* task entries with a corrective hint pointing at their real home. */
|
|
202
|
+
const TOP_LEVEL_TASK_KEY_HINTS = new Set(["async", "sessionAction"]);
|
|
203
|
+
|
|
204
|
+
/** Validate the four operation modes after compatibility reshaping.
|
|
205
|
+
*
|
|
206
|
+
* One classifier with fixed precedence runs first: `ticketAction` → ticket
|
|
207
|
+
* RPC; `sessionAction` → session RPC; non-empty `tasks` → dispatch; otherwise
|
|
208
|
+
* help. Each mode gets a small total validator that rejects foreign fields
|
|
209
|
+
* generically (naming the offending field and the fix), so ordering is no
|
|
210
|
+
* longer load-bearing across checks and a new field costs one schema entry
|
|
211
|
+
* plus one mode check. */
|
|
191
212
|
export function validateDelegateOperation(
|
|
192
213
|
params: DelegateArguments,
|
|
193
214
|
): string | undefined {
|
|
@@ -195,37 +216,85 @@ export function validateDelegateOperation(
|
|
|
195
216
|
if ("action" in rawParams) {
|
|
196
217
|
return (
|
|
197
218
|
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
|
|
198
|
-
"or 'sessionAction' for
|
|
219
|
+
"or 'sessionAction' for close/list."
|
|
199
220
|
);
|
|
200
221
|
}
|
|
201
|
-
const tasks = params.tasks ?? [];
|
|
202
222
|
|
|
203
|
-
|
|
204
|
-
|
|
223
|
+
// Mode classification — precedence by selector presence.
|
|
224
|
+
if (params.ticketAction !== undefined) {
|
|
225
|
+
return validateTicketMode(params);
|
|
226
|
+
}
|
|
227
|
+
if (params.sessionAction !== undefined) {
|
|
228
|
+
return validateSessionMode(params);
|
|
229
|
+
}
|
|
230
|
+
return validateDispatchOrHelpMode(params);
|
|
231
|
+
}
|
|
205
232
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return
|
|
233
|
+
/** Fields recognized at the top level in session mode: the selector itself
|
|
234
|
+
* plus its target. Anything else (ticket fields, async, tasks, stray task
|
|
235
|
+
* fields) is foreign to session RPC. */
|
|
236
|
+
const SESSION_MODE_FIELDS = new Set(["sessionAction", "sessionId"]);
|
|
237
|
+
|
|
238
|
+
/** Session RPC: one close/list action per call against one pooled session. */
|
|
239
|
+
function validateSessionMode(params: DelegateArguments): string | undefined {
|
|
240
|
+
const rawParams = params as Record<string, unknown>;
|
|
241
|
+
const { sessionAction } = params;
|
|
242
|
+
// Nothing strips or defaults anymore; any value besides 'close'/'list'
|
|
243
|
+
// fails closed below rather than misclassifying.
|
|
244
|
+
if (!isSessionControlAction(sessionAction)) {
|
|
245
|
+
return `sessionAction '${String(sessionAction)}' is not a session control action; use 'close' or 'list'.`;
|
|
246
|
+
}
|
|
247
|
+
if (sessionAction === "close" && !params.sessionId) {
|
|
248
|
+
return "sessionAction 'close' requires sessionId.";
|
|
249
|
+
}
|
|
250
|
+
const foreign = Object.keys(rawParams).filter(
|
|
251
|
+
(key) => !SESSION_MODE_FIELDS.has(key),
|
|
252
|
+
);
|
|
253
|
+
if (foreign.length) {
|
|
254
|
+
return `sessionAction '${sessionAction}' cannot be combined with ${foreign
|
|
255
|
+
.map((field) => `'${field}'`)
|
|
256
|
+
.join(", ")}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Ticket RPC: `ticketAction` owns the call; every other field family is
|
|
262
|
+
* foreign. Note `sessionAction` must be listed explicitly: it left
|
|
263
|
+
* `TASK_FIELD_NAMES` when it was promoted to top level, and without owning it
|
|
264
|
+
* here the old task-level exclusion would silently evaporate. */
|
|
265
|
+
function validateTicketMode(params: DelegateArguments): string | undefined {
|
|
266
|
+
const rawParams = params as Record<string, unknown>;
|
|
267
|
+
const ticketAction = params.ticketAction;
|
|
268
|
+
const incompatibleFields = (
|
|
269
|
+
[...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
|
|
270
|
+
).filter((field) => rawParams[field] !== undefined);
|
|
271
|
+
if (incompatibleFields.length) {
|
|
272
|
+
return `ticket control cannot be combined with field(s) ${incompatibleFields
|
|
273
|
+
.map((field) => `'${field}'`)
|
|
274
|
+
.join(", ")}; call it separately.`;
|
|
275
|
+
}
|
|
276
|
+
if (params.async === true) {
|
|
277
|
+
return "ticket control cannot include async; call it separately.";
|
|
278
|
+
}
|
|
279
|
+
if (ticketAction !== "poll" && !params.ticket) {
|
|
280
|
+
return `ticketAction '${ticketAction}' requires ticket.`;
|
|
281
|
+
}
|
|
282
|
+
if (ticketAction !== "cancel" && params.force === true) {
|
|
283
|
+
return "force is valid only with ticketAction 'cancel'.";
|
|
228
284
|
}
|
|
285
|
+
if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
|
|
286
|
+
return "timeoutMs is valid only with ticketAction 'wait'.";
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Dispatch (non-empty `tasks`) or help (empty/absent). Stray ticket- and
|
|
292
|
+
* session-mode selectors are foreign here and rejected generically. */
|
|
293
|
+
function validateDispatchOrHelpMode(
|
|
294
|
+
params: DelegateArguments,
|
|
295
|
+
): string | undefined {
|
|
296
|
+
const rawParams = params as Record<string, unknown>;
|
|
297
|
+
const tasks = params.tasks ?? [];
|
|
229
298
|
|
|
230
299
|
if (params.ticket !== undefined) {
|
|
231
300
|
return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
|
|
@@ -249,30 +318,29 @@ export function validateDelegateOperation(
|
|
|
249
318
|
// nonempty tasks array. The normalize shim only wraps flat fields when
|
|
250
319
|
// there is no tasks array, so a mixed call silently lets tasks win —
|
|
251
320
|
// a model mistake that should fail loudly.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)} with an explicit tasks array; move them into a task entry or remove tasks.`;
|
|
262
|
-
}
|
|
321
|
+
const flatTaskFields = TASK_FIELD_NAMES.filter(
|
|
322
|
+
(field) => rawParams[field] !== undefined,
|
|
323
|
+
);
|
|
324
|
+
if (flatTaskFields.length) {
|
|
325
|
+
return `cannot mix top-level task field(s) ${flatTaskFields
|
|
326
|
+
.map((field) => `'${field}'`)
|
|
327
|
+
.join(
|
|
328
|
+
", ",
|
|
329
|
+
)} with an explicit tasks array; move them into a task entry or remove tasks.`;
|
|
263
330
|
}
|
|
264
331
|
|
|
265
332
|
for (const [index, task] of tasks.entries()) {
|
|
266
333
|
const rawTask = task as Record<string, unknown>;
|
|
267
|
-
const sessionAction = task.sessionAction;
|
|
268
334
|
|
|
269
335
|
const unknownKeys = Object.keys(rawTask).filter(
|
|
270
336
|
(key) => !VALID_TASK_KEYS.has(key),
|
|
271
337
|
);
|
|
272
338
|
if (unknownKeys.length) {
|
|
273
|
-
const misplacedTopLevel = unknownKeys.filter((key) =>
|
|
339
|
+
const misplacedTopLevel = unknownKeys.filter((key) =>
|
|
340
|
+
TOP_LEVEL_TASK_KEY_HINTS.has(key),
|
|
341
|
+
);
|
|
274
342
|
const topLevelHint = misplacedTopLevel.length
|
|
275
|
-
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level
|
|
343
|
+
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level field${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
|
|
276
344
|
: "";
|
|
277
345
|
return (
|
|
278
346
|
`task ${index + 1}: unknown field(s) ${unknownKeys
|
|
@@ -291,28 +359,9 @@ export function validateDelegateOperation(
|
|
|
291
359
|
}
|
|
292
360
|
if (
|
|
293
361
|
(task.workspace === "scratch" || task.workspace === "isolated") &&
|
|
294
|
-
(task.sessionId || task.resumeFrom
|
|
362
|
+
(task.sessionId || task.resumeFrom)
|
|
295
363
|
) {
|
|
296
|
-
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId
|
|
297
|
-
}
|
|
298
|
-
if (sessionAction === "close") {
|
|
299
|
-
if (!task.sessionId) {
|
|
300
|
-
return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
|
|
301
|
-
}
|
|
302
|
-
const extras = Object.keys(rawTask).filter(
|
|
303
|
-
(key) => key !== "sessionAction" && key !== "sessionId" && key !== "id",
|
|
304
|
-
);
|
|
305
|
-
if (extras.length) {
|
|
306
|
-
return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (sessionAction === "list") {
|
|
310
|
-
const extras = Object.keys(rawTask).filter(
|
|
311
|
-
(key) => key !== "sessionAction" && key !== "id",
|
|
312
|
-
);
|
|
313
|
-
if (extras.length) {
|
|
314
|
-
return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
|
|
315
|
-
}
|
|
364
|
+
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId or resumeFrom. Set workspace: "shared" to use a persistent agent.`;
|
|
316
365
|
}
|
|
317
366
|
}
|
|
318
367
|
|
|
@@ -350,13 +399,29 @@ function hasTicketControlIntent(record: Record<string, unknown>): boolean {
|
|
|
350
399
|
);
|
|
351
400
|
}
|
|
352
401
|
|
|
402
|
+
/** True when `record` carries top-level session-RPC intent: an explicit
|
|
403
|
+
* close/list `sessionAction`. A bare top-level `sessionId` is deliberately
|
|
404
|
+
* NOT session intent — it stays task-intent and wraps into a task as reuse.
|
|
405
|
+
* Only `sessionAction` presence selects the session mode. */
|
|
406
|
+
function hasSessionControlIntent(record: Record<string, unknown>): boolean {
|
|
407
|
+
return isSessionControlAction(record.sessionAction);
|
|
408
|
+
}
|
|
409
|
+
|
|
353
410
|
/** Fold top-level task fields into a single `tasks` entry. Only fires when
|
|
354
|
-
* there is no usable tasks array and
|
|
355
|
-
*
|
|
356
|
-
*
|
|
411
|
+
* there is no usable tasks array and neither ticket-control intent nor
|
|
412
|
+
* session-control intent makes those calls legitimately taskless.
|
|
413
|
+
* `sessionAction` is not part of `TASK_FIELD_NAMES`: a top-level close/list
|
|
414
|
+
* means session RPC and must reach the classifier unwrapped — wrapping first
|
|
415
|
+
* would swallow stray fields into a task instead of rejecting them. */
|
|
357
416
|
function wrapFlatTaskFields(record: Record<string, unknown>): void {
|
|
358
417
|
const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
|
|
359
|
-
if (
|
|
418
|
+
if (
|
|
419
|
+
hasTasks ||
|
|
420
|
+
hasTicketControlIntent(record) ||
|
|
421
|
+
hasSessionControlIntent(record)
|
|
422
|
+
) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
360
425
|
const task: Record<string, unknown> = {};
|
|
361
426
|
for (const key of TASK_FIELD_NAMES) {
|
|
362
427
|
if (record[key] !== undefined) {
|
|
@@ -390,9 +455,11 @@ function normalizeTaskEntry(entry: unknown): unknown {
|
|
|
390
455
|
* models then misread as "the tool is broken"):
|
|
391
456
|
* - `tasks` as a JSON string instead of an array;
|
|
392
457
|
* - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
|
|
393
|
-
* level instead of inside a `tasks` entry — wrapped into a single task
|
|
458
|
+
* level instead of inside a `tasks` entry — wrapped into a single task,
|
|
459
|
+
* unless ticket- or session-control intent makes the call legitimately
|
|
460
|
+
* taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
|
|
394
461
|
* - `tools` as a JSON string (or bare token) inside a task entry;
|
|
395
|
-
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc)
|
|
462
|
+
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
|
|
396
463
|
* All other invalid input is left for normal schema validation to reject
|
|
397
464
|
* loudly.
|
|
398
465
|
*
|
package/task-resolution.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_AGENT_NAME,
|
|
6
6
|
DEFAULT_TOOLS,
|
|
7
7
|
VALID_THINKING,
|
|
8
|
+
isSessionControlAction,
|
|
8
9
|
} from "./constants.ts";
|
|
9
10
|
import {
|
|
10
11
|
TOOL_FACTORIES,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
isSessionIdQuarantined,
|
|
19
20
|
} from "./session-quarantine.ts";
|
|
20
21
|
import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
|
|
22
|
+
import { formatResumeTag } from "./format.ts";
|
|
21
23
|
import { buildParentTranscript } from "./parent-context.ts";
|
|
22
24
|
import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
|
|
23
25
|
import {
|
|
@@ -35,7 +37,8 @@ import type {
|
|
|
35
37
|
DelegateToolResult,
|
|
36
38
|
ParentAgentDefaults,
|
|
37
39
|
ResolvedTask,
|
|
38
|
-
|
|
40
|
+
ResolveTasksResult,
|
|
41
|
+
DispatchableTask,
|
|
39
42
|
} from "./types.ts";
|
|
40
43
|
|
|
41
44
|
const PROJECT_CONTEXT_START =
|
|
@@ -91,7 +94,7 @@ export function stripInheritedProjectContext(
|
|
|
91
94
|
/** Build a tool result for an error/notice with no task progress. */
|
|
92
95
|
function noticeResult(
|
|
93
96
|
text: string,
|
|
94
|
-
tasks:
|
|
97
|
+
tasks: DispatchableTask[],
|
|
95
98
|
parentModel: string | undefined,
|
|
96
99
|
): DelegateToolResult {
|
|
97
100
|
return {
|
|
@@ -109,7 +112,7 @@ function formatTaskRef(index: number, id: string | undefined): string {
|
|
|
109
112
|
* ticket, and unknown agent names. Returns an error result to short-circuit
|
|
110
113
|
* the call, or null when all checks pass. */
|
|
111
114
|
export function validateTasks(
|
|
112
|
-
tasks:
|
|
115
|
+
tasks: DispatchableTask[],
|
|
113
116
|
agents: Map<string, AgentConfig>,
|
|
114
117
|
parentModelId: string | undefined,
|
|
115
118
|
): DelegateToolResult | null {
|
|
@@ -151,7 +154,7 @@ export function validateTasks(
|
|
|
151
154
|
: `uses workspace \`${workspace}\``;
|
|
152
155
|
const persistentAgent = task.agent ?? "agent";
|
|
153
156
|
return noticeResult(
|
|
154
|
-
`${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId
|
|
157
|
+
`${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId\` or \`resumeFrom\`. Set \`workspace: "shared"\` to use a persistent ${persistentAgent}.`,
|
|
155
158
|
tasks,
|
|
156
159
|
parentModelId,
|
|
157
160
|
);
|
|
@@ -234,15 +237,17 @@ export function validateTasks(
|
|
|
234
237
|
|
|
235
238
|
/** Resolve every task into a fully-specified `ResolvedTask`: cwd, system
|
|
236
239
|
* prompt, model, tools, thinking, and prompt (with optional parent-transcript
|
|
237
|
-
* injection).
|
|
238
|
-
*
|
|
240
|
+
* injection). Returns `{ error }` — rejecting the whole batch before any
|
|
241
|
+
* dispatch — when any task names a tool outside the closed valid set for its
|
|
242
|
+
* resolved model's provider. Throws on unrecoverable misconfiguration
|
|
243
|
+
* (missing prompt, unavailable explicit model, no model at all). */
|
|
239
244
|
export function resolveTasks(
|
|
240
|
-
tasks:
|
|
245
|
+
tasks: DispatchableTask[],
|
|
241
246
|
ctx: DelegateToolCtx,
|
|
242
247
|
agents: Map<string, AgentConfig>,
|
|
243
248
|
parentDefaults: ParentAgentDefaults,
|
|
244
249
|
dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
|
|
245
|
-
):
|
|
250
|
+
): ResolveTasksResult {
|
|
246
251
|
// Build parent transcript lazily — only computed once if any task uses with-parent-transcript
|
|
247
252
|
let parentTranscript: string | null = null;
|
|
248
253
|
const needsParentContext = tasks.some(
|
|
@@ -268,7 +273,10 @@ export function resolveTasks(
|
|
|
268
273
|
const agentOverrides = getAgentOverrides(dispatchConfig);
|
|
269
274
|
const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
|
|
270
275
|
|
|
271
|
-
|
|
276
|
+
const resolveTask = (
|
|
277
|
+
t: DispatchableTask,
|
|
278
|
+
i: number,
|
|
279
|
+
): ResolvedTask | { error: string } => {
|
|
272
280
|
const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
|
|
273
281
|
const agent = t.agent
|
|
274
282
|
? (agents.get(t.agent) ?? BUILTIN_AGENT_CONFIGS[t.agent])
|
|
@@ -317,8 +325,7 @@ export function resolveTasks(
|
|
|
317
325
|
|
|
318
326
|
// Prompt is required for fresh tasks. ResumeFrom provides context already.
|
|
319
327
|
if (
|
|
320
|
-
t.sessionAction
|
|
321
|
-
t.sessionAction !== "list" &&
|
|
328
|
+
!isSessionControlAction(t.sessionAction) &&
|
|
322
329
|
!t.resumeFrom &&
|
|
323
330
|
!t.prompt?.trim()
|
|
324
331
|
) {
|
|
@@ -327,11 +334,12 @@ export function resolveTasks(
|
|
|
327
334
|
);
|
|
328
335
|
}
|
|
329
336
|
|
|
330
|
-
// Resolve tools —
|
|
337
|
+
// Resolve tools — unknown names reject the whole batch (checked below,
|
|
338
|
+
// post-model-resolution, because the provider decides the valid set).
|
|
331
339
|
// For active pooled sessions, fall back to the frozen pooled config so
|
|
332
340
|
// "continue with only sessionId" works without re-supplying tools.
|
|
333
341
|
// Explicit overrides that don't match get rejected by acquireAgentSession.
|
|
334
|
-
if (t.sessionAction
|
|
342
|
+
if (!isSessionControlAction(t.sessionAction)) {
|
|
335
343
|
// For `default` a deny-only override (no explicit allowlist) is not
|
|
336
344
|
// materialized at discovery; apply its denylist to the parent's actual
|
|
337
345
|
// tools here so a read-only parent stays read-only.
|
|
@@ -427,7 +435,7 @@ export function resolveTasks(
|
|
|
427
435
|
let modelSuffix: ThinkingLevel | undefined;
|
|
428
436
|
let thinking: ThinkingLevel = "off";
|
|
429
437
|
|
|
430
|
-
if (t.sessionAction
|
|
438
|
+
if (!isSessionControlAction(t.sessionAction)) {
|
|
431
439
|
const agentType = t.agent ?? "inline";
|
|
432
440
|
// The built-in `default` profile bypasses delegate.json model overrides.
|
|
433
441
|
// The other built-ins accept task and modern agent overrides, but
|
|
@@ -567,15 +575,20 @@ export function resolveTasks(
|
|
|
567
575
|
dispatchConfig,
|
|
568
576
|
);
|
|
569
577
|
|
|
578
|
+
// The valid tool set is closed: core TOOL_FACTORIES plus the static
|
|
579
|
+
// per-provider list. Unknown names hard-reject the whole batch (mirroring
|
|
580
|
+
// unknown agent names) instead of warn-dropping a silently weakened
|
|
581
|
+
// subagent. Frozen pooled configs are known-good: they were filtered
|
|
582
|
+
// against this same static set when first resolved, so bare
|
|
583
|
+
// "continue with only sessionId" resumes never trip this check.
|
|
570
584
|
const availableTools = availableToolNames(model?.provider);
|
|
571
585
|
const availableToolSet = new Set(availableTools);
|
|
572
586
|
const unknownTools = tools.filter((name) => !availableToolSet.has(name));
|
|
573
587
|
if (unknownTools.length) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
return {
|
|
589
|
+
error: `${formatTaskRef(i, t.id)}: unknown tool(s): ${unknownTools.join(", ")}. Available: ${availableTools.join(", ")}. Fix the tool names in the task's tools list or the agent profile's tools.`,
|
|
590
|
+
};
|
|
577
591
|
}
|
|
578
|
-
tools = tools.filter((name) => availableToolSet.has(name));
|
|
579
592
|
systemPrompt = buildSubagentSystemPrompt({
|
|
580
593
|
taskSystemPrompt: t.systemPrompt,
|
|
581
594
|
agentSystemPrompt: agent?.systemPrompt,
|
|
@@ -591,6 +604,14 @@ export function resolveTasks(
|
|
|
591
604
|
requestedSystemPrompt = systemPrompt;
|
|
592
605
|
}
|
|
593
606
|
|
|
607
|
+
// Freeze the display tag from the caller's path *before* lifecycle
|
|
608
|
+
// canonicalizes `resumeFrom` for locking/acquisition. A symlink whose
|
|
609
|
+
// basename differs from its target would otherwise make the settled row's
|
|
610
|
+
// `resumedFrom` disagree with the live progress row and `agentName`,
|
|
611
|
+
// defeating `resumeMarker`'s no-duplication rule.
|
|
612
|
+
const resumeFromDisplay = t.resumeFrom
|
|
613
|
+
? formatResumeTag(t.resumeFrom)
|
|
614
|
+
: undefined;
|
|
594
615
|
return {
|
|
595
616
|
...t,
|
|
596
617
|
id: t.id,
|
|
@@ -604,8 +625,13 @@ export function resolveTasks(
|
|
|
604
625
|
// display code treats "" and absent alike (`t.prompt || …`).
|
|
605
626
|
prompt: prompt ?? "",
|
|
606
627
|
// Keep the built-in selector visible in progress/results. Omitted-agent
|
|
607
|
-
// inline tasks retain the established `ad-hoc` label and config namespace
|
|
608
|
-
|
|
628
|
+
// inline tasks retain the established `ad-hoc` label and config namespace
|
|
629
|
+
// — except resumes: a continued transcript is not a fresh ad-hoc spawn,
|
|
630
|
+
// so it carries the resumed-transcript identity instead.
|
|
631
|
+
agentName:
|
|
632
|
+
agent?.name ??
|
|
633
|
+
(resumeFromDisplay ? `resume:${resumeFromDisplay}` : "ad-hoc"),
|
|
634
|
+
resumeFromDisplay,
|
|
609
635
|
warnings,
|
|
610
636
|
reuseIntent: {
|
|
611
637
|
model: requestedModel,
|
|
@@ -613,5 +639,13 @@ export function resolveTasks(
|
|
|
613
639
|
},
|
|
614
640
|
providerExtensionSources,
|
|
615
641
|
};
|
|
616
|
-
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const resolved: ResolvedTask[] = [];
|
|
645
|
+
for (const [i, task] of tasks.entries()) {
|
|
646
|
+
const result = resolveTask(task, i);
|
|
647
|
+
if ("error" in result) return { error: result.error };
|
|
648
|
+
resolved.push(result);
|
|
649
|
+
}
|
|
650
|
+
return { tasks: resolved };
|
|
617
651
|
}
|
package/ticket-format.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
relativeTouchedSummary,
|
|
11
11
|
findTouchedOverlaps,
|
|
12
12
|
formatTouchedOverlapWarning,
|
|
13
|
+
resumeMarker,
|
|
13
14
|
} from "./format.ts";
|
|
14
15
|
import { renderOutputForPoll } from "./spill.ts";
|
|
15
16
|
import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
@@ -93,12 +94,12 @@ export function formatInFlightTaskLine(p: TaskProgress): string {
|
|
|
93
94
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
94
95
|
const age = getActivityAge(p.lastActivityAt);
|
|
95
96
|
if (age) parts.push(age);
|
|
96
|
-
return `⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`;
|
|
97
|
+
return `⏳ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${parts.join(" · ")}`;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/** Queued-task line shared by poll snapshots and cancel previews. */
|
|
100
101
|
export function formatQueuedTaskLine(p: TaskProgress): string {
|
|
101
|
-
return `○ ${p.agent}${formatTaskId(p.id)} · waiting…`;
|
|
102
|
+
return `○ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · waiting…`;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
function appendTouchedMeta(
|
|
@@ -123,7 +124,7 @@ function formatSettledPollLines(
|
|
|
123
124
|
const thresholdChars = getOutputSpillThreshold(ticket.config);
|
|
124
125
|
if (!failed) {
|
|
125
126
|
const lines = [
|
|
126
|
-
`✓ ${result.agent}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
|
|
127
|
+
`✓ ${result.agent}${resumeMarker(result)}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
|
|
127
128
|
];
|
|
128
129
|
if (result.output && result.output !== "(no output)") {
|
|
129
130
|
lines.push(
|
|
@@ -134,7 +135,7 @@ function formatSettledPollLines(
|
|
|
134
135
|
}
|
|
135
136
|
const errorText = result.error ?? "unknown error";
|
|
136
137
|
const lines = [
|
|
137
|
-
`✗ ${result.agent}${formatTaskId(result.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
138
|
+
`✗ ${result.agent}${resumeMarker(result)}${formatTaskId(result.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
138
139
|
];
|
|
139
140
|
if (result.sessionFile)
|
|
140
141
|
lines.push(` session: ${shortenPath(result.sessionFile)}`);
|
|
@@ -273,9 +274,13 @@ export function formatCancelPreview(ticket: AsyncTicket): string {
|
|
|
273
274
|
|
|
274
275
|
for (const p of ticket.progress) {
|
|
275
276
|
if (p.status === "done") {
|
|
276
|
-
lines.push(
|
|
277
|
+
lines.push(
|
|
278
|
+
`✓ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · completed`,
|
|
279
|
+
);
|
|
277
280
|
} else if (p.status === "failed") {
|
|
278
|
-
lines.push(
|
|
281
|
+
lines.push(
|
|
282
|
+
`✗ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${p.error ?? "failed"}`,
|
|
283
|
+
);
|
|
279
284
|
} else if (p.status === "running") {
|
|
280
285
|
lines.push(formatInFlightTaskLine(p));
|
|
281
286
|
} else {
|
package/tickets.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
fmtDuration,
|
|
12
12
|
formatCompletedTask,
|
|
13
13
|
formatTaskId,
|
|
14
|
+
resumeMarker,
|
|
14
15
|
trunc,
|
|
15
16
|
findTouchedOverlaps,
|
|
16
17
|
formatTouchedOverlapWarning,
|
|
@@ -268,7 +269,7 @@ export function formatCompletedTicket(
|
|
|
268
269
|
const t = ticket.resolved[i]!;
|
|
269
270
|
if (!r) {
|
|
270
271
|
parts.push(
|
|
271
|
-
`=== ${t.agentName}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
272
|
+
`=== ${t.agentName}${resumeMarker(ticket.progress[i]!)}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
272
273
|
);
|
|
273
274
|
parts.push(`[${pendingLabelFor(i)}]`);
|
|
274
275
|
continue;
|
|
@@ -323,6 +324,7 @@ function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
|
|
|
323
324
|
return {
|
|
324
325
|
id: task?.id,
|
|
325
326
|
agent: task?.agentName ?? "unknown",
|
|
327
|
+
resumedFrom: task?.resumeFromDisplay,
|
|
326
328
|
output: "",
|
|
327
329
|
durationMs: 0,
|
|
328
330
|
tokens: 0,
|
package/types.ts
CHANGED
|
@@ -53,13 +53,22 @@ type CanonicalTaskDef = NonNullable<
|
|
|
53
53
|
export type TicketAction = NonNullable<
|
|
54
54
|
CanonicalDelegateArguments["ticketAction"]
|
|
55
55
|
>;
|
|
56
|
-
/**
|
|
57
|
-
export type SessionAction = NonNullable<
|
|
56
|
+
/** Top-level session RPC action: "close" | "list" (#32 promotion). */
|
|
57
|
+
export type SessionAction = NonNullable<
|
|
58
|
+
CanonicalDelegateArguments["sessionAction"]
|
|
59
|
+
>;
|
|
58
60
|
/** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
|
|
59
61
|
export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
|
|
60
62
|
|
|
61
63
|
export type TaskDef = CanonicalTaskDef;
|
|
62
64
|
|
|
65
|
+
/** Pipeline-wide task type: caller-provided TaskDefs plus the internal
|
|
66
|
+
* session-RPC bridge task built by extension.ts from the promoted top-level
|
|
67
|
+
* `sessionAction`/`sessionId` fields. `sessionAction` left the public task
|
|
68
|
+
* schema in #32 (it became top-level); the runner still executes the bridged
|
|
69
|
+
* operation through ResolvedTask, so the dispatch pipeline accepts both. */
|
|
70
|
+
export type DispatchableTask = TaskDef & { sessionAction?: SessionAction };
|
|
71
|
+
|
|
63
72
|
export type DelegateArguments = CanonicalDelegateArguments;
|
|
64
73
|
|
|
65
74
|
// ── Async Ticket Types ─────────────────────────────────────────────────────
|
|
@@ -148,6 +157,14 @@ export interface ResolvedTask {
|
|
|
148
157
|
sessionId?: string;
|
|
149
158
|
sessionAction?: SessionAction;
|
|
150
159
|
resumeFrom?: string;
|
|
160
|
+
/** Display tag (`formatResumeTag`) of the *caller's* `resumeFrom` path, frozen
|
|
161
|
+
* once at task resolution. `resumeFrom` itself is later replaced by the
|
|
162
|
+
* canonical transcript path for locking/acquisition/quarantine, which can
|
|
163
|
+
* have a different basename (symlink alias vs target). Settled-result and
|
|
164
|
+
* progress `resumedFrom` tags must read this field — never re-derive from the
|
|
165
|
+
* now-canonical `resumeFrom` — so the live and settled rows agree and
|
|
166
|
+
* `resumeMarker`'s no-duplication rule holds. */
|
|
167
|
+
resumeFromDisplay?: string;
|
|
151
168
|
/** Hard wall-clock budget in milliseconds, measured from task start. */
|
|
152
169
|
deadlineMs?: number;
|
|
153
170
|
agentName: string;
|
|
@@ -161,6 +178,14 @@ export interface ResolvedTask {
|
|
|
161
178
|
providerExtensionSources?: string;
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
/** Result of `resolveTasks`: either every task resolved, or a batch-wide
|
|
182
|
+
* rejection (e.g. an unknown tool name) with no tasks to dispatch. The
|
|
183
|
+
* optional-`error` discriminant keeps `.tasks` accessible on the whole union
|
|
184
|
+
* so success-path callers can narrow with a single `error !== undefined` check. */
|
|
185
|
+
export type ResolveTasksResult =
|
|
186
|
+
| { tasks: ResolvedTask[]; error?: undefined }
|
|
187
|
+
| { tasks?: undefined; error: string };
|
|
188
|
+
|
|
164
189
|
export interface FileAttributionPathSignature {
|
|
165
190
|
/** Absolute component inspected while resolving the pre-execution target. */
|
|
166
191
|
path: string;
|
|
@@ -228,6 +253,9 @@ export interface TaskProgress {
|
|
|
228
253
|
id?: string;
|
|
229
254
|
index: number;
|
|
230
255
|
agent: string;
|
|
256
|
+
/** Short tag (via `formatResumeTag`) of the transcript this task continued
|
|
257
|
+
* via `resumeFrom`, if any. Lets renderers mark the row as a revival. */
|
|
258
|
+
resumedFrom?: string;
|
|
231
259
|
task: string;
|
|
232
260
|
status: "pending" | "running" | "done" | "failed";
|
|
233
261
|
durationMs: number;
|
|
@@ -240,8 +268,9 @@ export interface TaskProgress {
|
|
|
240
268
|
model?: string;
|
|
241
269
|
lastActivityAt?: number;
|
|
242
270
|
activities: ToolActivity[];
|
|
243
|
-
/** Human-facing notices (e.g.
|
|
244
|
-
*
|
|
271
|
+
/** Human-facing notices (e.g. scratch/isolated workspace notices, an
|
|
272
|
+
* ignored model `:level` suffix). Surfaced in the TUI under the task; the
|
|
273
|
+
* LLM gets the same text in `content` already. */
|
|
245
274
|
warnings?: string[];
|
|
246
275
|
}
|
|
247
276
|
|
|
@@ -267,6 +296,10 @@ export interface DelegateDetails {
|
|
|
267
296
|
export interface TaskResult {
|
|
268
297
|
id?: string;
|
|
269
298
|
agent: string;
|
|
299
|
+
/** Short tag (via `formatResumeTag`) of the transcript this task continued
|
|
300
|
+
* via `resumeFrom`, if any. Lets settled-result renderers mark the row as
|
|
301
|
+
* a revival — mirrors `TaskProgress.resumedFrom`. */
|
|
302
|
+
resumedFrom?: string;
|
|
270
303
|
output: string;
|
|
271
304
|
error?: string;
|
|
272
305
|
/** Stable machine-readable failure reason; error remains human-facing. */
|
package/workspace.ts
CHANGED
|
@@ -142,9 +142,61 @@ function throwIfSetupCancelled(
|
|
|
142
142
|
);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** Project an absolute source-tree symlink target onto its counterpart inside
|
|
146
|
+
* the copy, as a relative target so the copy stays self-contained. Returns
|
|
147
|
+
* undefined when the target is not lexically inside the source root, which is
|
|
148
|
+
* the only case that can be rewritten without following it. */
|
|
149
|
+
function relinkTargetIntoCopy(
|
|
150
|
+
linkPath: string,
|
|
151
|
+
target: string,
|
|
152
|
+
root: string,
|
|
153
|
+
sourceRoot: string,
|
|
154
|
+
): string | undefined {
|
|
155
|
+
if (!path.isAbsolute(target) || !isWithin(sourceRoot, target)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const projected = path.join(root, path.relative(sourceRoot, target));
|
|
159
|
+
if (!isWithin(root, projected)) return undefined;
|
|
160
|
+
const relative = path.relative(path.dirname(linkPath), projected);
|
|
161
|
+
return relative === "" ? "." : relative;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Restore owner traversal/write bits so a partial copy can be removed.
|
|
165
|
+
* `cp --archive` reproduces read-only source directories, and their copies
|
|
166
|
+
* would otherwise leak a lease directory behind a failed setup. */
|
|
167
|
+
async function makeTreeRemovable(root: string): Promise<void> {
|
|
168
|
+
let entries: fs.Dirent[];
|
|
169
|
+
try {
|
|
170
|
+
await fs.promises.chmod(root, 0o700);
|
|
171
|
+
entries = await fs.promises.readdir(root, { withFileTypes: true });
|
|
172
|
+
} catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
await makeTreeRemovable(path.join(root, entry.name));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function replaceSymlink(linkPath: string, target: string): Promise<void> {
|
|
183
|
+
const staging = path.join(
|
|
184
|
+
path.dirname(linkPath),
|
|
185
|
+
`.pi-delegate-relink-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
186
|
+
);
|
|
187
|
+
await fs.promises.symlink(target, staging);
|
|
188
|
+
try {
|
|
189
|
+
await fs.promises.rename(staging, linkPath);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
await fs.promises.rm(staging, { force: true });
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
145
196
|
/** Validate the completed copy before any subagent receives its path. */
|
|
146
197
|
async function validateCopiedTree(
|
|
147
198
|
root: string,
|
|
199
|
+
sourceRoot: string,
|
|
148
200
|
signal: AbortSignal,
|
|
149
201
|
parentSignal: AbortSignal | undefined,
|
|
150
202
|
): Promise<void> {
|
|
@@ -179,10 +231,31 @@ async function validateCopiedTree(
|
|
|
179
231
|
}
|
|
180
232
|
if (!entry.isSymbolicLink()) continue;
|
|
181
233
|
const target = await fs.promises.readlink(candidate);
|
|
234
|
+
// `cp --archive` copies link text verbatim, so an absolute in-project
|
|
235
|
+
// link (bun/pnpm-style local package installs) still resolves to the
|
|
236
|
+
// real tree from inside the copy. Retarget it at its copied counterpart
|
|
237
|
+
// instead of failing: the link keeps working and stays disposable.
|
|
238
|
+
const relinked = relinkTargetIntoCopy(
|
|
239
|
+
candidate,
|
|
240
|
+
target,
|
|
241
|
+
root,
|
|
242
|
+
sourceRoot,
|
|
243
|
+
);
|
|
244
|
+
if (relinked !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
await replaceSymlink(candidate, relinked);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw new ScratchSetupError(
|
|
249
|
+
`Scratch workspace could not retarget in-project symlink '${path.relative(root, candidate)}' at its copied counterpart.`,
|
|
250
|
+
{ cause: error },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
182
255
|
const resolvedTarget = path.resolve(path.dirname(candidate), target);
|
|
183
256
|
if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
|
|
184
257
|
throw new ScratchSetupError(
|
|
185
|
-
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}'
|
|
258
|
+
`Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' -> '${target}': it resolves outside the disposable copy, so relative writes through it would reach the host. Remove the link, or run this task with workspace "shared" (read-only tools) or "isolated".`,
|
|
186
259
|
);
|
|
187
260
|
}
|
|
188
261
|
}
|
|
@@ -704,7 +777,12 @@ export async function createScratchWorkspace(
|
|
|
704
777
|
// GNU cp --archive applies the source root's mode to the destination.
|
|
705
778
|
// Restore the private boundary after it has finished copying metadata.
|
|
706
779
|
await fs.promises.chmod(scratchRoot, 0o700);
|
|
707
|
-
await validateCopiedTree(
|
|
780
|
+
await validateCopiedTree(
|
|
781
|
+
scratchRoot,
|
|
782
|
+
sourceRoot,
|
|
783
|
+
controller.signal,
|
|
784
|
+
signal,
|
|
785
|
+
);
|
|
708
786
|
if (
|
|
709
787
|
await fs.promises.stat(path.join(scratchRoot, ".git")).then(
|
|
710
788
|
(stat) => stat.isDirectory(),
|
|
@@ -766,7 +844,7 @@ export async function createScratchWorkspace(
|
|
|
766
844
|
} catch (error) {
|
|
767
845
|
if (leaseRoot) {
|
|
768
846
|
try {
|
|
769
|
-
await
|
|
847
|
+
await makeTreeRemovable(leaseRoot);
|
|
770
848
|
await fs.promises.rm(leaseRoot, { recursive: true, force: true });
|
|
771
849
|
} catch (cleanupError) {
|
|
772
850
|
console.error(
|
|
@@ -913,13 +991,18 @@ export async function createScratchWorkspace(
|
|
|
913
991
|
readStableLink(source, sourceStat),
|
|
914
992
|
]);
|
|
915
993
|
// A readlink or identity race is not evidence that the nodes matched.
|
|
916
|
-
// Keep the lexical source path rather than dropping it.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
994
|
+
// Keep the lexical source path rather than dropping it. An in-project
|
|
995
|
+
// absolute source link was deliberately retargeted at setup, so its
|
|
996
|
+
// copied form matches that projection rather than the source text.
|
|
997
|
+
if (scratchTarget !== undefined && sourceTarget !== undefined) {
|
|
998
|
+
const expected =
|
|
999
|
+
relinkTargetIntoCopy(
|
|
1000
|
+
lexical,
|
|
1001
|
+
sourceTarget,
|
|
1002
|
+
completedRoot,
|
|
1003
|
+
sourceRoot!,
|
|
1004
|
+
) ?? sourceTarget;
|
|
1005
|
+
if (scratchTarget === expected) return undefined;
|
|
923
1006
|
}
|
|
924
1007
|
}
|
|
925
1008
|
// This is evidence about the lexical node, not its current target.
|