@bermudi/pi-delegate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manual.ts ADDED
@@ -0,0 +1,184 @@
1
+ import {
2
+ DEFAULT_TOOLS,
3
+ OUTPUT_SPILL_THRESHOLD_CHARS,
4
+ OUTPUT_SPILL_TAIL_CHARS,
5
+ } from "./constants.ts";
6
+ import { getMaxAsyncTickets, getMaxConcurrent } from "./config.ts";
7
+ import type { TSchema } from "@sinclair/typebox";
8
+ import { delegateArgumentsSchema, delegateTaskSchema } from "./schema.ts";
9
+ import type { AgentConfig } from "./types.ts";
10
+
11
+ function schemaType(schema: TSchema): string {
12
+ if (Array.isArray(schema.enum)) {
13
+ return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
14
+ }
15
+ if (schema.type === "array") return `${schemaType(schema.items)}[]`;
16
+ return typeof schema.type === "string" ? schema.type : "unknown";
17
+ }
18
+
19
+ function markdownCell(value: string): string {
20
+ return value.replaceAll("|", "\\|").replaceAll("\n", " ");
21
+ }
22
+
23
+ function schemaTable(properties: Record<string, TSchema>): string {
24
+ const rows = Object.entries(properties).map(([name, schema]) => {
25
+ const type = markdownCell(schemaType(schema));
26
+ const defaultValue =
27
+ "default" in schema ? JSON.stringify(schema.default) : "—";
28
+ const description = markdownCell(schema.description ?? "");
29
+ return `| \`${name}\` | \`${type}\` | \`${defaultValue}\` | ${description} |`;
30
+ });
31
+ return [
32
+ "| Field | Type | Default | Description |",
33
+ "| --- | --- | --- | --- |",
34
+ ...rows,
35
+ ].join("\n");
36
+ }
37
+
38
+ /** Render the current schema and discovered agents as model-facing help. */
39
+ export function getSubagentManualMarkdown(
40
+ agents: Map<string, AgentConfig>,
41
+ ): string {
42
+ const entries = [...agents];
43
+ const agentList = entries.length
44
+ ? entries
45
+ .map(([n, a]) => {
46
+ const model = a.model ? ` (model: ${a.model})` : "";
47
+ const thinking =
48
+ a.thinking !== "off" ? ` [thinking: ${a.thinking}]` : "";
49
+ const tools =
50
+ a.tools.length !== DEFAULT_TOOLS.length ||
51
+ a.tools.some((t, i) => t !== DEFAULT_TOOLS[i])
52
+ ? ` tools: ${a.tools.join(", ")}`
53
+ : "";
54
+ const scope =
55
+ a.scope === "project"
56
+ ? " [project]"
57
+ : a.scope === "global"
58
+ ? " [global]"
59
+ : a.scope === "claude"
60
+ ? " [claude]"
61
+ : "";
62
+ return `- **${n}**${model}${thinking}${tools}${scope}: ${a.description}`;
63
+ })
64
+ .join("\n")
65
+ : "_(none defined)_";
66
+
67
+ return [
68
+ "# Delegate Tool Manual",
69
+ "",
70
+ "**Why you're seeing this:** no tasks were provided, so the tool returned help instead of dispatching. Nothing is broken. To dispatch subagents, put task fields inside `tasks: [{ ... }]`.",
71
+ "",
72
+ "```ts",
73
+ 'delegate({ tasks: [{ prompt: "Investigate the auth module" }] })',
74
+ "```",
75
+ "",
76
+ "Delegate subagents to execute tasks in parallel. Each subagent gets an independent context, system prompt, model, tools, and thinking level. Custom agents can be defined inline in a task or persisted as Markdown files.",
77
+ "",
78
+ "## Available Custom Agents",
79
+ "",
80
+ agentList,
81
+ "",
82
+ "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). Markdown agents are examples of custom agents — the parent model can shape the subagent it needs on each call. Each Markdown file is an agent with YAML frontmatter:",
83
+ "",
84
+ "```markdown",
85
+ "---",
86
+ "name: my-agent",
87
+ "description: What it does",
88
+ "thinking: low # off/minimal/low/medium/high/xhigh/max",
89
+ "tools: * # * = full agent. ro = read-only. Omit to inherit *.",
90
+ "---",
91
+ "You are a helpful agent...",
92
+ "```",
93
+ "",
94
+ "## Task Fields",
95
+ "",
96
+ schemaTable(delegateTaskSchema.properties),
97
+ "",
98
+ "## Top-level Fields",
99
+ "",
100
+ schemaTable(delegateArgumentsSchema.properties),
101
+ "",
102
+ "## Session Reuse",
103
+ "",
104
+ "When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
105
+ "Subsequent calls with the same `sessionId` continue the conversation — the agent remembers prior context.",
106
+ "",
107
+ "```ts",
108
+ "// First call — creates and runs an inline custom agent",
109
+ '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" }] })',
110
+ "",
111
+ "// Second call — continues the same agent",
112
+ 'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
113
+ "",
114
+ "// Clean up when done",
115
+ 'delegate({ tasks: [{ sessionId: "auth-research", action: "close" }] })',
116
+ "```",
117
+ "",
118
+ 'Pooled agents remain live until `action: "close"` or parent Pi session shutdown.',
119
+ "",
120
+ "## Resuming Previous Sessions",
121
+ "",
122
+ "Use `resumeFrom` to continue a failed or interrupted subagent from where it left off.",
123
+ "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.",
124
+ "The agent gets the full conversation history and the new `prompt` continues naturally.",
125
+ "",
126
+ "```ts",
127
+ "// Copy this exact path from the failed delegate result; do not invent it.",
128
+ "const exactRetrySessionFile = failedTaskResult.sessionFile;",
129
+ 'delegate({ tasks: [{ prompt: "Continue testing — the server is already running on :3000",',
130
+ " resumeFrom: exactRetrySessionFile }] })",
131
+ "```",
132
+ "",
133
+ "Combine with `sessionId` to resume AND pool the agent for further multi-turn use:",
134
+ "",
135
+ "```ts",
136
+ 'delegate({ tasks: [{ prompt: "Continue the investigation",',
137
+ " resumeFrom: exactRetrySessionFile,",
138
+ ' sessionId: "my-resumed-agent" }] })',
139
+ "```",
140
+ "",
141
+ "## Resuming on a Different Model",
142
+ "",
143
+ "A failed subagent's conversation can be resumed on a **different model** — useful when the original model hit an account usage limit, quota, or auth error (delegate tags these `model_error` and won't waste same-model retries on them). `createAgentSession` honors an explicit `model` over the session's stored model, so the resumed subagent keeps its full conversation history but runs the next turn on the model you name:",
144
+ "",
145
+ "```ts",
146
+ 'delegate({ tasks: [{ prompt: "continue",',
147
+ " resumeFrom: exactRetrySessionFile,",
148
+ ' model: "anthropic/claude-sonnet-4" }] })',
149
+ "```",
150
+ "",
151
+ "Pick a model you have auth for (the parent session's provider list). The failed result's retry hint names this exact shape when `failureKind` is `model_error`.",
152
+ "",
153
+ "## Async Mode",
154
+ "",
155
+ "Set `async: true` to run tasks in the background. The top-level `action` controls the ticket:",
156
+ "",
157
+ "```ts",
158
+ 'delegate({ async: true, tasks: [{ prompt: "Investigate auth", systemPrompt: "You are a focused investigator.", tools: ["read", "grep", "find", "ls"] }] })',
159
+ "```",
160
+ "",
161
+ '- `delegate({ action: "poll" })` \u2014 list all tickets',
162
+ '- `delegate({ action: "poll", ticket: "abc123" })` \u2014 check one ticket',
163
+ '- `delegate({ action: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
164
+ '- `delegate({ action: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
165
+ '- `delegate({ action: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
166
+ "",
167
+ `See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
168
+ "",
169
+ "## Gotchas",
170
+ "",
171
+ "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
172
+ '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
173
+ "- An ad-hoc task with no `tools` uses `*`; a named task uses its profile; a profile with no tools uses `*`.",
174
+ "- 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.",
175
+ `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
176
+ "",
177
+ "## Config",
178
+ "",
179
+ "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 per-agent model overrides.",
180
+ "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.",
181
+ "",
182
+ `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.`,
183
+ ].join("\n");
184
+ }
package/model.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
+ import { VALID_THINKING } from "./constants.ts";
5
+
6
+ export interface ResolvedModelRequest {
7
+ model: Model<Api> | undefined;
8
+ /** Pi-style `:<thinking-level>` suffix stripped to make the reference
9
+ * resolve. Reported so the caller can warn — it is NOT honored as a
10
+ * thinking level; the task's `thinking` field is the only thinking input. */
11
+ strippedSuffix?: ThinkingLevel;
12
+ }
13
+
14
+ function resolveModelReference(
15
+ spec: string,
16
+ registry: ModelRegistry,
17
+ ): Model<Api> | undefined {
18
+ const idx = spec.indexOf("/");
19
+ if (idx === -1) {
20
+ // Bare id — match against available models
21
+ const match = registry.getAvailable().find((m) => m.id === spec);
22
+ return match ?? undefined;
23
+ }
24
+ return registry.find(spec.slice(0, idx), spec.slice(idx + 1)) ?? undefined;
25
+ }
26
+
27
+ /** Resolve a model reference, tolerating a Pi-style `:<thinking-level>`
28
+ * suffix.
29
+ *
30
+ * Exact model references win first so provider model IDs containing colons are
31
+ * preserved. If no exact match exists, a final `:<thinking-level>` suffix is
32
+ * stripped and the base reference is resolved — models learned this syntax
33
+ * from Pi's CLI (e.g. `openai-codex/gpt-5.6-luna:max`) and keep emitting it,
34
+ * so hard-failing the whole call over it is worse than tolerating it. The
35
+ * suffix is deliberately NOT fed into thinking resolution: a single knob
36
+ * (the `thinking` field) beats two knobs with a silent precedence rule. */
37
+ export function resolveModelRequest(
38
+ spec: string | undefined,
39
+ registry: ModelRegistry,
40
+ parentModel: Model<Api> | undefined,
41
+ ): ResolvedModelRequest {
42
+ if (!spec) return { model: parentModel };
43
+
44
+ const exact = resolveModelReference(spec, registry);
45
+ if (exact) return { model: exact };
46
+
47
+ const colon = spec.lastIndexOf(":");
48
+ if (colon === -1) return { model: undefined };
49
+
50
+ const suffix = spec.slice(colon + 1);
51
+ if (!VALID_THINKING.has(suffix)) return { model: undefined };
52
+
53
+ const model = resolveModelReference(spec.slice(0, colon), registry);
54
+ return model
55
+ ? { model, strippedSuffix: suffix as ThinkingLevel }
56
+ : { model: undefined };
57
+ }
58
+
59
+ /** Resolve a model spec and return only the selected model instance. */
60
+ export function resolveModel(
61
+ spec: string | undefined,
62
+ registry: ModelRegistry,
63
+ parentModel: Model<Api> | undefined,
64
+ ): Model<Api> | undefined {
65
+ return resolveModelRequest(spec, registry, parentModel).model;
66
+ }
67
+
68
+ /** Find an available model with the same id as the given model, preferring
69
+ * a different provider if the original has no configured auth. */
70
+ export function findAvailableAlternative(
71
+ model: Model<Api> | undefined,
72
+ registry: ModelRegistry,
73
+ ): Model<Api> | undefined {
74
+ if (!model) return undefined;
75
+ if (registry.hasConfiguredAuth(model)) return model;
76
+ // Look for another model with the same id that DOES have auth.
77
+ // Prefer a different provider (avoid returning the same broken model).
78
+ return registry
79
+ .getAvailable()
80
+ .find((m) => m.id === model.id && m.provider !== model.provider);
81
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@bermudi/pi-delegate",
3
+ "version": "0.1.0",
4
+ "devDependencies": {
5
+ "@earendil-works/pi-agent-core": "^0.80.9",
6
+ "@earendil-works/pi-ai": "^0.80.9",
7
+ "@earendil-works/pi-coding-agent": "^0.80.9",
8
+ "@earendil-works/pi-tui": "^0.80.9",
9
+ "@marcfargas/pi-test-harness": "^0.6.1",
10
+ "@sinclair/typebox": "^0.34.0",
11
+ "esbuild": "^0.27.0",
12
+ "prettier": "^3.8.4",
13
+ "typescript": "^5.9.0"
14
+ },
15
+ "private": false,
16
+ "scripts": {
17
+ "test": "bun test",
18
+ "typecheck": "tsc --noEmit",
19
+ "build": "esbuild delegate.ts --bundle --platform=neutral --packages=external --format=esm --banner:js=\"// @ts-nocheck\" --outfile=delegate.bundle.ts",
20
+ "format": "prettier --write \"**/*.ts\""
21
+ },
22
+ "type": "module",
23
+ "description": "Delegate tool for the Pi coding agent.",
24
+ "keywords": [
25
+ "pi-package"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/bermudi/pi-delegate.git"
30
+ },
31
+ "files": [
32
+ "*.ts",
33
+ "!*.test.ts",
34
+ "!*.bundle.ts",
35
+ "README.md",
36
+ "patches"
37
+ ],
38
+ "pi": {
39
+ "extensions": [
40
+ "./delegate.ts"
41
+ ]
42
+ }
43
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ buildSessionContext,
3
+ type SessionEntry,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ /** Render the active parent conversation as compact context for a subagent. */
7
+ export function buildParentTranscript(
8
+ entries: SessionEntry[],
9
+ leafId: string | null,
10
+ ): string | null {
11
+ try {
12
+ const ctx = buildSessionContext(entries, leafId);
13
+ const lines: string[] = [];
14
+ for (const msg of ctx.messages) {
15
+ if (msg.role === "user") {
16
+ const text = extractTextContent(msg.content);
17
+ if (text) lines.push(`**User:** ${text.trim()}`);
18
+ } else if (msg.role === "assistant") {
19
+ const text = extractTextContent(msg.content);
20
+ if (text) lines.push(`**Assistant:** ${text.trim()}`);
21
+ }
22
+ }
23
+ return lines.join("\n\n") || null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Extract only text blocks from a Pi message content value. */
30
+ export function extractTextContent(
31
+ content: string | Array<{ type: string; text?: string }>,
32
+ ): string {
33
+ if (typeof content === "string") return content;
34
+ if (!Array.isArray(content)) return "";
35
+ return content
36
+ .filter(
37
+ (b): b is { type: "text"; text: string } =>
38
+ b.type === "text" && typeof b.text === "string",
39
+ )
40
+ .map((b) => b.text)
41
+ .join("");
42
+ }