@bermudi/pi-delegate 0.1.6 → 0.1.8
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 +14 -1
- package/agents.ts +54 -3
- package/constants.ts +17 -0
- package/delegate.ts +12 -1
- package/dispatch.ts +4 -2
- package/lifecycle.ts +3 -11
- package/manual.ts +9 -6
- package/migrate-delegate-sessions.ts +202 -0
- package/package.json +1 -1
- package/schema.ts +4 -4
- package/sessions.ts +9 -50
- package/settings.ts +235 -13
- package/task-resolution.ts +109 -52
- package/types.ts +7 -3
package/README.md
CHANGED
|
@@ -38,6 +38,19 @@ Parent extension/MCP tools are not copied, and project instructions are rebuilt
|
|
|
38
38
|
for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
|
|
39
39
|
normal inline defaults instead.
|
|
40
40
|
|
|
41
|
+
The other built-ins are:
|
|
42
|
+
|
|
43
|
+
- `scout` — read-only investigation with `read`, `grep`, `find`, and `ls`.
|
|
44
|
+
- `coder` — implementation and verification with `read`, `write`, `edit`, and
|
|
45
|
+
`bash` in the shared workspace.
|
|
46
|
+
- `reviewer` — review with `read` and `bash`, using a disposable scratch copy by
|
|
47
|
+
default. Set `workspace: "shared"` when a reviewer needs a persistent
|
|
48
|
+
`sessionId`.
|
|
49
|
+
|
|
50
|
+
Fresh built-ins inherit the parent's exact model object and thinking level.
|
|
51
|
+
Task-level overrides win; settings can provide unconditional overrides or exact
|
|
52
|
+
parent-model overrides under `delegate.agentOverridesByParentModel`.
|
|
53
|
+
|
|
41
54
|
### Disposable scratch workspace
|
|
42
55
|
|
|
43
56
|
For review, tests, or other commands whose project changes should be thrown
|
|
@@ -136,7 +149,7 @@ over an installed extension.
|
|
|
136
149
|
rare override. Markdown agents are examples of custom agents.
|
|
137
150
|
- **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
|
|
138
151
|
Markdown file in `.pi/agents/*.md` or `~/.pi/agent/agents/*.md`. The frontmatter
|
|
139
|
-
defines its name, description, model, tools, thinking level
|
|
152
|
+
defines its name, description, model, tools, and thinking level; the
|
|
140
153
|
Markdown body is its system prompt.
|
|
141
154
|
- **Ad-hoc subagent** — A subagent created from inline task fields instead of a
|
|
142
155
|
named Markdown agent profile. In current output this is labeled `ad-hoc`.
|
package/agents.ts
CHANGED
|
@@ -7,8 +7,13 @@ import type {
|
|
|
7
7
|
} from "@earendil-works/pi-agent-core";
|
|
8
8
|
import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import {
|
|
10
|
+
BUILTIN_AGENT_NAMES,
|
|
11
|
+
CODER_AGENT_NAME,
|
|
10
12
|
DEFAULT_AGENT_NAME,
|
|
11
13
|
DEFAULT_TOOLS,
|
|
14
|
+
READONLY_TOOLS,
|
|
15
|
+
REVIEWER_AGENT_NAME,
|
|
16
|
+
SCOUT_AGENT_NAME,
|
|
12
17
|
VALID_THINKING,
|
|
13
18
|
} from "./constants.ts";
|
|
14
19
|
import { resolveToolGroups } from "./tools.ts";
|
|
@@ -110,6 +115,50 @@ export function parseFrontmatter(
|
|
|
110
115
|
|
|
111
116
|
// ── Agent Discovery ───────────────────────────────────────────────────────
|
|
112
117
|
|
|
118
|
+
/** Built-in profiles are always available and cannot vary with Markdown files. */
|
|
119
|
+
export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
|
|
120
|
+
[DEFAULT_AGENT_NAME]: {
|
|
121
|
+
name: DEFAULT_AGENT_NAME,
|
|
122
|
+
description:
|
|
123
|
+
"Mirror the live parent model, thinking level, native tools, and base prompt.",
|
|
124
|
+
tools: DEFAULT_TOOLS,
|
|
125
|
+
systemPrompt: "",
|
|
126
|
+
builtin: true,
|
|
127
|
+
workspace: "shared",
|
|
128
|
+
},
|
|
129
|
+
[SCOUT_AGENT_NAME]: {
|
|
130
|
+
name: SCOUT_AGENT_NAME,
|
|
131
|
+
description: "Investigate without modifying the source project.",
|
|
132
|
+
tools: READONLY_TOOLS,
|
|
133
|
+
systemPrompt:
|
|
134
|
+
"Explore the codebase to answer the assigned question. Do not modify files. Trace relevant code, tests, documentation, and history when useful. Return concise findings with concrete paths, symbols, and any uncertainty. Prefer evidence over speculation.",
|
|
135
|
+
builtin: true,
|
|
136
|
+
workspace: "shared",
|
|
137
|
+
},
|
|
138
|
+
[CODER_AGENT_NAME]: {
|
|
139
|
+
name: CODER_AGENT_NAME,
|
|
140
|
+
description: "Implement and verify changes in the shared source tree.",
|
|
141
|
+
tools: DEFAULT_TOOLS,
|
|
142
|
+
systemPrompt:
|
|
143
|
+
"Implement the assigned change. Read the existing code and project instructions first. Prefer the smallest maintainable solution that follows existing conventions. Surface failures clearly. Run focused tests or checks and report what changed, what passed, and any remaining risk.",
|
|
144
|
+
builtin: true,
|
|
145
|
+
workspace: "shared",
|
|
146
|
+
},
|
|
147
|
+
[REVIEWER_AGENT_NAME]: {
|
|
148
|
+
name: REVIEWER_AGENT_NAME,
|
|
149
|
+
description: "Inspect the current snapshot and report actionable findings.",
|
|
150
|
+
tools: ["read", "bash"],
|
|
151
|
+
systemPrompt:
|
|
152
|
+
"Review the current snapshot for correctness, regressions, security problems, and missing tests. Do not modify the source project. Run focused checks when useful. Report actionable findings ordered by severity, with concrete paths and locations. If there are no material findings, say so plainly; do not invent issues or merely summarize the implementation.",
|
|
153
|
+
builtin: true,
|
|
154
|
+
workspace: "scratch",
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function isBuiltinAgentName(name: string): boolean {
|
|
159
|
+
return (BUILTIN_AGENT_NAMES as readonly string[]).includes(name);
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
/** Find the nearest ancestor containing project-scoped agent files. */
|
|
114
163
|
export function findProjectRoot(cwd: string): string | null {
|
|
115
164
|
let dir = cwd;
|
|
@@ -330,7 +379,9 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
330
379
|
scope: "claude",
|
|
331
380
|
});
|
|
332
381
|
|
|
333
|
-
const agents = new Map<string, AgentConfig>(
|
|
382
|
+
const agents = new Map<string, AgentConfig>(
|
|
383
|
+
Object.entries(BUILTIN_AGENT_CONFIGS),
|
|
384
|
+
);
|
|
334
385
|
const loadDir = (
|
|
335
386
|
{ dir, scope }: { dir: string; scope: AgentConfig["scope"] },
|
|
336
387
|
loader: (fp: string) => AgentConfig | null,
|
|
@@ -345,9 +396,9 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
345
396
|
if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
|
|
346
397
|
const filePath = path.join(dir, e.name);
|
|
347
398
|
const cfg = loader(filePath);
|
|
348
|
-
if (cfg
|
|
399
|
+
if (cfg && isBuiltinAgentName(cfg.name)) {
|
|
349
400
|
console.warn(
|
|
350
|
-
`[delegate] ignoring agent profile '${
|
|
401
|
+
`[delegate] ignoring agent profile '${cfg.name}' from ${filePath}: the name is reserved for a built-in delegate profile.`,
|
|
351
402
|
);
|
|
352
403
|
continue;
|
|
353
404
|
}
|
package/constants.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
/** Reserved built-in profile that mirrors the live parent configuration. */
|
|
2
2
|
export const DEFAULT_AGENT_NAME = "default";
|
|
3
3
|
|
|
4
|
+
/** Reserved built-in profile for read-only investigation. */
|
|
5
|
+
export const SCOUT_AGENT_NAME = "scout";
|
|
6
|
+
|
|
7
|
+
/** Reserved built-in profile for shared-workspace implementation work. */
|
|
8
|
+
export const CODER_AGENT_NAME = "coder";
|
|
9
|
+
|
|
10
|
+
/** Reserved built-in profile for isolated review work. */
|
|
11
|
+
export const REVIEWER_AGENT_NAME = "reviewer";
|
|
12
|
+
|
|
13
|
+
/** All names reserved by delegate's built-in profiles. */
|
|
14
|
+
export const BUILTIN_AGENT_NAMES = [
|
|
15
|
+
DEFAULT_AGENT_NAME,
|
|
16
|
+
SCOUT_AGENT_NAME,
|
|
17
|
+
CODER_AGENT_NAME,
|
|
18
|
+
REVIEWER_AGENT_NAME,
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
4
21
|
/** Full-capability agent set. Inline-task default and the `*` shorthand.
|
|
5
22
|
* Bash subsumes search, so the dedicated grep/find/ls tools are excluded. */
|
|
6
23
|
export const DEFAULT_TOOLS = ["read", "write", "edit", "bash"];
|
package/delegate.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type { DelegateConfig } from "./config.ts";
|
|
|
24
24
|
|
|
25
25
|
export {
|
|
26
26
|
DEFAULT_AGENT_NAME,
|
|
27
|
+
SCOUT_AGENT_NAME,
|
|
28
|
+
CODER_AGENT_NAME,
|
|
29
|
+
REVIEWER_AGENT_NAME,
|
|
30
|
+
BUILTIN_AGENT_NAMES,
|
|
27
31
|
DEFAULT_TOOLS,
|
|
28
32
|
READONLY_TOOLS,
|
|
29
33
|
MAX_CONCURRENCY,
|
|
@@ -123,6 +127,8 @@ export {
|
|
|
123
127
|
loadAgentFile,
|
|
124
128
|
loadClaudeAgentFile,
|
|
125
129
|
discoverAgents,
|
|
130
|
+
BUILTIN_AGENT_CONFIGS,
|
|
131
|
+
isBuiltinAgentName,
|
|
126
132
|
buildSubagentSystemPrompt,
|
|
127
133
|
DEFAULT_SUBAGENT_SYSTEM_PROMPT,
|
|
128
134
|
} from "./agents.ts";
|
|
@@ -133,7 +139,12 @@ export {
|
|
|
133
139
|
resolveModelRequest,
|
|
134
140
|
findAvailableAlternative,
|
|
135
141
|
} from "./model.ts";
|
|
136
|
-
export {
|
|
142
|
+
export {
|
|
143
|
+
readDelegateSettingsFile,
|
|
144
|
+
loadDelegateSettings,
|
|
145
|
+
clearDelegateSettingsCache,
|
|
146
|
+
} from "./settings.ts";
|
|
147
|
+
export type { AgentOverride } from "./settings.ts";
|
|
137
148
|
export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
|
|
138
149
|
export {
|
|
139
150
|
decideSpill,
|
package/dispatch.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { validateDelegateOperation } from "./schema.ts";
|
|
25
25
|
import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
|
|
26
26
|
import { validateTasks, resolveTasks } from "./task-resolution.ts";
|
|
27
|
+
import { clearDelegateSettingsCache } from "./settings.ts";
|
|
27
28
|
import type { CallSpan } from "./telemetry.ts";
|
|
28
29
|
import type {
|
|
29
30
|
AgentConfig,
|
|
@@ -146,6 +147,9 @@ export interface DelegateDispatchInput {
|
|
|
146
147
|
export async function dispatchDelegate(
|
|
147
148
|
input: DelegateDispatchInput,
|
|
148
149
|
): Promise<DelegateToolResult> {
|
|
150
|
+
// Settings are user-editable. Clear once at the dispatch boundary so every
|
|
151
|
+
// task in this batch observes one consistent settings snapshot.
|
|
152
|
+
clearDelegateSettingsCache();
|
|
149
153
|
const {
|
|
150
154
|
pi,
|
|
151
155
|
params,
|
|
@@ -293,7 +297,6 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
293
297
|
const asyncEnv: TaskRunEnv = {
|
|
294
298
|
signal: ticketSignal,
|
|
295
299
|
modelRegistry,
|
|
296
|
-
parentSessionManager: ctx.sessionManager,
|
|
297
300
|
ticketId,
|
|
298
301
|
delegateStartedAt: ticket.created,
|
|
299
302
|
telemetryCallId: callSpan?.id,
|
|
@@ -426,7 +429,6 @@ export async function dispatchSync(
|
|
|
426
429
|
const syncEnv: TaskRunEnv = {
|
|
427
430
|
signal,
|
|
428
431
|
modelRegistry: ctx.modelRegistry,
|
|
429
|
-
parentSessionManager: ctx.sessionManager,
|
|
430
432
|
ticketId: undefined,
|
|
431
433
|
delegateStartedAt: startedAt,
|
|
432
434
|
telemetryCallId: callSpan?.id,
|
package/lifecycle.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { isSessionBusy } from "./tickets.ts";
|
|
|
18
18
|
import {
|
|
19
19
|
createSubagentSessionManager,
|
|
20
20
|
persistSessionHeader,
|
|
21
|
-
setParentSession,
|
|
22
21
|
} from "./sessions.ts";
|
|
23
22
|
import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
|
|
24
23
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
@@ -329,8 +328,8 @@ async function sleepForWholeTaskRetry(
|
|
|
329
328
|
}
|
|
330
329
|
|
|
331
330
|
/** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
|
|
332
|
-
* Reuses the caller-supplied sessionManager (so
|
|
333
|
-
*
|
|
331
|
+
* Reuses the caller-supplied sessionManager (so per-task .jsonl files stay under
|
|
332
|
+
* our control). Extension-free host deps may be cached, while
|
|
334
333
|
* provider-configured or allowlisted-extension deps are session-local because
|
|
335
334
|
* Pi binds mutable extension callbacks onto each loader runtime. */
|
|
336
335
|
async function buildDelegateSession(
|
|
@@ -484,10 +483,6 @@ async function acquireAgentSession(
|
|
|
484
483
|
};
|
|
485
484
|
}
|
|
486
485
|
|
|
487
|
-
// Link resumed session to parent for /resume discoverability.
|
|
488
|
-
const parentFile = env.parentSessionManager?.getSessionFile?.();
|
|
489
|
-
if (parentFile) setParentSession(resumed, parentFile);
|
|
490
|
-
|
|
491
486
|
const session = await buildDelegateSession(
|
|
492
487
|
task,
|
|
493
488
|
resumed,
|
|
@@ -508,10 +503,7 @@ async function acquireAgentSession(
|
|
|
508
503
|
// isolation. Keep scratch transcripts in memory only.
|
|
509
504
|
sessionManager = SessionManager.inMemory(task.cwd);
|
|
510
505
|
} else {
|
|
511
|
-
const fresh = createSubagentSessionManager(
|
|
512
|
-
env.parentSessionManager,
|
|
513
|
-
task.cwd,
|
|
514
|
-
);
|
|
506
|
+
const fresh = createSubagentSessionManager(task.cwd);
|
|
515
507
|
if (!fresh) {
|
|
516
508
|
return {
|
|
517
509
|
error: failTask(task, "Internal: could not create session file"),
|
package/manual.ts
CHANGED
|
@@ -39,7 +39,7 @@ function schemaTable(properties: Record<string, TSchema>): string {
|
|
|
39
39
|
export function getSubagentManualMarkdown(
|
|
40
40
|
agents: Map<string, AgentConfig>,
|
|
41
41
|
): string {
|
|
42
|
-
const entries = [...agents];
|
|
42
|
+
const entries = [...agents].filter(([, a]) => !a.builtin);
|
|
43
43
|
const agentList = entries.length
|
|
44
44
|
? entries
|
|
45
45
|
.map(([n, a]) => {
|
|
@@ -95,11 +95,14 @@ export function getSubagentManualMarkdown(
|
|
|
95
95
|
"- Git failures degrade to an empty diff.",
|
|
96
96
|
"- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
|
|
97
97
|
"",
|
|
98
|
-
"## Built-in
|
|
98
|
+
"## Built-in Agents",
|
|
99
99
|
"",
|
|
100
|
-
"- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base
|
|
100
|
+
"- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base prompt. It uses the shared workspace.",
|
|
101
|
+
"- **scout**: investigates without modifying files. Tools: `read`, `grep`, `find`, `ls`. Shared workspace.",
|
|
102
|
+
"- **coder**: implements and verifies changes. Tools: `read`, `write`, `edit`, `bash`. Shared workspace.",
|
|
103
|
+
'- **reviewer**: reviews the current snapshot and reports findings. Tools: `read`, `bash`. Defaults to a disposable scratch workspace; set `workspace: "shared"` for a persistent reviewer with `sessionId`.',
|
|
101
104
|
"",
|
|
102
|
-
"Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context
|
|
105
|
+
"Fresh built-ins inherit the parent's exact model object and thinking level unless task fields or settings override 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.",
|
|
103
106
|
"",
|
|
104
107
|
"## Available Custom Agents",
|
|
105
108
|
"",
|
|
@@ -199,7 +202,7 @@ export function getSubagentManualMarkdown(
|
|
|
199
202
|
"- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
|
|
200
203
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
201
204
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
202
|
-
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt.
|
|
205
|
+
'- 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.',
|
|
203
206
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
204
207
|
"- 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.",
|
|
205
208
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
@@ -211,7 +214,7 @@ export function getSubagentManualMarkdown(
|
|
|
211
214
|
"",
|
|
212
215
|
"## Config",
|
|
213
216
|
"",
|
|
214
|
-
"Tunables live in `~/.pi/agent/delegate.json`: `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, and
|
|
217
|
+
"Tunables live in `~/.pi/agent/delegate.json`: `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, and legacy custom-agent model overrides. Built-in model/thinking/tools overrides live in `settings.json`; `agentOverridesByParentModel` uses an exact `provider/model-id` key and project settings take precedence.",
|
|
215
218
|
"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.",
|
|
216
219
|
"",
|
|
217
220
|
`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.`,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move old pi-delegate sessions out of Pi's normal session index.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally a standalone migration, not extension startup code:
|
|
5
|
+
* `pi -r` indexes sessions before extensions are loaded.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun run migrate-delegate-sessions.ts # report only
|
|
9
|
+
* bun run migrate-delegate-sessions.ts --apply # unlink and move
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
type JsonObject = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
const agentDir = getAgentDir();
|
|
18
|
+
const sourceDir = path.join(agentDir, "sessions");
|
|
19
|
+
const destinationDir = path.join(agentDir, "delegate-sessions");
|
|
20
|
+
const apply = process.argv.includes("--apply");
|
|
21
|
+
|
|
22
|
+
function isObject(value: unknown): value is JsonObject {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
27
|
+
const relative = path.relative(root, candidate);
|
|
28
|
+
return (
|
|
29
|
+
relative === "" ||
|
|
30
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
31
|
+
relative !== ".." &&
|
|
32
|
+
!path.isAbsolute(relative))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readSession(file: string): JsonObject[] | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
|
39
|
+
const entries: JsonObject[] = [];
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
const parsed: unknown = JSON.parse(line);
|
|
43
|
+
if (!isObject(parsed)) return undefined;
|
|
44
|
+
entries.push(parsed);
|
|
45
|
+
}
|
|
46
|
+
return entries;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readSessionHeader(file: string): JsonObject | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const firstLine = fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0];
|
|
55
|
+
const parsed: unknown = JSON.parse(firstLine);
|
|
56
|
+
return isObject(parsed) && parsed.type === "session" ? parsed : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sessionHeader(entries: JsonObject[]): JsonObject | undefined {
|
|
63
|
+
const header = entries[0];
|
|
64
|
+
return header?.type === "session" ? header : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entryKey(entry: JsonObject): string {
|
|
68
|
+
return JSON.stringify(entry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pi's forkFrom() copies every non-header entry from the source session before
|
|
73
|
+
* writing anything new. Old delegate sessions do not copy the parent history.
|
|
74
|
+
* This is the discriminator: parentSession by itself is deliberately not
|
|
75
|
+
* sufficient because genuine Pi forks also have it.
|
|
76
|
+
*/
|
|
77
|
+
function isPiFork(
|
|
78
|
+
childEntries: JsonObject[],
|
|
79
|
+
parentEntries: JsonObject[],
|
|
80
|
+
): boolean {
|
|
81
|
+
const childBody = childEntries.slice(1);
|
|
82
|
+
const parentBody = parentEntries.slice(1);
|
|
83
|
+
if (parentBody.length === 0 || childBody.length < parentBody.length) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return parentBody.every(
|
|
87
|
+
(entry, index) => entryKey(entry) === entryKey(childBody[index]),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findJsonlFiles(directory: string): string[] {
|
|
92
|
+
if (!fs.existsSync(directory)) return [];
|
|
93
|
+
const files: string[] = [];
|
|
94
|
+
const visit = (current: string): void => {
|
|
95
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
96
|
+
const candidate = path.join(current, entry.name);
|
|
97
|
+
if (entry.isDirectory()) visit(candidate);
|
|
98
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
99
|
+
files.push(candidate);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
visit(directory);
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface MigrationCandidate {
|
|
107
|
+
source: string;
|
|
108
|
+
destination: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidates: MigrationCandidate[] = [];
|
|
112
|
+
let skipped = 0;
|
|
113
|
+
const parentCache = new Map<string, JsonObject[] | undefined>();
|
|
114
|
+
|
|
115
|
+
for (const file of findJsonlFiles(sourceDir)) {
|
|
116
|
+
const header = readSessionHeader(file);
|
|
117
|
+
const parent = header?.parentSession;
|
|
118
|
+
if (!header || typeof parent !== "string") continue;
|
|
119
|
+
|
|
120
|
+
const parentPath = path.resolve(parent);
|
|
121
|
+
if (!isWithin(sourceDir, parentPath) || !fs.existsSync(parentPath)) {
|
|
122
|
+
skipped++;
|
|
123
|
+
console.warn(`skip (parent unavailable): ${file}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let parentEntries = parentCache.get(parentPath);
|
|
128
|
+
if (parentEntries === undefined && !parentCache.has(parentPath)) {
|
|
129
|
+
parentEntries = readSession(parentPath);
|
|
130
|
+
parentCache.set(parentPath, parentEntries);
|
|
131
|
+
}
|
|
132
|
+
const entries = readSession(file);
|
|
133
|
+
if (!entries) {
|
|
134
|
+
skipped++;
|
|
135
|
+
console.warn(`skip (invalid session): ${file}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!parentEntries || isPiFork(entries, parentEntries)) continue;
|
|
139
|
+
|
|
140
|
+
const relative = path.relative(sourceDir, file);
|
|
141
|
+
candidates.push({
|
|
142
|
+
source: file,
|
|
143
|
+
destination: path.join(destinationDir, relative),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
`${apply ? "Migrating" : "Found"} ${candidates.length} delegate session(s); ` +
|
|
149
|
+
`${skipped} skipped because their parent could not be verified.`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!apply) {
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
console.log(`would move: ${candidate.source} -> ${candidate.destination}`);
|
|
155
|
+
}
|
|
156
|
+
console.log("Nothing changed. Re-run with --apply to perform the migration.");
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let moved = 0;
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const entries = readSession(candidate.source);
|
|
163
|
+
const header = entries && sessionHeader(entries);
|
|
164
|
+
if (!entries || !header) {
|
|
165
|
+
console.warn(`skip (changed during migration): ${candidate.source}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
delete header.parentSession;
|
|
170
|
+
const parent = path.dirname(candidate.destination);
|
|
171
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const temporary = `${candidate.source}.delegate-migration-${process.pid}.tmp`;
|
|
174
|
+
try {
|
|
175
|
+
if (fs.existsSync(candidate.destination)) {
|
|
176
|
+
throw new Error("destination already exists");
|
|
177
|
+
}
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
temporary,
|
|
180
|
+
`${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
|
|
181
|
+
{ flag: "wx", mode: 0o600 },
|
|
182
|
+
);
|
|
183
|
+
fs.renameSync(temporary, candidate.source);
|
|
184
|
+
fs.renameSync(candidate.source, candidate.destination);
|
|
185
|
+
console.log(`moved: ${candidate.source} -> ${candidate.destination}`);
|
|
186
|
+
moved++;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
190
|
+
} catch {
|
|
191
|
+
// Preserve the original error below; the temp file is harmless and
|
|
192
|
+
// uniquely named for this process.
|
|
193
|
+
}
|
|
194
|
+
console.error(
|
|
195
|
+
`failed: ${candidate.source}: ${
|
|
196
|
+
error instanceof Error ? error.message : String(error)
|
|
197
|
+
}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(`Moved ${moved}/${candidates.length} delegate session(s).`);
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
40
40
|
agent: Type.Optional(
|
|
41
41
|
Type.String({
|
|
42
42
|
description:
|
|
43
|
-
"
|
|
43
|
+
"Built-ins: default, scout, coder, reviewer. Reviewer defaults to one-shot scratch. Omit for ad-hoc.",
|
|
44
44
|
}),
|
|
45
45
|
),
|
|
46
46
|
cwd: Type.Optional(
|
|
@@ -106,8 +106,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
106
106
|
workspace: Type.Optional(
|
|
107
107
|
StringEnum(["shared", "scratch"], {
|
|
108
108
|
description:
|
|
109
|
-
"
|
|
110
|
-
default: "shared",
|
|
109
|
+
"shared source; scratch disposable copy, one-shot; not security isolation. Reviewer=scratch; others=shared.",
|
|
111
110
|
}),
|
|
112
111
|
),
|
|
113
112
|
});
|
|
@@ -327,9 +326,10 @@ export function validateDelegateOperation(
|
|
|
327
326
|
}
|
|
328
327
|
if (
|
|
329
328
|
task.workspace === "scratch" &&
|
|
329
|
+
!task.agent &&
|
|
330
330
|
(task.sessionId || task.resumeFrom || sessionAction !== undefined)
|
|
331
331
|
) {
|
|
332
|
-
return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.`;
|
|
332
|
+
return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
|
|
333
333
|
}
|
|
334
334
|
if (sessionAction === "close") {
|
|
335
335
|
if (!task.sessionId) {
|
package/sessions.ts
CHANGED
|
@@ -1,67 +1,26 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
fileEntries: Array<{ type: string; parentSession?: string }>;
|
|
8
|
-
getSessionFile?: () => string | undefined;
|
|
9
|
-
_rewriteFile?: () => void;
|
|
10
|
-
};
|
|
11
|
-
const header = inner.fileEntries[0];
|
|
12
|
-
if (header && header.type === "session") {
|
|
13
|
-
header.parentSession = parentPath;
|
|
14
|
-
// For a *resumed* session the file already exists on disk and the manager
|
|
15
|
-
// is flushed (SessionManager.open/setSessionFile sets flushed=true). The
|
|
16
|
-
// in-memory header mutation above is otherwise lost: upstream _persist()
|
|
17
|
-
// only *appends* new entries once flushed — it never rewrites the header.
|
|
18
|
-
// So a resumeFrom session would never surface as a child in /resume despite
|
|
19
|
-
// the link being set in memory. Rewrite the whole file (header + entries)
|
|
20
|
-
// so the parentSession field is actually persisted. Fresh sessions skip
|
|
21
|
-
// this (file doesn't exist yet); their first _persist() writes the mutated
|
|
22
|
-
// header along with the rest, and rewriting early would trip the
|
|
23
|
-
// duplicate-header bug in _persist()'s not-yet-flushed path.
|
|
24
|
-
const file = inner.getSessionFile?.();
|
|
25
|
-
if (file && fs.existsSync(file)) {
|
|
26
|
-
try {
|
|
27
|
-
inner._rewriteFile?.();
|
|
28
|
-
} catch {
|
|
29
|
-
/* best effort — link stays in-memory; not fatal */
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
5
|
+
/** Persistent storage for delegate-only conversations. */
|
|
6
|
+
export function getDelegateSessionDir(): string {
|
|
7
|
+
return join(getAgentDir(), "delegate-sessions");
|
|
33
8
|
}
|
|
34
9
|
|
|
35
10
|
/**
|
|
36
11
|
* Create a session manager for a subagent run.
|
|
37
12
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* as a child of the parent session in `/resume`.
|
|
41
|
-
*
|
|
42
|
-
* Returns the concrete `SessionManager` (ready to hand to `createAgentSession`)
|
|
43
|
-
* and its file path (for result reporting + pool bookkeeping).
|
|
13
|
+
* Delegate sessions are deliberately standalone and live in their own
|
|
14
|
+
* directory. They are not attached to the parent's session tree.
|
|
44
15
|
*/
|
|
45
16
|
export function createSubagentSessionManager(
|
|
46
|
-
parentSessionManager: unknown,
|
|
47
17
|
cwd: string,
|
|
48
18
|
): { manager: SessionManager; file: string } | undefined {
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
parentSessionManager as
|
|
52
|
-
{ getSessionFile?(): string | undefined } | undefined
|
|
53
|
-
)?.getSessionFile?.();
|
|
54
|
-
|
|
55
|
-
// Always persist subagent work so the main agent can search it later.
|
|
56
|
-
const sm = SessionManager.create(cwd);
|
|
19
|
+
// Always persist subagent work separately from the parent's session tree.
|
|
20
|
+
const sm = SessionManager.create(cwd, getDelegateSessionDir());
|
|
57
21
|
const sessionFile = sm.getSessionFile();
|
|
58
22
|
if (!sessionFile) return undefined;
|
|
59
23
|
|
|
60
|
-
// Link to parent session so subagent appears as a child in /resume.
|
|
61
|
-
if (parentFile) {
|
|
62
|
-
setParentSession(sm, parentFile);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
24
|
return { manager: sm, file: sessionFile };
|
|
66
25
|
}
|
|
67
26
|
|
package/settings.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
5
|
+
import { VALID_THINKING } from "./constants.ts";
|
|
6
|
+
|
|
7
|
+
export interface AgentOverride {
|
|
8
|
+
model?: string;
|
|
9
|
+
thinking?: ThinkingLevel;
|
|
10
|
+
tools?: string[];
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
export interface DelegateSettings {
|
|
6
|
-
agentOverrides?: Record<
|
|
7
|
-
|
|
8
|
-
{ model?: string; thinking?: string; tools?: string[]; skills?: string[] }
|
|
9
|
-
>;
|
|
14
|
+
agentOverrides?: Record<string, AgentOverride>;
|
|
15
|
+
agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/** Read and validate a JSON settings object, returning null on I/O or parse errors. */
|
|
@@ -16,29 +22,192 @@ export function readDelegateSettingsFile(
|
|
|
16
22
|
try {
|
|
17
23
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
18
24
|
const parsed = JSON.parse(raw);
|
|
19
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
25
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
26
|
+
console.warn(
|
|
27
|
+
`[delegate] ignoring malformed settings file ${filePath}: expected a JSON object.`,
|
|
28
|
+
);
|
|
20
29
|
return null;
|
|
30
|
+
}
|
|
21
31
|
return parsed as Record<string, unknown>;
|
|
22
|
-
} catch {
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (
|
|
34
|
+
error instanceof Error &&
|
|
35
|
+
"code" in error &&
|
|
36
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
console.warn(
|
|
41
|
+
`[delegate] could not read settings file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
42
|
+
);
|
|
23
43
|
return null;
|
|
24
44
|
}
|
|
25
45
|
}
|
|
26
46
|
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeOverride(
|
|
52
|
+
raw: unknown,
|
|
53
|
+
source: string,
|
|
54
|
+
agentName: string,
|
|
55
|
+
): AgentOverride | null {
|
|
56
|
+
if (!isRecord(raw)) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: expected an object.`,
|
|
59
|
+
);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result: AgentOverride = {};
|
|
64
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
65
|
+
if (key === "model") {
|
|
66
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: model must be a nonempty string.`,
|
|
69
|
+
);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
result.model = value.trim();
|
|
73
|
+
} else if (key === "thinking") {
|
|
74
|
+
if (typeof value !== "string" || !VALID_THINKING.has(value)) {
|
|
75
|
+
console.warn(
|
|
76
|
+
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: thinking must be a supported level.`,
|
|
77
|
+
);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
result.thinking = value as ThinkingLevel;
|
|
81
|
+
} else if (key === "tools") {
|
|
82
|
+
if (
|
|
83
|
+
!Array.isArray(value) ||
|
|
84
|
+
value.some((tool) => typeof tool !== "string")
|
|
85
|
+
) {
|
|
86
|
+
console.warn(
|
|
87
|
+
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: tools must be a string array.`,
|
|
88
|
+
);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
result.tools = [...value];
|
|
92
|
+
} else if (key === "skills") {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[delegate] ignoring unsupported skills override for agent '${agentName}' in ${source}: per-agent skill filtering is not supported.`,
|
|
95
|
+
);
|
|
96
|
+
return null;
|
|
97
|
+
} else {
|
|
98
|
+
console.warn(
|
|
99
|
+
`[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: unknown field '${key}'.`,
|
|
100
|
+
);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeOverrides(
|
|
108
|
+
raw: unknown,
|
|
109
|
+
source: string,
|
|
110
|
+
): Record<string, AgentOverride> {
|
|
111
|
+
if (!isRecord(raw)) {
|
|
112
|
+
console.warn(
|
|
113
|
+
`[delegate] ignoring malformed agentOverrides in ${source}: expected an object.`,
|
|
114
|
+
);
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result: Record<string, AgentOverride> = {};
|
|
119
|
+
const seenNames = new Map<string, string>();
|
|
120
|
+
for (const [agentName, value] of Object.entries(raw)) {
|
|
121
|
+
const normalizedAgentName = agentName.trim();
|
|
122
|
+
if (normalizedAgentName.length === 0) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`[delegate] ignoring malformed settings override in ${source}: agent name must be nonempty.`,
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const previousName = seenNames.get(normalizedAgentName);
|
|
129
|
+
if (previousName !== undefined) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[delegate] ignoring duplicate settings override in ${source}: agent keys '${previousName}' and '${agentName}' both normalize to '${normalizedAgentName}'.`,
|
|
132
|
+
);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
seenNames.set(normalizedAgentName, agentName);
|
|
136
|
+
const override = normalizeOverride(value, source, normalizedAgentName);
|
|
137
|
+
if (override) result[normalizedAgentName] = override;
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeOverridesByParentModel(
|
|
143
|
+
raw: unknown,
|
|
144
|
+
source: string,
|
|
145
|
+
): Record<string, Record<string, AgentOverride>> {
|
|
146
|
+
if (!isRecord(raw)) {
|
|
147
|
+
console.warn(
|
|
148
|
+
`[delegate] ignoring malformed agentOverridesByParentModel in ${source}: expected an object.`,
|
|
149
|
+
);
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result: Record<string, Record<string, AgentOverride>> = {};
|
|
154
|
+
const seenModels = new Map<string, string>();
|
|
155
|
+
for (const [parentModel, overrides] of Object.entries(raw)) {
|
|
156
|
+
const normalizedParentModel = parentModel.trim();
|
|
157
|
+
if (normalizedParentModel.length === 0) {
|
|
158
|
+
console.warn(
|
|
159
|
+
`[delegate] ignoring malformed parent-model override in ${source}: model key must be nonempty.`,
|
|
160
|
+
);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const previousModel = seenModels.get(normalizedParentModel);
|
|
164
|
+
if (previousModel !== undefined) {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[delegate] ignoring duplicate parent-model override in ${source}: model keys '${previousModel}' and '${parentModel}' both normalize to '${normalizedParentModel}'.`,
|
|
167
|
+
);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
seenModels.set(normalizedParentModel, parentModel);
|
|
171
|
+
result[normalizedParentModel] = normalizeOverrides(
|
|
172
|
+
overrides,
|
|
173
|
+
`${source} (parent model '${normalizedParentModel}')`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
27
179
|
function getDelegateSettings(filePath: string): DelegateSettings | null {
|
|
28
180
|
const settings = readDelegateSettingsFile(filePath);
|
|
29
181
|
if (
|
|
30
182
|
!settings?.delegate ||
|
|
31
183
|
typeof settings.delegate !== "object" ||
|
|
32
184
|
Array.isArray(settings.delegate)
|
|
33
|
-
)
|
|
185
|
+
) {
|
|
186
|
+
if (settings?.delegate !== undefined) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`[delegate] ignoring malformed delegate settings in ${filePath}: expected an object.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
34
191
|
return null;
|
|
35
|
-
|
|
192
|
+
}
|
|
193
|
+
const raw = settings.delegate as Record<string, unknown>;
|
|
194
|
+
const result: DelegateSettings = {};
|
|
195
|
+
if (raw.agentOverrides !== undefined) {
|
|
196
|
+
result.agentOverrides = normalizeOverrides(raw.agentOverrides, filePath);
|
|
197
|
+
}
|
|
198
|
+
if (raw.agentOverridesByParentModel !== undefined) {
|
|
199
|
+
result.agentOverridesByParentModel = normalizeOverridesByParentModel(
|
|
200
|
+
raw.agentOverridesByParentModel,
|
|
201
|
+
filePath,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
36
205
|
}
|
|
37
206
|
|
|
38
207
|
const delegateSettingsCache = new Map<string, DelegateSettings | null>();
|
|
39
208
|
|
|
40
209
|
/** Load merged delegate settings: project overrides user.
|
|
41
|
-
* Result is cached per cwd
|
|
210
|
+
* Result is cached per cwd until the next delegate dispatch clears it. */
|
|
42
211
|
export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
43
212
|
const key = path.resolve(cwd);
|
|
44
213
|
const cached = delegateSettingsCache.get(key);
|
|
@@ -68,11 +237,64 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
|
|
|
68
237
|
return null;
|
|
69
238
|
}
|
|
70
239
|
const result: DelegateSettings = {
|
|
71
|
-
agentOverrides
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
240
|
+
...(user?.agentOverrides || project?.agentOverrides
|
|
241
|
+
? {
|
|
242
|
+
agentOverrides: mergeOverrides(
|
|
243
|
+
user?.agentOverrides,
|
|
244
|
+
project?.agentOverrides,
|
|
245
|
+
),
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
...(user?.agentOverridesByParentModel ||
|
|
249
|
+
project?.agentOverridesByParentModel
|
|
250
|
+
? {
|
|
251
|
+
agentOverridesByParentModel: mergeParentModelOverrides(
|
|
252
|
+
user?.agentOverridesByParentModel,
|
|
253
|
+
project?.agentOverridesByParentModel,
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
: {}),
|
|
75
257
|
};
|
|
76
258
|
delegateSettingsCache.set(key, result);
|
|
77
259
|
return result;
|
|
78
260
|
}
|
|
261
|
+
|
|
262
|
+
function mergeOverride(
|
|
263
|
+
base: AgentOverride | undefined,
|
|
264
|
+
override: AgentOverride | undefined,
|
|
265
|
+
): AgentOverride {
|
|
266
|
+
return { ...(base ?? {}), ...(override ?? {}) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function mergeOverrides(
|
|
270
|
+
user: Record<string, AgentOverride> | undefined,
|
|
271
|
+
project: Record<string, AgentOverride> | undefined,
|
|
272
|
+
): Record<string, AgentOverride> {
|
|
273
|
+
const result: Record<string, AgentOverride> = {};
|
|
274
|
+
for (const name of new Set([
|
|
275
|
+
...Object.keys(user ?? {}),
|
|
276
|
+
...Object.keys(project ?? {}),
|
|
277
|
+
])) {
|
|
278
|
+
result[name] = mergeOverride(user?.[name], project?.[name]);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function mergeParentModelOverrides(
|
|
284
|
+
user: Record<string, Record<string, AgentOverride>> | undefined,
|
|
285
|
+
project: Record<string, Record<string, AgentOverride>> | undefined,
|
|
286
|
+
): Record<string, Record<string, AgentOverride>> {
|
|
287
|
+
const result: Record<string, Record<string, AgentOverride>> = {};
|
|
288
|
+
for (const model of new Set([
|
|
289
|
+
...Object.keys(user ?? {}),
|
|
290
|
+
...Object.keys(project ?? {}),
|
|
291
|
+
])) {
|
|
292
|
+
result[model] = mergeOverrides(user?.[model], project?.[model]);
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Clear settings read from earlier delegate calls so edits are visible. */
|
|
298
|
+
export function clearDelegateSettingsCache(): void {
|
|
299
|
+
delegateSettingsCache.clear();
|
|
300
|
+
}
|
package/task-resolution.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import {
|
|
4
|
+
BUILTIN_AGENT_NAMES,
|
|
4
5
|
DEFAULT_AGENT_NAME,
|
|
5
6
|
DEFAULT_TOOLS,
|
|
6
7
|
VALID_THINKING,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
import { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
|
|
9
10
|
import { configFor } from "./pool.ts";
|
|
10
11
|
import { isSessionBusy } from "./tickets.ts";
|
|
11
|
-
import { buildSubagentSystemPrompt } from "./agents.ts";
|
|
12
|
+
import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
|
|
12
13
|
import { buildParentTranscript } from "./parent-context.ts";
|
|
13
14
|
import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
|
|
14
15
|
import { resolveModelSpec } from "./config.ts";
|
|
@@ -91,6 +92,52 @@ export function validateTasks(
|
|
|
91
92
|
agents: Map<string, AgentConfig>,
|
|
92
93
|
parentModelId: string | undefined,
|
|
93
94
|
): DelegateToolResult | null {
|
|
95
|
+
const unknown: string[] = [];
|
|
96
|
+
for (const task of tasks) {
|
|
97
|
+
if (
|
|
98
|
+
task.agent &&
|
|
99
|
+
!(BUILTIN_AGENT_NAMES as readonly string[]).includes(task.agent) &&
|
|
100
|
+
!agents.has(task.agent)
|
|
101
|
+
) {
|
|
102
|
+
unknown.push(task.agent);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (unknown.length) {
|
|
106
|
+
const names = [...new Set([...BUILTIN_AGENT_NAMES, ...agents.keys()])];
|
|
107
|
+
return noticeResult(
|
|
108
|
+
`Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
|
|
109
|
+
tasks,
|
|
110
|
+
parentModelId,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Scratch sessions are deliberately one-shot. This check uses the
|
|
115
|
+
// effective workspace, so reviewer gets the same protection even when the
|
|
116
|
+
// caller omits workspace. Explicit scratch is never silently promoted to
|
|
117
|
+
// shared.
|
|
118
|
+
for (const [index, task] of tasks.entries()) {
|
|
119
|
+
const agent = task.agent
|
|
120
|
+
? (agents.get(task.agent) ?? BUILTIN_AGENT_CONFIGS[task.agent])
|
|
121
|
+
: undefined;
|
|
122
|
+
const workspace = task.workspace ?? agent?.workspace ?? "shared";
|
|
123
|
+
const sessionAction = task.sessionAction ?? task.action;
|
|
124
|
+
if (
|
|
125
|
+
workspace === "scratch" &&
|
|
126
|
+
(task.sessionId || task.resumeFrom || sessionAction !== undefined)
|
|
127
|
+
) {
|
|
128
|
+
const defaultText =
|
|
129
|
+
task.workspace === undefined && agent?.workspace === "scratch"
|
|
130
|
+
? "defaults to workspace `scratch`"
|
|
131
|
+
: "uses workspace `scratch`";
|
|
132
|
+
const persistentAgent = task.agent ?? "agent";
|
|
133
|
+
return noticeResult(
|
|
134
|
+
`${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId\`, \`resumeFrom\`, or session actions. Set \`workspace: "shared"\` to use a persistent ${persistentAgent}.`,
|
|
135
|
+
tasks,
|
|
136
|
+
parentModelId,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
// Disallow same sessionId across multiple parallel tasks (one agent can't serve two prompts concurrently).
|
|
95
142
|
const sessionIds = tasks.map((t) => t.sessionId).filter(Boolean) as string[];
|
|
96
143
|
const duplicateSessions = sessionIds.filter(
|
|
@@ -136,21 +183,6 @@ export function validateTasks(
|
|
|
136
183
|
return noticeResult(duplicateIds.join(" "), tasks, parentModelId);
|
|
137
184
|
}
|
|
138
185
|
|
|
139
|
-
const unknown: string[] = [];
|
|
140
|
-
for (const t of tasks) {
|
|
141
|
-
if (t.agent && t.agent !== DEFAULT_AGENT_NAME && !agents.has(t.agent)) {
|
|
142
|
-
unknown.push(t.agent);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (unknown.length) {
|
|
146
|
-
const names = [DEFAULT_AGENT_NAME, ...agents.keys()];
|
|
147
|
-
return noticeResult(
|
|
148
|
-
`Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
|
|
149
|
-
tasks,
|
|
150
|
-
parentModelId,
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
186
|
return null;
|
|
155
187
|
}
|
|
156
188
|
|
|
@@ -187,11 +219,21 @@ export function resolveTasks(
|
|
|
187
219
|
|
|
188
220
|
return tasks.map((t, i) => {
|
|
189
221
|
const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
|
|
190
|
-
const agent = t.agent
|
|
222
|
+
const agent = t.agent
|
|
223
|
+
? (agents.get(t.agent) ?? BUILTIN_AGENT_CONFIGS[t.agent])
|
|
224
|
+
: undefined;
|
|
225
|
+
const isBuiltinAgent = agent?.builtin === true;
|
|
191
226
|
const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
|
|
192
227
|
|
|
193
228
|
// Load settings-based overrides for this agent
|
|
194
229
|
const settings = loadDelegateSettings(cwd);
|
|
230
|
+
const parentModelKey = ctx.model
|
|
231
|
+
? `${ctx.model.provider}/${ctx.model.id}`
|
|
232
|
+
: undefined;
|
|
233
|
+
const parentModelOverride =
|
|
234
|
+
t.agent && !isDefaultAgent && parentModelKey
|
|
235
|
+
? settings?.agentOverridesByParentModel?.[parentModelKey]?.[t.agent]
|
|
236
|
+
: undefined;
|
|
195
237
|
const agentOverride =
|
|
196
238
|
t.agent && !isDefaultAgent && settings?.agentOverrides?.[t.agent]
|
|
197
239
|
? settings.agentOverrides[t.agent]
|
|
@@ -208,7 +250,8 @@ export function resolveTasks(
|
|
|
208
250
|
);
|
|
209
251
|
let tools: string[] = [];
|
|
210
252
|
const warnings: string[] = [];
|
|
211
|
-
|
|
253
|
+
const workspace = t.workspace ?? agent?.workspace ?? "shared";
|
|
254
|
+
if (workspace === "scratch") {
|
|
212
255
|
warnings.push(
|
|
213
256
|
"Scratch workspace: relative file changes run in a disposable CoW copy and are discarded.",
|
|
214
257
|
);
|
|
@@ -233,9 +276,11 @@ export function resolveTasks(
|
|
|
233
276
|
if (t.sessionAction !== "close" && t.sessionAction !== "list") {
|
|
234
277
|
tools = resolveToolGroups(
|
|
235
278
|
t.tools ??
|
|
279
|
+
parentModelOverride?.tools ??
|
|
236
280
|
agentOverride?.tools ??
|
|
237
|
-
agent?.tools ??
|
|
238
281
|
(isDefaultAgent ? parentNativeTools : undefined) ??
|
|
282
|
+
(isBuiltinAgent ? agent?.tools : undefined) ??
|
|
283
|
+
agent?.tools ??
|
|
239
284
|
(isPoolHit ? pooledConfig?.tools : undefined) ??
|
|
240
285
|
DEFAULT_TOOLS,
|
|
241
286
|
);
|
|
@@ -318,20 +363,32 @@ export function resolveTasks(
|
|
|
318
363
|
let thinking: ThinkingLevel = "off";
|
|
319
364
|
|
|
320
365
|
if (t.sessionAction !== "close" && t.sessionAction !== "list") {
|
|
366
|
+
const agentType = t.agent ?? "inline";
|
|
367
|
+
// The built-in `default` profile bypasses delegate/settings model
|
|
368
|
+
// overrides for backwards compatibility. The other built-ins accept
|
|
369
|
+
// task and settings.json model overrides, but deliberately ignore the
|
|
370
|
+
// legacy delegate.json agent model map so they inherit the parent unless
|
|
371
|
+
// an explicit modern override wins.
|
|
372
|
+
const modelSpec = isDefaultAgent
|
|
373
|
+
? t.model
|
|
374
|
+
: isBuiltinAgent
|
|
375
|
+
? (t.model ?? parentModelOverride?.model ?? agentOverride?.model)
|
|
376
|
+
: resolveModelSpec({
|
|
377
|
+
taskModel:
|
|
378
|
+
t.model ?? parentModelOverride?.model ?? agentOverride?.model,
|
|
379
|
+
agentType,
|
|
380
|
+
frontmatterModel: agent?.model,
|
|
381
|
+
});
|
|
382
|
+
|
|
321
383
|
// A pool hit always runs its frozen model, but an explicitly requested
|
|
322
384
|
// task/profile model still has to be resolved so checkout can reject a
|
|
323
385
|
// contradictory request rather than silently discarding it. Naming the
|
|
324
386
|
// built-in `default` profile is also explicit: it requests the live
|
|
325
387
|
// parent model, so reuse fails clearly if the pool was frozen differently.
|
|
326
388
|
if (pooledConfig) {
|
|
327
|
-
|
|
328
|
-
t.model ??
|
|
329
|
-
(t.agent && !isDefaultAgent
|
|
330
|
-
? (agentOverride?.model ?? agent?.model)
|
|
331
|
-
: undefined);
|
|
332
|
-
if (requestedModelSpec) {
|
|
389
|
+
if (modelSpec) {
|
|
333
390
|
const requested = resolveModelRequest(
|
|
334
|
-
|
|
391
|
+
modelSpec,
|
|
335
392
|
ctx.modelRegistry,
|
|
336
393
|
ctx.model,
|
|
337
394
|
);
|
|
@@ -339,7 +396,7 @@ export function resolveTasks(
|
|
|
339
396
|
modelSuffix = requested.strippedSuffix;
|
|
340
397
|
if (!requestedModel) {
|
|
341
398
|
throw new Error(
|
|
342
|
-
`${formatTaskRef(i, t.id)}: requested model '${
|
|
399
|
+
`${formatTaskRef(i, t.id)}: requested model '${modelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
|
|
343
400
|
);
|
|
344
401
|
}
|
|
345
402
|
} else if (isDefaultAgent) {
|
|
@@ -347,17 +404,6 @@ export function resolveTasks(
|
|
|
347
404
|
}
|
|
348
405
|
model = pooledConfig.model;
|
|
349
406
|
} else {
|
|
350
|
-
// The built-in `default` profile bypasses delegate.json and settings:
|
|
351
|
-
// absent a task override, it means this exact live parent Model object.
|
|
352
|
-
// Other tasks retain the normal task > config > frontmatter chain.
|
|
353
|
-
const agentType = t.agent ?? "inline";
|
|
354
|
-
const modelSpec = isDefaultAgent
|
|
355
|
-
? t.model
|
|
356
|
-
: resolveModelSpec({
|
|
357
|
-
taskModel: t.model ?? agentOverride?.model,
|
|
358
|
-
agentType,
|
|
359
|
-
frontmatterModel: agent?.model,
|
|
360
|
-
});
|
|
361
407
|
const resolvedRequest = modelSpec
|
|
362
408
|
? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
|
|
363
409
|
: undefined;
|
|
@@ -376,7 +422,7 @@ export function resolveTasks(
|
|
|
376
422
|
);
|
|
377
423
|
}
|
|
378
424
|
|
|
379
|
-
model =
|
|
425
|
+
model = isBuiltinAgent
|
|
380
426
|
? (resolvedModel ?? ctx.model)
|
|
381
427
|
: (resolvedModel ??
|
|
382
428
|
findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
|
|
@@ -398,15 +444,28 @@ export function resolveTasks(
|
|
|
398
444
|
// changed, rather than silently reusing a stale frozen value. The final
|
|
399
445
|
// pooled fallback is reachable only when parentDefaults.thinking is
|
|
400
446
|
// undefined (headless parent without a thinking level).
|
|
401
|
-
const thinkingRaw =
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
447
|
+
const thinkingRaw = isBuiltinAgent
|
|
448
|
+
? isDefaultAgent
|
|
449
|
+
? (t.thinking ??
|
|
450
|
+
parentModelOverride?.thinking ??
|
|
451
|
+
agentOverride?.thinking ??
|
|
452
|
+
modelSuffix ??
|
|
453
|
+
parentDefaults.thinking ??
|
|
454
|
+
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
455
|
+
"off")
|
|
456
|
+
: (t.thinking ??
|
|
457
|
+
parentModelOverride?.thinking ??
|
|
458
|
+
agentOverride?.thinking ??
|
|
459
|
+
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
460
|
+
modelSuffix ??
|
|
461
|
+
parentDefaults.thinking ??
|
|
462
|
+
"off")
|
|
463
|
+
: (t.thinking ??
|
|
464
|
+
agentOverride?.thinking ??
|
|
465
|
+
agent?.thinking ??
|
|
466
|
+
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
467
|
+
modelSuffix ??
|
|
468
|
+
"off");
|
|
410
469
|
thinking = VALID_THINKING.has(thinkingRaw)
|
|
411
470
|
? (thinkingRaw as ThinkingLevel)
|
|
412
471
|
: "off";
|
|
@@ -423,7 +482,7 @@ export function resolveTasks(
|
|
|
423
482
|
...t,
|
|
424
483
|
id: t.id,
|
|
425
484
|
cwd,
|
|
426
|
-
workspace
|
|
485
|
+
workspace,
|
|
427
486
|
systemPrompt,
|
|
428
487
|
model: model!,
|
|
429
488
|
tools,
|
|
@@ -433,9 +492,7 @@ export function resolveTasks(
|
|
|
433
492
|
prompt: prompt ?? "",
|
|
434
493
|
// Keep the built-in selector visible in progress/results. Omitted-agent
|
|
435
494
|
// inline tasks retain the established `ad-hoc` label and config namespace.
|
|
436
|
-
agentName:
|
|
437
|
-
? DEFAULT_AGENT_NAME
|
|
438
|
-
: (agent?.name ?? "ad-hoc"),
|
|
495
|
+
agentName: agent?.name ?? "ad-hoc",
|
|
439
496
|
warnings,
|
|
440
497
|
reuseIntent: {
|
|
441
498
|
model: requestedModel,
|
package/types.ts
CHANGED
|
@@ -18,9 +18,15 @@ export interface AgentConfig {
|
|
|
18
18
|
name: string;
|
|
19
19
|
description: string;
|
|
20
20
|
model?: string;
|
|
21
|
-
|
|
21
|
+
/** Markdown agents default invalid/omitted values to "off". Built-ins omit
|
|
22
|
+
* this field so they can inherit the parent's thinking level. */
|
|
23
|
+
thinking?: ThinkingLevel;
|
|
22
24
|
tools: string[];
|
|
23
25
|
systemPrompt: string;
|
|
26
|
+
/** Built-in profiles are immutable and cannot be shadowed by Markdown. */
|
|
27
|
+
builtin?: boolean;
|
|
28
|
+
/** Default workspace for a built-in profile. Custom agents use shared. */
|
|
29
|
+
workspace?: WorkspaceMode;
|
|
24
30
|
/** Origin of the profile. `claude` denotes imported .claude/agents files. */
|
|
25
31
|
scope?: "project" | "global" | "claude";
|
|
26
32
|
}
|
|
@@ -251,8 +257,6 @@ export interface TaskRunEnv {
|
|
|
251
257
|
/** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
|
|
252
258
|
signal: AbortSignal | undefined;
|
|
253
259
|
modelRegistry: ModelRegistry;
|
|
254
|
-
/** Parent session manager — used to link subagent sessions for /resume. */
|
|
255
|
-
parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
|
|
256
260
|
/** Ticket id for busy-guard self-checks. undefined for sync. */
|
|
257
261
|
ticketId?: string;
|
|
258
262
|
/** When the delegate started. Used for close/list progress (elapsed time). */
|