@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1444 -2
- package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
- package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
- package/dist/index.ts +9874 -0
- package/dist/index.ts.map +7 -0
- package/package.json +82 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +298 -0
- package/src/agent-manager.ts +2431 -0
- package/src/agent-runner.ts +1703 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +33 -0
- package/src/clock.ts +25 -0
- package/src/completion-outbox.ts +144 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +200 -0
- package/src/custom-agents.ts +441 -0
- package/src/default-agents.ts +126 -0
- package/src/detached-results.ts +150 -0
- package/src/enabled-models.ts +181 -0
- package/src/enterprise-local.ts +67 -0
- package/src/env.ts +33 -0
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +142 -0
- package/src/home-paths.ts +27 -0
- package/src/index.ts +5047 -0
- package/src/invocation-config.ts +158 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +200 -0
- package/src/mention.ts +141 -0
- package/src/model-call.ts +83 -0
- package/src/model-resolver.ts +333 -0
- package/src/model-scope.ts +71 -0
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +512 -0
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +186 -0
- package/src/package-resources.ts +639 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +346 -0
- package/src/prompts.ts +161 -0
- package/src/quota-admission.ts +121 -0
- package/src/resource-loader.ts +65 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +514 -0
- package/src/settings.ts +851 -0
- package/src/skill-loader.ts +288 -0
- package/src/stale-ctx.ts +16 -0
- package/src/startup-error.ts +29 -0
- package/src/status-note.ts +113 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +487 -0
- package/src/ui/agent-mention.ts +219 -0
- package/src/ui/agent-row.ts +245 -0
- package/src/ui/agent-widget.ts +722 -0
- package/src/ui/conversation-viewer.ts +837 -0
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +1020 -0
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +139 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/ui/workflow-card.ts +459 -0
- package/src/ui/workflow-dialog.ts +1138 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +398 -0
- package/src/workflow/journal.ts +176 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +329 -0
- package/src/workflow/progress.ts +554 -0
- package/src/workflow/runtime.ts +1249 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +317 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +903 -0
- package/src/worktree.ts +520 -0
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-mode-children.ts -- keeps a child started while the parent plans in Plan
|
|
3
|
+
* mode (#437), as Claude Code keeps a subagent spawned in plan mode in plan mode.
|
|
4
|
+
*
|
|
5
|
+
* pi-plan-mode gates only its own session's tool calls, and a child is a separate
|
|
6
|
+
* session on a separate event bus. So right before a child runs, the manager asks
|
|
7
|
+
* the parent's pi-plan-mode, synchronously on the parent's `pi.events`, whether it
|
|
8
|
+
* is planning; when it is, the answer carries the Plan tool policy, and this
|
|
9
|
+
* module applies it to every call the child makes. No answer means pi-plan-mode is
|
|
10
|
+
* not installed, and nothing changes.
|
|
11
|
+
*
|
|
12
|
+
* The contract mirrors `packages/plan-mode/src/child-policy.ts`, repeated rather
|
|
13
|
+
* than imported because either package installs without the other.
|
|
14
|
+
*/
|
|
15
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { AgentRecord } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export const PLAN_MODE_STATE_QUERY = "pi-plan-mode:state-query";
|
|
19
|
+
/** pi-plan-mode asks here whether a compatible gate is installed; answering is the handshake. */
|
|
20
|
+
export const PLAN_MODE_GATE_QUERY = "pi-plan-mode:gate-query";
|
|
21
|
+
const PLAN_MODE_STATE_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** Buses on which pi-plan-mode has shown itself by asking {@link PLAN_MODE_GATE_QUERY}. */
|
|
24
|
+
const planModeSeen = new WeakSet<object>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Answer pi-plan-mode's gate query, so Plan admits `Agent` and friends, and remember that Plan
|
|
28
|
+
* mode is installed on this bus: from then on a state query nobody answers fails closed.
|
|
29
|
+
*/
|
|
30
|
+
export function answerPlanGateQueries(events: { on(channel: string, handler: (data: unknown) => void): unknown }): void {
|
|
31
|
+
events.on(PLAN_MODE_GATE_QUERY, (data) => {
|
|
32
|
+
planModeSeen.add(events);
|
|
33
|
+
const reply = (data as { reply?: unknown } | undefined)?.reply;
|
|
34
|
+
if (typeof reply === "function") reply({ version: PLAN_MODE_STATE_VERSION, gate: "@nredd/pi-subagents" });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Thrown when pi-plan-mode is installed but did not answer whether it is planning. */
|
|
39
|
+
export const PLAN_MODE_SILENT_ERROR =
|
|
40
|
+
"Plan mode is installed but did not answer whether it is planning, so this subagent was not started. Reload Pi, or update @nredd/pi-plan-mode and @nredd/pi-subagents together.";
|
|
41
|
+
|
|
42
|
+
/** One tool call inside a child session. */
|
|
43
|
+
interface PlanModeChildToolCall {
|
|
44
|
+
toolName: string;
|
|
45
|
+
input: unknown;
|
|
46
|
+
cwd: string;
|
|
47
|
+
tool: ToolInfo | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Why Plan mode refuses a child's tool call, or `undefined` when it may run. */
|
|
51
|
+
export type PlanToolPolicy = (call: PlanModeChildToolCall) => string | undefined;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Plan restriction one child run is under. Mutable on purpose: it is decided
|
|
55
|
+
* per run (a spawn, then each resume) while the hooks reading it are installed
|
|
56
|
+
* once, on the child's session.
|
|
57
|
+
*/
|
|
58
|
+
export interface PlanGate {
|
|
59
|
+
policy?: PlanToolPolicy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tools a child keeps without asking: safe built-ins only. */
|
|
63
|
+
const FAIL_CLOSED_TOOLS: ReadonlySet<string> = new Set(["read", "grep", "find", "ls"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* For a planner that says it is active but sent no policy this host understands:
|
|
67
|
+
* nothing beyond the safe built-ins, so a contract mismatch can never run a child
|
|
68
|
+
* unrestricted.
|
|
69
|
+
*/
|
|
70
|
+
const failClosedPolicy: PlanToolPolicy = ({ toolName }) =>
|
|
71
|
+
FAIL_CLOSED_TOOLS.has(toolName)
|
|
72
|
+
? undefined
|
|
73
|
+
: `Plan mode is active and sent no tool policy this subagent understands, so '${toolName}' is blocked.`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask the parent's pi-plan-mode whether a child started now must plan. Returns
|
|
77
|
+
* the policy to run it under, or `undefined` when nobody is planning. Several
|
|
78
|
+
* answers (two Plan extensions) combine fail-closed: any active one restricts.
|
|
79
|
+
* A malformed answer (not version 1 with a boolean `active`) restricts like an unusable one.
|
|
80
|
+
* When Plan mode is installed but nothing answers, a new child is refused
|
|
81
|
+
* (`silent: "refuse"`, which throws) and a resumed one runs fail-closed.
|
|
82
|
+
*/
|
|
83
|
+
export function queryPlanPolicy(
|
|
84
|
+
events: { emit(channel: string, data: unknown): void } | undefined,
|
|
85
|
+
silent: "refuse" | "restrict" = "refuse",
|
|
86
|
+
): PlanToolPolicy | undefined {
|
|
87
|
+
// Hosts without an event bus (bare programmatic callers) cannot run pi-plan-mode either.
|
|
88
|
+
if (!events) return undefined;
|
|
89
|
+
const policies: PlanToolPolicy[] = [];
|
|
90
|
+
let answered = false;
|
|
91
|
+
events.emit(PLAN_MODE_STATE_QUERY, {
|
|
92
|
+
version: PLAN_MODE_STATE_VERSION,
|
|
93
|
+
reply: (state: unknown) => {
|
|
94
|
+
const answer = state as { version?: unknown; active?: unknown; evaluate?: unknown } | undefined;
|
|
95
|
+
// Only a well-formed answer says whether Plan is on; anything else restricts, never frees.
|
|
96
|
+
if (answer?.version !== PLAN_MODE_STATE_VERSION || typeof answer.active !== "boolean") {
|
|
97
|
+
policies.push(failClosedPolicy);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
answered = true;
|
|
101
|
+
if (!answer.active) return;
|
|
102
|
+
policies.push(typeof answer.evaluate === "function" ? (answer.evaluate as PlanToolPolicy) : failClosedPolicy);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
if (!answered && policies.length === 0 && planModeSeen.has(events)) {
|
|
106
|
+
if (silent === "refuse") throw new Error(PLAN_MODE_SILENT_ERROR);
|
|
107
|
+
return failClosedPolicy;
|
|
108
|
+
}
|
|
109
|
+
if (policies.length === 0) return undefined;
|
|
110
|
+
return (call) => {
|
|
111
|
+
for (const policy of policies) {
|
|
112
|
+
const reason = policy(call);
|
|
113
|
+
if (reason !== undefined) return reason;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a Plan policy is active on `events` now, failing closed: an installed Plan mode that
|
|
121
|
+
* answers badly or not at all counts as planning. For work that would otherwise outlive Plan
|
|
122
|
+
* unrestricted (a schedule, a quota-parked dispatch, steering an unrestricted child).
|
|
123
|
+
*/
|
|
124
|
+
export function planningNow(events: { emit(channel: string, data: unknown): void } | undefined): boolean {
|
|
125
|
+
return queryPlanPolicy(events, "restrict") !== undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a steer must be refused: the agent is running or queued under no Plan policy (it started
|
|
130
|
+
* before Plan) while Plan is active, so the steer would drive it with its full surface. The one
|
|
131
|
+
* predicate for `steer_subagent` and `subagents:rpc:steer`.
|
|
132
|
+
*/
|
|
133
|
+
export function planSteerBlocked(
|
|
134
|
+
record: Pick<AgentRecord, "status" | "planGate">,
|
|
135
|
+
events: { emit(channel: string, data: unknown): void } | undefined,
|
|
136
|
+
): boolean {
|
|
137
|
+
return (record.status === "running" || record.status === "queued") && !record.planGate?.policy && planningNow(events);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Plan's model must not steer a child that runs without Plan's restrictions. */
|
|
141
|
+
export function planSteerRefusal(id: string): string {
|
|
142
|
+
return (
|
|
143
|
+
`Cannot steer agent "${id}" while Plan mode is active: it started before Plan and runs without Plan's restrictions, so a steer could make it edit. ` +
|
|
144
|
+
"Wait for it to finish and steer it then (that resumes it read-only), stop it with stop_subagent, or leave Plan mode (/plan exit) first."
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A scheduled agent fires as a fresh spawn, after Plan, with nothing restricting it. */
|
|
149
|
+
export const PLAN_SCHEDULE_REFUSAL =
|
|
150
|
+
"Cannot schedule an agent while Plan mode is active: it would start later, after Plan, without Plan's restrictions. Start it now, or leave Plan mode first.";
|
|
151
|
+
|
|
152
|
+
/** A quota-parked dispatch is a scheduled job too. */
|
|
153
|
+
export const PLAN_PARK_REFUSAL =
|
|
154
|
+
"The agent was not started or parked: Plan mode is active, and a parked agent would start after Plan without Plan's restrictions. Retry after the reset, or leave Plan mode first.";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The refusal for one child tool call, or `undefined`. Tools this extension
|
|
158
|
+
* injects (nested delegation, whose children inherit the gate; StructuredOutput;
|
|
159
|
+
* the workflow refusal) are exempt: Plan's policy has no entry for them.
|
|
160
|
+
*/
|
|
161
|
+
export function planGateReason(
|
|
162
|
+
gate: PlanGate | undefined,
|
|
163
|
+
call: { toolName: string; input: unknown; cwd: string },
|
|
164
|
+
tools: () => ToolInfo[],
|
|
165
|
+
exempt: ReadonlySet<string>,
|
|
166
|
+
): string | undefined {
|
|
167
|
+
const policy = gate?.policy;
|
|
168
|
+
if (!policy || exempt.has(call.toolName)) return undefined;
|
|
169
|
+
return policy({ ...call, tool: tools().find((tool) => tool.name === call.toolName) });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Told to a child started while planning, so a refused write is not a surprise. */
|
|
173
|
+
export const PLAN_MODE_CHILD_PROMPT = `<plan_mode>
|
|
174
|
+
Your parent is in Plan mode, so you are too: investigate and report, do not change anything.
|
|
175
|
+
edit and write are blocked, bash runs only reviewed read-only commands, and other tools run only when Plan mode admits them.
|
|
176
|
+
</plan_mode>`;
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-budget.ts -- one ceiling on running subagents for the whole pi process.
|
|
3
|
+
*
|
|
4
|
+
* `maxConcurrent` and `maxConcurrentForeground` are per `AgentManager`, and
|
|
5
|
+
* there can be more than one manager per process: `/new`, `/resume`, `/fork`
|
|
6
|
+
* and `/reload` activate a fresh one and leave the previous manager alive until
|
|
7
|
+
* its background agents finish. (Subagent sessions do not activate this
|
|
8
|
+
* extension; nested children run in the parent's manager.) Those pools also
|
|
9
|
+
* exempt nested children, workflow children, scheduled jobs and resumes. The budget is the one count all of them share, kept on `globalThis`
|
|
10
|
+
* under `Symbol.for` so every manager and every module instance (a `/reload`
|
|
11
|
+
* loads a fresh copy of this file) sees the same state.
|
|
12
|
+
*
|
|
13
|
+
* Scope is the PROCESS, not the machine: two pi processes each get their own
|
|
14
|
+
* budget. It bounds running agents, not CPU, memory or commands.
|
|
15
|
+
*
|
|
16
|
+
* Every run holds one lease from the moment it is admitted until it actually
|
|
17
|
+
* settles. A parent keeps its lease while it waits on a child: releasing it
|
|
18
|
+
* around a nested call would be wrong, because pi runs a turn's tool calls in
|
|
19
|
+
* parallel and the parent's other tools are still executing.
|
|
20
|
+
*
|
|
21
|
+
* Holding parent leases is what makes a plain cap deadlock: twelve parents
|
|
22
|
+
* each waiting on a child that can never start. The reserve prevents it. With
|
|
23
|
+
* `L` the policy's `maxDepth` (one value for the whole process, NOT a branch's
|
|
24
|
+
* own cap) and `limit(j) = maxActive - nestedReserve * (L - j)`, a run at depth
|
|
25
|
+
* `d` is admitted only while, for EVERY level `j` from `d` to `L`,
|
|
26
|
+
*
|
|
27
|
+
* active at depth <= j < limit(j)
|
|
28
|
+
*
|
|
29
|
+
* (`j = L` is the plain `active < maxActive`). So with `maxActive: 12,
|
|
30
|
+
* nestedReserve: 2, L: 2`, at most ten top-level runs hold slots at once, and
|
|
31
|
+
* the last two only ever go to depth-2 children. Every admission keeps
|
|
32
|
+
* `count(<= j) <= limit(j)` for all `j`, releases only lower counts, and
|
|
33
|
+
* policy changes that could break it wait for an idle process (see
|
|
34
|
+
* `setBudgetPolicy`). Suppose every holder is a parent blocked on a child and
|
|
35
|
+
* `k < L` is the deepest level holding any: then `active = count(<= k) <=
|
|
36
|
+
* limit(k) < limit(k + 1) <= maxActive`, so a depth-`k+1` child fits at every
|
|
37
|
+
* level. Leaves (depth `L`) never wait on anyone, so they finish. That needs
|
|
38
|
+
* `limit` strictly increasing and `limit(1) >= 1` -- `isCoherent` -- and a run
|
|
39
|
+
* deeper than `L` refused outright (`assertWithinBudgetDepth`), since nothing
|
|
40
|
+
* reserves a slot for it. Top-level, workflow and scheduled runs all count as
|
|
41
|
+
* depth 1.
|
|
42
|
+
*
|
|
43
|
+
* Waiters are served first-come-first-served among the ELIGIBLE: a deeper
|
|
44
|
+
* waiter may pass a shallower one whose threshold is not met, which is what the
|
|
45
|
+
* reserve is for. Without that, a root at the head of the line would block the
|
|
46
|
+
* child that its own siblings are waiting on.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const BUDGET_KEY = Symbol.for("pi-subagents:process-budget");
|
|
50
|
+
|
|
51
|
+
/** Bumped on any incompatible change to `BudgetState`'s shape. */
|
|
52
|
+
const BUDGET_VERSION = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Queued starts the budget will hold while it is enabled. Only a bound on
|
|
56
|
+
* memory and on runaway fan-out -- a full queue fails the spawn with
|
|
57
|
+
* `BUDGET_QUEUE_FULL_ERROR` instead of parking work nobody will see start.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_PENDING_SUBAGENTS = 128;
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_BUDGET_POLICY: Readonly<BudgetPolicy> = {
|
|
62
|
+
maxActive: 0,
|
|
63
|
+
nestedReserve: 2,
|
|
64
|
+
maxDepth: 2,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface BudgetPolicy {
|
|
68
|
+
/** Process-wide ceiling on running subagents. `0` = off (counted, never enforced). */
|
|
69
|
+
maxActive: number;
|
|
70
|
+
/** Slots held back per nesting level below a run, so children can always start. */
|
|
71
|
+
nestedReserve: number;
|
|
72
|
+
/**
|
|
73
|
+
* The deepest level the reserve covers: the GLOBAL `maxSubagentDepth`, so a
|
|
74
|
+
* project file cannot reshape a process-wide policy. A deeper run is refused.
|
|
75
|
+
*/
|
|
76
|
+
maxDepth: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One admitted run. Released exactly once; later releases are no-ops. */
|
|
80
|
+
export interface BudgetLease {
|
|
81
|
+
readonly depth: number;
|
|
82
|
+
released: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A start waiting for admission. */
|
|
86
|
+
export interface BudgetWaiter {
|
|
87
|
+
/** Nesting depth of the run: top-level = 1. */
|
|
88
|
+
depth: number;
|
|
89
|
+
/** Local conditions the owner also needs (its own pool limits). */
|
|
90
|
+
ready: () => boolean;
|
|
91
|
+
/** Takes ownership of the lease. Must not throw; a throw is swallowed and the lease released. */
|
|
92
|
+
grant: (lease: BudgetLease) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface BudgetState {
|
|
96
|
+
version: number;
|
|
97
|
+
policy: BudgetPolicy;
|
|
98
|
+
/** A tightening change, applied once no lease is active (`setBudgetPolicy`). */
|
|
99
|
+
pendingPolicy: BudgetPolicy | undefined;
|
|
100
|
+
active: Set<BudgetLease>;
|
|
101
|
+
waiters: BudgetWaiter[];
|
|
102
|
+
pumping: boolean;
|
|
103
|
+
repump: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const BUDGET_QUEUE_FULL_ERROR =
|
|
107
|
+
`Subagent queue is full (${MAX_PENDING_SUBAGENTS} waiting for the process-wide maxActiveSubagents limit). `
|
|
108
|
+
+ "Wait for running agents to finish, or do the work directly.";
|
|
109
|
+
|
|
110
|
+
function freshState(): BudgetState {
|
|
111
|
+
return {
|
|
112
|
+
version: BUDGET_VERSION,
|
|
113
|
+
policy: { ...DEFAULT_BUDGET_POLICY },
|
|
114
|
+
pendingPolicy: undefined,
|
|
115
|
+
active: new Set(),
|
|
116
|
+
waiters: [],
|
|
117
|
+
pumping: false,
|
|
118
|
+
repump: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The process's budget state. An incompatible version (a `/reload` onto a
|
|
124
|
+
* build that changed the shape) refuses budget operations until pi restarts.
|
|
125
|
+
* Never fork the state: two private counts could each admit the whole cap.
|
|
126
|
+
*/
|
|
127
|
+
function state(): BudgetState {
|
|
128
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
129
|
+
const existing = g[BUDGET_KEY] as BudgetState | undefined;
|
|
130
|
+
if (existing === undefined) {
|
|
131
|
+
const created = freshState();
|
|
132
|
+
g[BUDGET_KEY] = created;
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
if (existing.version === BUDGET_VERSION) return existing;
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[pi-subagents] Incompatible process budget v${String(existing.version)} already loaded (this build uses v${String(BUDGET_VERSION)}). `
|
|
138
|
+
+ "Restart pi before starting more subagents; no private budget will be created.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The deepest level `policy` reserves for; nesting off still means one level. */
|
|
143
|
+
const topLevel = (policy: BudgetPolicy): number => Math.max(1, policy.maxDepth);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How many runs at depth <= `level` may hold leases before a run at `level` or
|
|
147
|
+
* shallower has to wait. No floor: `isCoherent` keeps it at least one.
|
|
148
|
+
*/
|
|
149
|
+
export function admissionLimit(policy: BudgetPolicy, level: number): number {
|
|
150
|
+
if (policy.maxActive <= 0) return Number.POSITIVE_INFINITY;
|
|
151
|
+
return policy.maxActive - policy.nestedReserve * (topLevel(policy) - level);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether `policy` is deadlock-free: off, or `limit` strictly increasing from
|
|
156
|
+
* at least one at depth 1 -- a reserve of one or more, and room for one
|
|
157
|
+
* top-level run after reserving for every level below it.
|
|
158
|
+
*/
|
|
159
|
+
export function isCoherent(policy: BudgetPolicy): boolean {
|
|
160
|
+
if (policy.maxActive <= 0 || topLevel(policy) === 1) return true;
|
|
161
|
+
return policy.nestedReserve >= 1 && admissionLimit(policy, 1) >= 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every level's limit is at least what it was, so no held lease can block a child that fit before. */
|
|
165
|
+
function relaxes(from: BudgetPolicy, to: BudgetPolicy): boolean {
|
|
166
|
+
if (to.maxActive <= 0) return true;
|
|
167
|
+
if (from.maxActive <= 0 || topLevel(from) !== topLevel(to)) return false;
|
|
168
|
+
for (let level = 1; level <= topLevel(to); level++) {
|
|
169
|
+
if (admissionLimit(to, level) < admissionLimit(from, level)) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getBudgetPolicy(): BudgetPolicy {
|
|
175
|
+
return { ...state().policy };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replace the policy. Last writer wins: every activation in the process
|
|
180
|
+
* shares it. An incoherent policy is refused with a warning. A change that
|
|
181
|
+
* could strand a waiting child -- turning the budget on, lowering a limit,
|
|
182
|
+
* changing `maxDepth` -- is held until no lease is active, since running
|
|
183
|
+
* parents were admitted under the old limits; turning it off or raising
|
|
184
|
+
* limits applies at once.
|
|
185
|
+
*/
|
|
186
|
+
export function setBudgetPolicy(next: BudgetPolicy): void {
|
|
187
|
+
const policy: BudgetPolicy = {
|
|
188
|
+
maxActive: Math.max(0, Math.floor(next.maxActive)),
|
|
189
|
+
nestedReserve: Math.max(0, Math.floor(next.nestedReserve)),
|
|
190
|
+
maxDepth: Math.max(0, Math.floor(next.maxDepth)),
|
|
191
|
+
};
|
|
192
|
+
if (!isCoherent(policy)) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`[pi-subagents] Ignoring maxActiveSubagents ${String(policy.maxActive)} with nestedSubagentReserve ${String(policy.nestedReserve)} `
|
|
195
|
+
+ `at maxSubagentDepth ${String(policy.maxDepth)}: it could deadlock. Nesting needs a reserve of at least 1 and `
|
|
196
|
+
+ "maxActiveSubagents >= nestedSubagentReserve * (maxSubagentDepth - 1) + 1.",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const s = state();
|
|
201
|
+
if (s.active.size === 0 || relaxes(s.policy, policy)) {
|
|
202
|
+
s.policy = policy;
|
|
203
|
+
s.pendingPolicy = undefined;
|
|
204
|
+
pumpBudget();
|
|
205
|
+
} else {
|
|
206
|
+
s.pendingPolicy = policy;
|
|
207
|
+
console.warn(
|
|
208
|
+
`[pi-subagents] Process budget change deferred until all running subagents settle: `
|
|
209
|
+
+ `maxActiveSubagents=${String(policy.maxActive)}, nestedSubagentReserve=${String(policy.nestedReserve)}, `
|
|
210
|
+
+ `maxSubagentDepth=${String(policy.maxDepth)}. The previous policy remains in force; continuous work can delay this indefinitely.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The policy waiting for the process to go idle, if any. For diagnostics and tests. */
|
|
216
|
+
export function getPendingBudgetPolicy(): BudgetPolicy | undefined {
|
|
217
|
+
const pending = state().pendingPolicy;
|
|
218
|
+
return pending === undefined ? undefined : { ...pending };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Throw when the budget is on and a run at `depth` is deeper than its policy
|
|
223
|
+
* reserves for: it could wait forever behind its own ancestors. Pass/fail is
|
|
224
|
+
* fixed for the run's life, since `maxDepth` changes only while idle.
|
|
225
|
+
*/
|
|
226
|
+
export function assertWithinBudgetDepth(depth: number): void {
|
|
227
|
+
const { policy } = state();
|
|
228
|
+
if (policy.maxActive > 0 && depth > topLevel(policy)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Nested subagent at depth ${String(depth)} exceeds the process budget's depth ${String(topLevel(policy))} `
|
|
231
|
+
+ "(global maxSubagentDepth). Complete the task directly.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Running leases and waiting starts, for diagnostics and tests. */
|
|
237
|
+
export function budgetUsage(): { active: number; pending: number; maxActive: number } {
|
|
238
|
+
const s = state();
|
|
239
|
+
return { active: s.active.size, pending: s.waiters.length, maxActive: s.policy.maxActive };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function admits(s: BudgetState, depth: number): boolean {
|
|
243
|
+
const { policy } = s;
|
|
244
|
+
if (policy.maxActive <= 0) return true;
|
|
245
|
+
const top = topLevel(policy);
|
|
246
|
+
const level = Math.max(1, depth);
|
|
247
|
+
if (level > top) return false;
|
|
248
|
+
// Leases deeper than `top` cannot exist while it is on; clamp so they still count in the total.
|
|
249
|
+
const atDepth = new Array<number>(top + 1).fill(0);
|
|
250
|
+
for (const lease of s.active) {
|
|
251
|
+
const index = Math.min(top, Math.max(1, lease.depth));
|
|
252
|
+
atDepth[index] = atDepth[index]! + 1;
|
|
253
|
+
}
|
|
254
|
+
let atOrAbove = 0;
|
|
255
|
+
for (let j = 1; j <= top; j++) {
|
|
256
|
+
atOrAbove += atDepth[j]!;
|
|
257
|
+
if (j >= level && atOrAbove >= admissionLimit(policy, j)) return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function newLease(s: BudgetState, depth: number): BudgetLease {
|
|
263
|
+
const lease: BudgetLease = { depth, released: false };
|
|
264
|
+
s.active.add(lease);
|
|
265
|
+
return lease;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Take a lease now, or undefined when this depth is at its limit or an earlier
|
|
270
|
+
* eligible waiter has first claim. Completion listeners can spawn synchronously
|
|
271
|
+
* between release and drain; re-entrant grant callbacks can do the same.
|
|
272
|
+
*/
|
|
273
|
+
export function tryAcquireBudget(depth: number): BudgetLease | undefined {
|
|
274
|
+
const s = state();
|
|
275
|
+
if (!admits(s, depth) || s.waiters.some(waiter => admits(s, waiter.depth) && waiter.ready())) return undefined;
|
|
276
|
+
return newLease(s, depth);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether another waiter fits. Always true while the budget is off. */
|
|
280
|
+
export function budgetHasPendingRoom(): boolean {
|
|
281
|
+
const s = state();
|
|
282
|
+
return s.policy.maxActive <= 0 || s.waiters.length < MAX_PENDING_SUBAGENTS;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Join the queue. Returns a cancel function; cancelling a granted waiter is a no-op. */
|
|
286
|
+
export function enqueueBudget(waiter: BudgetWaiter): () => void {
|
|
287
|
+
state().waiters.push(waiter);
|
|
288
|
+
return () => {
|
|
289
|
+
const s = state();
|
|
290
|
+
const i = s.waiters.indexOf(waiter);
|
|
291
|
+
if (i !== -1) s.waiters.splice(i, 1);
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Give a lease back. Idempotent; does not pump, so callers keep their own
|
|
297
|
+
* ordering. The last release applies a held policy change.
|
|
298
|
+
*/
|
|
299
|
+
export function releaseBudget(lease: BudgetLease | undefined): void {
|
|
300
|
+
if (lease === undefined || lease.released) return;
|
|
301
|
+
lease.released = true;
|
|
302
|
+
const s = state();
|
|
303
|
+
s.active.delete(lease);
|
|
304
|
+
if (s.active.size === 0 && s.pendingPolicy !== undefined) {
|
|
305
|
+
s.policy = s.pendingPolicy;
|
|
306
|
+
s.pendingPolicy = undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Admit every waiter that fits, earliest first, re-scanning after each grant
|
|
312
|
+
* because a grant changes the counts (`ready()` reads the owner's pools).
|
|
313
|
+
* Re-entrant calls from inside a grant are folded into the running loop.
|
|
314
|
+
*/
|
|
315
|
+
export function pumpBudget(): void {
|
|
316
|
+
const s = state();
|
|
317
|
+
if (s.pumping) {
|
|
318
|
+
s.repump = true;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
s.pumping = true;
|
|
322
|
+
try {
|
|
323
|
+
do {
|
|
324
|
+
s.repump = false;
|
|
325
|
+
for (;;) {
|
|
326
|
+
const i = s.waiters.findIndex(w => admits(s, w.depth) && w.ready());
|
|
327
|
+
if (i === -1) break;
|
|
328
|
+
const waiter = s.waiters.splice(i, 1)[0]!;
|
|
329
|
+
const lease = newLease(s, waiter.depth);
|
|
330
|
+
try {
|
|
331
|
+
waiter.grant(lease);
|
|
332
|
+
} catch {
|
|
333
|
+
releaseBudget(lease);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} while (s.repump);
|
|
337
|
+
} finally {
|
|
338
|
+
s.pumping = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Test-only: forget all leases and waiters and restore the default policy. */
|
|
343
|
+
export function resetBudgetForTests(): void {
|
|
344
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
345
|
+
g[BUDGET_KEY] = freshState();
|
|
346
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { PLAN_MODE_CHILD_PROMPT } from "./plan-mode-children.js";
|
|
6
|
+
import type { AgentConfig, EnvInfo } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Extra sections to inject into the system prompt (memory, skills, etc.). */
|
|
9
|
+
export interface PromptExtras {
|
|
10
|
+
/** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
|
|
11
|
+
memoryBlock?: string;
|
|
12
|
+
/** Preloaded skill contents to inject. */
|
|
13
|
+
skillBlocks?: { name: string; content: string }[];
|
|
14
|
+
/**
|
|
15
|
+
* Parent directory the worktree copy was created from. Set only for
|
|
16
|
+
* `isolation: "worktree"` spawns — triggers the block that tells the agent
|
|
17
|
+
* to stay in the copy.
|
|
18
|
+
*/
|
|
19
|
+
worktreeBase?: string;
|
|
20
|
+
/** Set when the parent was planning at spawn: the child is told it plans too (#437). */
|
|
21
|
+
planMode?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Set only for a workflow's own children, and only when they have no
|
|
24
|
+
* `StructuredOutput` tool to answer through.
|
|
25
|
+
*
|
|
26
|
+
* A workflow child's final text is not read by a human — it is the value
|
|
27
|
+
* `agent()` resolves to, and the script interpolates it straight into the
|
|
28
|
+
* next stage's prompt. Without this, children answer the way every other
|
|
29
|
+
* subagent does (a report addressed to a reader), and the padding becomes
|
|
30
|
+
* input tokens for the stage downstream. Claude Code's `Workflow` tool
|
|
31
|
+
* documents this contract to the script-writing model; this is the end of it
|
|
32
|
+
* that makes the documentation true.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT applied to every subagent. In pi an ordinary agent's
|
|
35
|
+
* output IS read by a human — through FleetView, the conversation viewer and
|
|
36
|
+
* `get_subagent_result` — so terse raw data would be the wrong answer there.
|
|
37
|
+
*/
|
|
38
|
+
workflowChild?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Set when the agent lists `allowed_subagents` but got no `Agent` tool (depth
|
|
41
|
+
* cap, isolation, `disallowed_tools`). Without the line it reads the missing tool as a fault and
|
|
42
|
+
* emulates one, e.g. by shelling out to `claude -p`, which escapes the depth
|
|
43
|
+
* cap, quota admission and usage reporting.
|
|
44
|
+
*/
|
|
45
|
+
nestingDisabled?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the system prompt for an agent from its config.
|
|
50
|
+
*
|
|
51
|
+
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
52
|
+
* - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
|
|
53
|
+
* - "append" with empty systemPrompt: pure parent clone
|
|
54
|
+
*
|
|
55
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
|
|
56
|
+
* extensions (e.g. permission/policy systems) can resolve per-agent policy
|
|
57
|
+
* inside the child session by parsing the system prompt. In replace mode the tag
|
|
58
|
+
* is prepended; in append mode it follows the shared inherited content so the
|
|
59
|
+
* parent prompt forms an identical, cacheable byte prefix with the parent
|
|
60
|
+
* session (the LLM's KV cache can then reuse those tokens across every spawn).
|
|
61
|
+
*
|
|
62
|
+
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
63
|
+
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
64
|
+
*/
|
|
65
|
+
export function buildAgentPrompt(
|
|
66
|
+
config: AgentConfig,
|
|
67
|
+
cwd: string,
|
|
68
|
+
env: EnvInfo,
|
|
69
|
+
parentSystemPrompt?: string,
|
|
70
|
+
extras?: PromptExtras,
|
|
71
|
+
): string {
|
|
72
|
+
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
|
73
|
+
|
|
74
|
+
const envBlock = `# Environment
|
|
75
|
+
Working directory: ${cwd}
|
|
76
|
+
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
77
|
+
Platform: ${env.platform}`;
|
|
78
|
+
|
|
79
|
+
// A worktree agent is told its cwd twice: by the env block above (the copy)
|
|
80
|
+
// and by whatever names the main checkout — the inherited parent prompt in
|
|
81
|
+
// append mode, or the task prompt in either mode. It follows the latter and
|
|
82
|
+
// works in the shared tree (#187), so resolve the contradiction explicitly.
|
|
83
|
+
const worktreeBlock = extras?.worktreeBase
|
|
84
|
+
? `\n\n<worktree_isolation>
|
|
85
|
+
Your working directory is an isolated git worktree copy of ${extras.worktreeBase}.
|
|
86
|
+
Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory.
|
|
87
|
+
</worktree_isolation>`
|
|
88
|
+
: "";
|
|
89
|
+
|
|
90
|
+
// The script, not a person, reads what this child returns — see
|
|
91
|
+
// `PromptExtras.workflowChild` for why only workflow children get this.
|
|
92
|
+
const workflowBlock = extras?.workflowChild
|
|
93
|
+
? `\n\n<workflow_child>
|
|
94
|
+
Your final message IS the return value of this task. A workflow script captures it and passes it to the next stage; no person reads it.
|
|
95
|
+
Return only the answer, in exactly the shape the prompt asks for — no preamble, no summary of what you did, no offer to continue.
|
|
96
|
+
</workflow_child>`
|
|
97
|
+
: "";
|
|
98
|
+
|
|
99
|
+
const planModeBlock = extras?.planMode ? `\n\n${PLAN_MODE_CHILD_PROMPT}` : "";
|
|
100
|
+
|
|
101
|
+
const nestingBlock = extras?.nestingDisabled
|
|
102
|
+
? `\n\n<nesting_disabled>
|
|
103
|
+
Nested subagents are disabled for this session, so you have no Agent tool. Do the work yourself, inline.
|
|
104
|
+
Do not emulate subagents by running another agent CLI (e.g. \`claude -p\`) from bash.
|
|
105
|
+
</nesting_disabled>`
|
|
106
|
+
: "";
|
|
107
|
+
|
|
108
|
+
// Build optional extras suffix
|
|
109
|
+
const extraSections: string[] = [];
|
|
110
|
+
if (extras?.memoryBlock) {
|
|
111
|
+
extraSections.push(extras.memoryBlock);
|
|
112
|
+
}
|
|
113
|
+
if (extras?.skillBlocks?.length) {
|
|
114
|
+
for (const skill of extras.skillBlocks) {
|
|
115
|
+
extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
|
119
|
+
|
|
120
|
+
if (config.promptMode === "append") {
|
|
121
|
+
const identity = parentSystemPrompt || genericBase;
|
|
122
|
+
|
|
123
|
+
const bridge = `<sub_agent_context>
|
|
124
|
+
You are operating as a sub-agent invoked to handle a specific task.
|
|
125
|
+
- Use the read tool instead of cat/head/tail
|
|
126
|
+
- Use the edit tool instead of sed/awk
|
|
127
|
+
- Use the write tool instead of echo/heredoc
|
|
128
|
+
- Use the find tool instead of bash find/ls for file search
|
|
129
|
+
- Use the grep tool instead of bash grep/rg for content search
|
|
130
|
+
- Make independent tool calls in parallel
|
|
131
|
+
- Use absolute file paths
|
|
132
|
+
- Do not use emojis
|
|
133
|
+
- Be concise but complete
|
|
134
|
+
</sub_agent_context>`;
|
|
135
|
+
|
|
136
|
+
const customSection = config.systemPrompt?.trim()
|
|
137
|
+
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
138
|
+
: "";
|
|
139
|
+
|
|
140
|
+
// Place shared/stable content first so the LLM's KV cache can reuse the
|
|
141
|
+
// inherited prefix across all subagent invocations. The parent prompt is
|
|
142
|
+
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
143
|
+
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
144
|
+
// tag and env block vary per call and are placed after the cached prefix.
|
|
145
|
+
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + customSection + extrasSuffix;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// "replace" mode — env header + the config's full system prompt
|
|
149
|
+
const replaceHeader = `You are a pi coding agent sub-agent.
|
|
150
|
+
You have been invoked to handle a specific task autonomously.
|
|
151
|
+
|
|
152
|
+
${envBlock}`;
|
|
153
|
+
|
|
154
|
+
return activeAgentTag + replaceHeader + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + "\n\n" + config.systemPrompt + extrasSuffix;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
|
158
|
+
const genericBase = `# Role
|
|
159
|
+
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
160
|
+
You have full access to read, write, edit files, and execute commands.
|
|
161
|
+
Do what has been asked; nothing more, nothing less.`;
|