@bermudi/pi-delegate 0.1.11 → 0.1.13
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/README.md +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +8 -1
- package/dispatch.ts +410 -38
- package/extension.ts +14 -0
- package/format.ts +50 -4
- package/host.ts +6 -1
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +50 -16
- package/manual.ts +13 -9
- package/package.json +1 -1
- package/pool.ts +23 -1
- package/provider-extensions.ts +11 -2
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +3 -1
- package/schema.ts +58 -51
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +87 -16
- package/telemetry.ts +135 -68
- package/ticket-format.ts +13 -5
- package/tickets.ts +23 -11
- package/types.ts +79 -0
package/lifecycle.ts
CHANGED
|
@@ -70,11 +70,15 @@ export function _setWholeTaskRetryForTesting(
|
|
|
70
70
|
testWholeTaskBaseDelayMs = opts?.baseDelayMs;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function resolvedWholeTaskMaxRetries(): number {
|
|
74
|
-
return
|
|
73
|
+
function resolvedWholeTaskMaxRetries(env: TaskRunEnv): number {
|
|
74
|
+
return (
|
|
75
|
+
testWholeTaskMaxRetries ?? getWholeTaskMaxRetries(env.config ?? undefined)
|
|
76
|
+
);
|
|
75
77
|
}
|
|
76
|
-
function resolvedWholeTaskBaseDelayMs(): number {
|
|
77
|
-
return
|
|
78
|
+
function resolvedWholeTaskBaseDelayMs(env: TaskRunEnv): number {
|
|
79
|
+
return (
|
|
80
|
+
testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs(env.config ?? undefined)
|
|
81
|
+
);
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** Build a failed TaskResult. Used for early-failure paths (abort, busy, validation). */
|
|
@@ -254,6 +258,7 @@ function recordTaskOutcome(
|
|
|
254
258
|
recordTask({
|
|
255
259
|
callId: env.telemetryCallId,
|
|
256
260
|
generation: env.telemetryGeneration,
|
|
261
|
+
telemetryConfig: env.telemetryConfig,
|
|
257
262
|
async: env.async ?? false,
|
|
258
263
|
taskIndex: p.index,
|
|
259
264
|
task,
|
|
@@ -385,7 +390,7 @@ async function sleepForWholeTaskRetry(
|
|
|
385
390
|
async function buildDelegateSession(
|
|
386
391
|
task: ResolvedTask,
|
|
387
392
|
sessionManager: SessionManager,
|
|
388
|
-
|
|
393
|
+
env: TaskRunEnv,
|
|
389
394
|
): Promise<AgentSession> {
|
|
390
395
|
// Resolve host deps for this task's cwd + system prompt. Extension-free
|
|
391
396
|
// resource loaders are cached after the first call; provider-configured or
|
|
@@ -396,7 +401,7 @@ async function buildDelegateSession(
|
|
|
396
401
|
// Pass only the provider needed by this task. This keeps a non-Kilo task
|
|
397
402
|
// from receiving Kilo's provider/auth adapter merely because Kilo is also
|
|
398
403
|
// configured in the parent runtime.
|
|
399
|
-
const providerConfig = modelRegistry.getRegisteredProviderConfig?.(
|
|
404
|
+
const providerConfig = env.modelRegistry.getRegisteredProviderConfig?.(
|
|
400
405
|
task.model.provider,
|
|
401
406
|
);
|
|
402
407
|
const providerConfigs = providerConfig
|
|
@@ -407,6 +412,10 @@ async function buildDelegateSession(
|
|
|
407
412
|
systemPrompt: task.systemPrompt,
|
|
408
413
|
providerConfigs,
|
|
409
414
|
modelProvider: task.model.provider,
|
|
415
|
+
// Freeze the provider-extension allowlist to the dispatch-scoped snapshot
|
|
416
|
+
// so a later delegate.json edit cannot change which executable code an
|
|
417
|
+
// already-spawned async worker is allowed to load.
|
|
418
|
+
delegateConfig: env.config,
|
|
410
419
|
});
|
|
411
420
|
|
|
412
421
|
const { session } = await createAgentSession({
|
|
@@ -444,11 +453,16 @@ function checkoutPooledSession(
|
|
|
444
453
|
cwd: task.cwd,
|
|
445
454
|
thinking: task.thinking,
|
|
446
455
|
tools: task.tools,
|
|
456
|
+
providerExtensions: task.providerExtensionSources ?? "",
|
|
447
457
|
...task.reuseIntent,
|
|
448
458
|
});
|
|
449
459
|
if (co.status === "mismatch") {
|
|
450
460
|
const detail = co.mismatches
|
|
451
|
-
.map((m) =>
|
|
461
|
+
.map((m) =>
|
|
462
|
+
m.field === "providerExtensions"
|
|
463
|
+
? "providerExtensions: changed"
|
|
464
|
+
: `${m.field}: '${m.frozen}' vs '${m.requested}'`,
|
|
465
|
+
)
|
|
452
466
|
.join("; ");
|
|
453
467
|
return {
|
|
454
468
|
error: failTask(
|
|
@@ -529,7 +543,7 @@ async function resumeFromSessionFile(
|
|
|
529
543
|
};
|
|
530
544
|
}
|
|
531
545
|
|
|
532
|
-
const session = await buildDelegateSession(task, resumed, env
|
|
546
|
+
const session = await buildDelegateSession(task, resumed, env);
|
|
533
547
|
return {
|
|
534
548
|
session,
|
|
535
549
|
sessionManager: resumed,
|
|
@@ -544,7 +558,7 @@ async function createFreshSession(
|
|
|
544
558
|
): Promise<AcquireResult> {
|
|
545
559
|
let sessionManager: SessionManager;
|
|
546
560
|
let sessionFile: string | undefined;
|
|
547
|
-
if (task.workspace === "scratch") {
|
|
561
|
+
if (task.workspace === "scratch" || task.workspace === "isolated") {
|
|
548
562
|
// A discarded filesystem must not advertise a resumable conversation: a
|
|
549
563
|
// later resume would run against the source cwd and silently lose scratch
|
|
550
564
|
// isolation. Keep scratch transcripts in memory only.
|
|
@@ -560,11 +574,7 @@ async function createFreshSession(
|
|
|
560
574
|
sessionFile = fresh.file;
|
|
561
575
|
}
|
|
562
576
|
|
|
563
|
-
const session = await buildDelegateSession(
|
|
564
|
-
task,
|
|
565
|
-
sessionManager,
|
|
566
|
-
env.modelRegistry,
|
|
567
|
-
);
|
|
577
|
+
const session = await buildDelegateSession(task, sessionManager, env);
|
|
568
578
|
return {
|
|
569
579
|
session,
|
|
570
580
|
sessionManager,
|
|
@@ -635,6 +645,24 @@ async function runResolvedTaskUnlocked(
|
|
|
635
645
|
p: TaskProgress,
|
|
636
646
|
taskIndex: number,
|
|
637
647
|
): Promise<TaskResult> {
|
|
648
|
+
if (
|
|
649
|
+
task.workspace === "isolated" &&
|
|
650
|
+
(task.sessionId || task.resumeFrom || task.sessionAction)
|
|
651
|
+
) {
|
|
652
|
+
return recordTaskOutcome(
|
|
653
|
+
env,
|
|
654
|
+
p,
|
|
655
|
+
task,
|
|
656
|
+
finishTask(
|
|
657
|
+
env,
|
|
658
|
+
p,
|
|
659
|
+
failTask(
|
|
660
|
+
task,
|
|
661
|
+
"workspace 'isolated' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
|
|
662
|
+
),
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
638
666
|
if (task.workspace !== "scratch") {
|
|
639
667
|
const outcome = await runResolvedTaskCore(env, task, p, taskIndex);
|
|
640
668
|
return recordTaskOutcome(env, p, task, outcome);
|
|
@@ -944,6 +972,7 @@ async function settlePooledAttempt(
|
|
|
944
972
|
thinking: task.thinking,
|
|
945
973
|
tools: task.tools,
|
|
946
974
|
cwd: task.cwd,
|
|
975
|
+
providerExtensions: task.providerExtensionSources ?? "",
|
|
947
976
|
},
|
|
948
977
|
tokens: r.tokens,
|
|
949
978
|
});
|
|
@@ -1035,6 +1064,7 @@ async function runTaskAttempt(
|
|
|
1035
1064
|
gitBaseline,
|
|
1036
1065
|
timing.taskStartedAt,
|
|
1037
1066
|
timing.deadlineAt,
|
|
1067
|
+
env.config,
|
|
1038
1068
|
);
|
|
1039
1069
|
|
|
1040
1070
|
accounting.cumulativeTokens += r.tokens;
|
|
@@ -1159,8 +1189,12 @@ async function runWithWholeTaskRetries(
|
|
|
1159
1189
|
};
|
|
1160
1190
|
}
|
|
1161
1191
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1192
|
+
// An isolated worker is snapshotted once and reconciled once. Retrying the
|
|
1193
|
+
// whole conversation against a mutated proposal tree would make attribution
|
|
1194
|
+
// ambiguous, so v1 deliberately runs one attempt.
|
|
1195
|
+
const maxRetries =
|
|
1196
|
+
task.workspace === "isolated" ? 0 : resolvedWholeTaskMaxRetries(env);
|
|
1197
|
+
const baseDelayMs = resolvedWholeTaskBaseDelayMs(env);
|
|
1164
1198
|
let retries = 0;
|
|
1165
1199
|
for (
|
|
1166
1200
|
let retry = 0;
|
package/manual.ts
CHANGED
|
@@ -110,7 +110,7 @@ export function getSubagentManualMarkdown(
|
|
|
110
110
|
'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
|
|
111
111
|
"```",
|
|
112
112
|
"",
|
|
113
|
-
|
|
113
|
+
'Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. A batch with multiple write-capable tasks in one shared tree is rejected before dispatch; run them sequentially, use synchronous one-shot `workspace: "isolated"` for Git-backed ordered reconciliation, or use disposable scratch workspaces.',
|
|
114
114
|
"",
|
|
115
115
|
"The three handles have different lifetimes:",
|
|
116
116
|
"",
|
|
@@ -120,6 +120,8 @@ export function getSubagentManualMarkdown(
|
|
|
120
120
|
"",
|
|
121
121
|
"Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
|
|
122
122
|
"",
|
|
123
|
+
"Vocabulary, since 'agent' does double duty elsewhere: a **task** is one job; an **agent** is a named profile — a saved bundle of system prompt, tools, workspace, and model policy — that a task references by `agent` name; a **subagent** is the live session that executes one task. Agents are templates, not processes; nothing runs until a task dispatches.",
|
|
124
|
+
"",
|
|
123
125
|
"Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
|
|
124
126
|
"",
|
|
125
127
|
"## Touched Files (best-effort)",
|
|
@@ -136,13 +138,13 @@ export function getSubagentManualMarkdown(
|
|
|
136
138
|
"",
|
|
137
139
|
...builtinLines,
|
|
138
140
|
"",
|
|
139
|
-
"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`,
|
|
141
|
+
"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.",
|
|
140
142
|
"",
|
|
141
143
|
"## Available Custom Agents",
|
|
142
144
|
"",
|
|
143
145
|
agentList,
|
|
144
146
|
"",
|
|
145
|
-
"Custom agents are defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code).
|
|
147
|
+
"Custom agents are profiles, defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code). Inline definitions let the parent model shape the subagent it needs on each call. Each Markdown file is one profile with YAML frontmatter:",
|
|
146
148
|
"",
|
|
147
149
|
"```markdown",
|
|
148
150
|
"---",
|
|
@@ -165,26 +167,26 @@ export function getSubagentManualMarkdown(
|
|
|
165
167
|
"## Session Reuse",
|
|
166
168
|
"",
|
|
167
169
|
"When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
|
|
168
|
-
"Subsequent calls with the same `sessionId` continue the conversation — the
|
|
170
|
+
"Subsequent calls with the same `sessionId` continue the conversation — the session retains prior context.",
|
|
169
171
|
"",
|
|
170
172
|
"```ts",
|
|
171
173
|
"// First call — creates and runs an inline custom agent",
|
|
172
174
|
'delegate({ tasks: [{ prompt: "Investigate the auth module", systemPrompt: "You are a focused investigator. Map files and dependencies.", tools: ["read", "grep", "find", "ls"], sessionId: "auth-research" }] })',
|
|
173
175
|
"",
|
|
174
|
-
"// Second call — continues the same
|
|
176
|
+
"// Second call — continues the same subagent",
|
|
175
177
|
'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
|
|
176
178
|
"",
|
|
177
179
|
"// Clean up when done",
|
|
178
180
|
'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
|
|
179
181
|
"```",
|
|
180
182
|
"",
|
|
181
|
-
'Pooled
|
|
183
|
+
'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
|
|
182
184
|
"",
|
|
183
185
|
"## Resuming Previous Sessions",
|
|
184
186
|
"",
|
|
185
187
|
"Use `resumeFrom` to continue a failed or interrupted subagent from where it left off.",
|
|
186
188
|
"Pass the exact absolute path to the session `.jsonl` file copied from delegate retry output. Do not invent placeholder values or use it as a ticket ID; async resume is supported.",
|
|
187
|
-
"The
|
|
189
|
+
"The subagent gets the full conversation history and the new `prompt` continues naturally.",
|
|
188
190
|
"",
|
|
189
191
|
"```ts",
|
|
190
192
|
"// Copy this exact path from the failed delegate result; do not invent it.",
|
|
@@ -193,7 +195,7 @@ export function getSubagentManualMarkdown(
|
|
|
193
195
|
" resumeFrom: exactRetrySessionFile }] })",
|
|
194
196
|
"```",
|
|
195
197
|
"",
|
|
196
|
-
"Combine with `sessionId` to resume AND pool the
|
|
198
|
+
"Combine with `sessionId` to resume AND pool the subagent for further multi-turn use:",
|
|
197
199
|
"",
|
|
198
200
|
"```ts",
|
|
199
201
|
'delegate({ tasks: [{ prompt: "Continue the investigation",',
|
|
@@ -234,9 +236,11 @@ export function getSubagentManualMarkdown(
|
|
|
234
236
|
"## Gotchas",
|
|
235
237
|
"",
|
|
236
238
|
"- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
|
|
239
|
+
'- 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.',
|
|
237
240
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
238
241
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
239
242
|
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
|
|
243
|
+
"- 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.",
|
|
240
244
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
241
245
|
"- 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.",
|
|
242
246
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
@@ -244,7 +248,7 @@ export function getSubagentManualMarkdown(
|
|
|
244
248
|
"",
|
|
245
249
|
"## Config",
|
|
246
250
|
"",
|
|
247
|
-
"Tunables live in `~/.pi/agent/delegate.json
|
|
251
|
+
"Tunables live in `~/.pi/agent/delegate.json` (user scope, global — no project-level config): `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, legacy custom-agent model overrides (`agent`), and agent model/thinking/tools overrides — `agentOverrides` and `agentOverridesByParentModel` (exact `provider/model-id` key of the parent model; wins over `agentOverrides` on match). Config edits apply from the next delegate call.",
|
|
248
252
|
"The inactivity watchdog requests cooperative `AgentSession.abort()` cancellation and waits for the subagent to become idle; it is not a hard wall-clock execution deadline.",
|
|
249
253
|
"",
|
|
250
254
|
`Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions; the full output is always available in the expanded TUI view and the spilled file.`,
|
package/package.json
CHANGED
package/pool.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface FrozenConfig {
|
|
|
18
18
|
thinking: ThinkingLevel;
|
|
19
19
|
tools: string[];
|
|
20
20
|
cwd: string;
|
|
21
|
+
/** Stable signature of the provider-scoped extension allowlist this session
|
|
22
|
+
* was built with. A change in `delegate.json` providerExtensions must force
|
|
23
|
+
* session recreation so the old executable runtime is not silently reused. */
|
|
24
|
+
providerExtensions?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/** The subset a reuse request supplies for validation. `model` and
|
|
@@ -29,12 +33,20 @@ export interface ConfigCandidate {
|
|
|
29
33
|
tools: string[];
|
|
30
34
|
model?: Model<Api>;
|
|
31
35
|
systemPrompt?: string;
|
|
36
|
+
/** Provider-scoped extension allowlist signature for the current dispatch. */
|
|
37
|
+
providerExtensions?: string;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** One field-level diff from a reuse that conflicts with the frozen config. The
|
|
35
41
|
* pool computes these; the caller formats the error string. */
|
|
36
42
|
export interface ConfigMismatch {
|
|
37
|
-
field:
|
|
43
|
+
field:
|
|
44
|
+
| "cwd"
|
|
45
|
+
| "thinking"
|
|
46
|
+
| "tools"
|
|
47
|
+
| "model"
|
|
48
|
+
| "systemPrompt"
|
|
49
|
+
| "providerExtensions";
|
|
38
50
|
frozen: string;
|
|
39
51
|
requested: string;
|
|
40
52
|
}
|
|
@@ -189,6 +201,16 @@ export function checkout(
|
|
|
189
201
|
requested: "<requested>",
|
|
190
202
|
});
|
|
191
203
|
}
|
|
204
|
+
if (frozen.providerExtensions !== candidate.providerExtensions) {
|
|
205
|
+
// This field is derived from configured package sources, which may contain
|
|
206
|
+
// credentials. Never expose either the sources or their digest through the
|
|
207
|
+
// public checkout result; a digest can still enable dictionary guessing.
|
|
208
|
+
mismatches.push({
|
|
209
|
+
field: "providerExtensions",
|
|
210
|
+
frozen: "<redacted>",
|
|
211
|
+
requested: "<redacted>",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
192
214
|
if (mismatches.length) return { status: "mismatch", mismatches };
|
|
193
215
|
|
|
194
216
|
return {
|
package/provider-extensions.ts
CHANGED
|
@@ -35,7 +35,11 @@ import {
|
|
|
35
35
|
SettingsManager,
|
|
36
36
|
} from "@earendil-works/pi-coding-agent";
|
|
37
37
|
import { getSubagentProviderExtensionSourcesForProvider } from "./config.ts";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
canonicalPath,
|
|
40
|
+
isPathWithinDirectory,
|
|
41
|
+
isPathWithinDirectoryLexical,
|
|
42
|
+
} from "./trusted-paths.ts";
|
|
39
43
|
import {
|
|
40
44
|
parseGitOriginIdentity,
|
|
41
45
|
parsePackageSource,
|
|
@@ -331,8 +335,13 @@ export async function getProviderExtensionPaths(
|
|
|
331
335
|
provider: string | undefined,
|
|
332
336
|
cwd: string,
|
|
333
337
|
agentDir: string,
|
|
338
|
+
/** Dispatch-scoped snapshot for the provider-extension allowlist. */
|
|
339
|
+
config?: import("./config.ts").DelegateConfig,
|
|
334
340
|
): Promise<ProviderExtensionResolution> {
|
|
335
|
-
const requested = getSubagentProviderExtensionSourcesForProvider(
|
|
341
|
+
const requested = getSubagentProviderExtensionSourcesForProvider(
|
|
342
|
+
provider,
|
|
343
|
+
config,
|
|
344
|
+
);
|
|
336
345
|
if (!requested.length) {
|
|
337
346
|
return { paths: [], bestEffortPaths: new Set<string>() };
|
|
338
347
|
}
|
package/render-branches.ts
CHANGED
|
@@ -378,6 +378,36 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
378
378
|
if (!isLive && !isCancelledPending && r && "error" in r && r.error) {
|
|
379
379
|
lines.push(truncLine(`${ind}${theme.fg("error", r.error)}`, w));
|
|
380
380
|
}
|
|
381
|
+
if (r && "integration" in r && r.integration) {
|
|
382
|
+
const integration = r.integration;
|
|
383
|
+
const tone =
|
|
384
|
+
integration.status === "applied_unverified" ||
|
|
385
|
+
integration.status === "no_changes"
|
|
386
|
+
? "warning"
|
|
387
|
+
: "error";
|
|
388
|
+
lines.push(
|
|
389
|
+
truncLine(
|
|
390
|
+
`${ind}${theme.fg(tone, `integration: ${integration.status} · ${integration.appliedFiles.length}/${integration.proposedFiles.length} files applied`)}`,
|
|
391
|
+
w,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
if (expanded) {
|
|
395
|
+
if (integration.patchPath)
|
|
396
|
+
lines.push(
|
|
397
|
+
truncLine(
|
|
398
|
+
`${ind}${theme.fg("muted", `patch: ${integration.patchPath}`)}`,
|
|
399
|
+
w,
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
if (integration.worktreePath)
|
|
403
|
+
lines.push(
|
|
404
|
+
truncLine(
|
|
405
|
+
`${ind}${theme.fg("muted", `worktree: ${integration.worktreePath}`)}`,
|
|
406
|
+
w,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
381
411
|
// Collapsed: one-line output preview so a human scanning the TUI sees
|
|
382
412
|
// the payoff without expanding every task. Expanded mode renders the
|
|
383
413
|
// full markdown below instead.
|
package/render-result.ts
CHANGED
|
@@ -166,11 +166,14 @@ export function renderDelegateResult(
|
|
|
166
166
|
ticketStatus,
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
-
// Surface
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
// Surface batch-level warnings at the top of the TUI. The same text already
|
|
170
|
+
// lives in textual content, but the progress renderer ignores content.
|
|
171
|
+
if (details?.dispatchWarning) {
|
|
172
|
+
lines.push(
|
|
173
|
+
truncLine(theme.fg("warning", `⚠ ${details.dispatchWarning}`), w),
|
|
174
|
+
"",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
174
177
|
if (details?.overlapWarning) {
|
|
175
178
|
lines.push(
|
|
176
179
|
truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
|
package/runner.ts
CHANGED
|
@@ -72,6 +72,8 @@ export async function runAgentSession(
|
|
|
72
72
|
gitBaseline: Set<string> | undefined,
|
|
73
73
|
start: number,
|
|
74
74
|
deadlineAt?: number,
|
|
75
|
+
/** Dispatch-scoped delegate.json snapshot for the stall timeout. */
|
|
76
|
+
delegateConfig?: import("./config.ts").DelegateConfig,
|
|
75
77
|
): Promise<{
|
|
76
78
|
output: string;
|
|
77
79
|
error?: string;
|
|
@@ -89,7 +91,7 @@ export async function runAgentSession(
|
|
|
89
91
|
prompted: boolean;
|
|
90
92
|
}> {
|
|
91
93
|
const startTime = start ?? Date.now();
|
|
92
|
-
const stallTimeoutMs = getStallTimeoutMs();
|
|
94
|
+
const stallTimeoutMs = getStallTimeoutMs(delegateConfig);
|
|
93
95
|
let toolUses = 0;
|
|
94
96
|
let lastActivityAt: number | undefined = startTime;
|
|
95
97
|
let phase = "starting agent";
|
package/schema.ts
CHANGED
|
@@ -42,7 +42,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
42
42
|
agent: Type.Optional(
|
|
43
43
|
Type.String({
|
|
44
44
|
description:
|
|
45
|
-
"Built-ins: default, scout, coder, reviewer.
|
|
45
|
+
"Agent profile name. Built-ins: default, scout, coder, reviewer. Omit for ad-hoc.",
|
|
46
46
|
}),
|
|
47
47
|
),
|
|
48
48
|
cwd: Type.Optional(
|
|
@@ -77,7 +77,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
77
77
|
thinking: Type.Optional(
|
|
78
78
|
StringEnum(VALID_THINKING_LEVELS, {
|
|
79
79
|
description:
|
|
80
|
-
"
|
|
80
|
+
"off/minimal/low/medium/high/xhigh/max. Omit for agents — it overrides delegate.json tiers; default inherits.",
|
|
81
81
|
}),
|
|
82
82
|
),
|
|
83
83
|
sessionId: Type.Optional(
|
|
@@ -106,9 +106,9 @@ export const delegateTaskSchema = Type.Object({
|
|
|
106
106
|
}),
|
|
107
107
|
),
|
|
108
108
|
workspace: Type.Optional(
|
|
109
|
-
StringEnum(["shared", "scratch"], {
|
|
109
|
+
StringEnum(["shared", "scratch", "isolated"], {
|
|
110
110
|
description:
|
|
111
|
-
"shared
|
|
111
|
+
"shared; scratch discarded; isolated=sync one-shot Git apply; not security isolation. Reviewer=scratch.",
|
|
112
112
|
}),
|
|
113
113
|
),
|
|
114
114
|
});
|
|
@@ -116,47 +116,50 @@ export const delegateTaskSchema = Type.Object({
|
|
|
116
116
|
// Single source of truth for registration and generated help. The exported
|
|
117
117
|
// argument types in types.ts project this canonical schema; providers see
|
|
118
118
|
// only these fields.
|
|
119
|
-
export const delegateArgumentsSchema = Type.Object(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
Type.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Type.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Type.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
Type.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Type.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
119
|
+
export const delegateArgumentsSchema = Type.Object(
|
|
120
|
+
{
|
|
121
|
+
ticketAction: Type.Optional(
|
|
122
|
+
StringEnum(["poll", "cancel", "wait"], {
|
|
123
|
+
description:
|
|
124
|
+
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
async: Type.Optional(
|
|
128
|
+
Type.Boolean({
|
|
129
|
+
description:
|
|
130
|
+
"Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
|
|
131
|
+
default: false,
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
ticket: Type.Optional(
|
|
135
|
+
Type.String({
|
|
136
|
+
description: "Ticket ID; omit only when polling all tickets.",
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
force: Type.Optional(
|
|
140
|
+
Type.Boolean({
|
|
141
|
+
description:
|
|
142
|
+
"With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
|
|
143
|
+
default: false,
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
timeoutMs: Type.Optional(
|
|
147
|
+
Type.Number({
|
|
148
|
+
minimum: 0,
|
|
149
|
+
description:
|
|
150
|
+
"Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
tasks: Type.Optional(
|
|
154
|
+
Type.Array(delegateTaskSchema, {
|
|
155
|
+
minItems: 0,
|
|
156
|
+
description:
|
|
157
|
+
"Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{ additionalProperties: false },
|
|
162
|
+
);
|
|
160
163
|
|
|
161
164
|
/** Fields that belong to a task entry. Models sometimes place these at the top
|
|
162
165
|
* level of the arguments; the shim folds them back into a single task. */
|
|
@@ -238,6 +241,10 @@ export function validateDelegateOperation(
|
|
|
238
241
|
: undefined; // Intentional help request.
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
if (params.async && tasks.some((task) => task.workspace === "isolated")) {
|
|
245
|
+
return 'workspace "isolated" is synchronous; remove async.';
|
|
246
|
+
}
|
|
247
|
+
|
|
241
248
|
// Reject mixed shapes: flat task fields at the top level alongside a
|
|
242
249
|
// nonempty tasks array. The normalize shim only wraps flat fields when
|
|
243
250
|
// there is no tasks array, so a mixed call silently lets tasks win —
|
|
@@ -263,13 +270,14 @@ export function validateDelegateOperation(
|
|
|
263
270
|
(key) => !VALID_TASK_KEYS.has(key),
|
|
264
271
|
);
|
|
265
272
|
if (unknownKeys.length) {
|
|
266
|
-
const
|
|
267
|
-
|
|
273
|
+
const misplacedTopLevel = unknownKeys.filter((key) => key === "async");
|
|
274
|
+
const topLevelHint = misplacedTopLevel.length
|
|
275
|
+
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level flag${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
|
|
268
276
|
: "";
|
|
269
277
|
return (
|
|
270
278
|
`task ${index + 1}: unknown field(s) ${unknownKeys
|
|
271
279
|
.map((key) => `'${key}'`)
|
|
272
|
-
.join(", ")}.${
|
|
280
|
+
.join(", ")}.${topLevelHint} ` +
|
|
273
281
|
`Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
|
|
274
282
|
);
|
|
275
283
|
}
|
|
@@ -282,11 +290,10 @@ export function validateDelegateOperation(
|
|
|
282
290
|
return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
|
|
283
291
|
}
|
|
284
292
|
if (
|
|
285
|
-
task.workspace === "scratch" &&
|
|
286
|
-
!task.agent &&
|
|
293
|
+
(task.workspace === "scratch" || task.workspace === "isolated") &&
|
|
287
294
|
(task.sessionId || task.resumeFrom || sessionAction !== undefined)
|
|
288
295
|
) {
|
|
289
|
-
return `task ${index + 1}: workspace '
|
|
296
|
+
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
|
|
290
297
|
}
|
|
291
298
|
if (sessionAction === "close") {
|
|
292
299
|
if (!task.sessionId) {
|