@nredd/pi-subagents 0.22.0-nredd.8 → 0.22.0-nredd.9
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 +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- package/src/worktree.ts +309 -19
|
@@ -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>`;
|
package/src/prompts.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* prompts.ts — System prompt builder for agents.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { PLAN_MODE_CHILD_PROMPT } from "./plan-mode-children.js";
|
|
5
6
|
import type { AgentConfig, EnvInfo } from "./types.js";
|
|
6
7
|
|
|
7
8
|
/** Extra sections to inject into the system prompt (memory, skills, etc.). */
|
|
@@ -16,6 +17,8 @@ export interface PromptExtras {
|
|
|
16
17
|
* to stay in the copy.
|
|
17
18
|
*/
|
|
18
19
|
worktreeBase?: string;
|
|
20
|
+
/** Set when the parent was planning at spawn: the child is told it plans too (#437). */
|
|
21
|
+
planMode?: boolean;
|
|
19
22
|
/**
|
|
20
23
|
* Set only for a workflow's own children, and only when they have no
|
|
21
24
|
* `StructuredOutput` tool to answer through.
|
|
@@ -93,6 +96,8 @@ Return only the answer, in exactly the shape the prompt asks for — no preamble
|
|
|
93
96
|
</workflow_child>`
|
|
94
97
|
: "";
|
|
95
98
|
|
|
99
|
+
const planModeBlock = extras?.planMode ? `\n\n${PLAN_MODE_CHILD_PROMPT}` : "";
|
|
100
|
+
|
|
96
101
|
const nestingBlock = extras?.nestingDisabled
|
|
97
102
|
? `\n\n<nesting_disabled>
|
|
98
103
|
Nested subagents are disabled for this session, so you have no Agent tool. Do the work yourself, inline.
|
|
@@ -137,7 +142,7 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
137
142
|
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
138
143
|
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
139
144
|
// tag and env block vary per call and are placed after the cached prefix.
|
|
140
|
-
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + workflowBlock + nestingBlock + customSection + extrasSuffix;
|
|
145
|
+
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + customSection + extrasSuffix;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
// "replace" mode — env header + the config's full system prompt
|
|
@@ -146,7 +151,7 @@ You have been invoked to handle a specific task autonomously.
|
|
|
146
151
|
|
|
147
152
|
${envBlock}`;
|
|
148
153
|
|
|
149
|
-
return activeAgentTag + replaceHeader + worktreeBlock + workflowBlock + nestingBlock + "\n\n" + config.systemPrompt + extrasSuffix;
|
|
154
|
+
return activeAgentTag + replaceHeader + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + "\n\n" + config.systemPrompt + extrasSuffix;
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
package/src/quota-admission.ts
CHANGED
|
@@ -56,8 +56,9 @@ export async function checkAdmission(
|
|
|
56
56
|
model: string,
|
|
57
57
|
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
58
58
|
clock: Clock = realClock,
|
|
59
|
+
signal?: AbortSignal,
|
|
59
60
|
): Promise<QuotaBlock | undefined> {
|
|
60
|
-
return (await admissionOutcome(events, model, timeoutMs, clock)).block;
|
|
61
|
+
return (await admissionOutcome(events, model, timeoutMs, clock, signal)).block;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** Retain the physical admission label and whether the router supplied a signal. */
|
|
@@ -66,15 +67,23 @@ export function admissionOutcome(
|
|
|
66
67
|
model: string,
|
|
67
68
|
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
68
69
|
clock: Clock = realClock,
|
|
70
|
+
signal?: AbortSignal,
|
|
69
71
|
): Promise<TierAdmission> {
|
|
72
|
+
if (signal?.aborted) return Promise.reject(new DOMException("Admission cancelled", "AbortError"));
|
|
70
73
|
const requestId = nanoid();
|
|
71
|
-
return new Promise(resolve => {
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
if (done) return;
|
|
75
|
-
done = true;
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
// Every step is idempotent and a promise settles once, so no `done` latch is needed.
|
|
76
|
+
const cleanup = () => {
|
|
76
77
|
timer.cancel();
|
|
77
78
|
unsubscribe();
|
|
79
|
+
signal?.removeEventListener("abort", abort);
|
|
80
|
+
};
|
|
81
|
+
const abort = () => {
|
|
82
|
+
cleanup();
|
|
83
|
+
reject(new DOMException("Admission cancelled", "AbortError"));
|
|
84
|
+
};
|
|
85
|
+
const finish = (outcome: TierAdmission) => {
|
|
86
|
+
cleanup();
|
|
78
87
|
resolve(outcome);
|
|
79
88
|
};
|
|
80
89
|
const unsubscribe = events.on(`${ADMIT_CHANNEL}:reply:${requestId}`, raw => {
|
|
@@ -90,6 +99,7 @@ export function admissionOutcome(
|
|
|
90
99
|
} });
|
|
91
100
|
});
|
|
92
101
|
const timer = clock.setTimeout(() => finish({ quotaSignal: false }), timeoutMs);
|
|
102
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
93
103
|
events.emit(ADMIT_CHANNEL, { requestId, model });
|
|
94
104
|
});
|
|
95
105
|
}
|
package/src/resource-loader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Normalize explicit settings paths before Pi's real package manager resolves them. */
|
|
2
|
-
import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DefaultPackageManager, DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { normalizeHomePath } from "./home-paths.js";
|
|
4
4
|
|
|
5
5
|
type ResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
|
|
@@ -35,10 +35,10 @@ export function normalizeResourceSettings(settings: ResourceSettings, homeDir: s
|
|
|
35
35
|
return normalized;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
type PackageManagerOptions = ConstructorParameters<typeof DefaultPackageManager>[0];
|
|
39
|
+
|
|
40
|
+
/** Wrap settings readers once so Pi receives injected-home resource paths. */
|
|
41
|
+
function normalizeHomeResourceSettings(settingsManager: SettingsManager, homeDir: string): SettingsManager {
|
|
42
42
|
const globalSettings = settingsManager.getGlobalSettings.bind(settingsManager);
|
|
43
43
|
const projectSettings = settingsManager.getProjectSettings.bind(settingsManager);
|
|
44
44
|
// Wrap the public readers, not a cached snapshot: reload and project-trust
|
|
@@ -47,5 +47,19 @@ export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir
|
|
|
47
47
|
getGlobalSettings: () => normalizeResourceSettings(globalSettings(), homeDir),
|
|
48
48
|
getProjectSettings: () => normalizeResourceSettings(projectSettings(), homeDir),
|
|
49
49
|
});
|
|
50
|
-
return
|
|
50
|
+
return settingsManager;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build Pi's package manager with injected-home settings normalization. */
|
|
54
|
+
export function createHomePackageManager(options: PackageManagerOptions, homeDir?: string): DefaultPackageManager {
|
|
55
|
+
if (homeDir === undefined) return new DefaultPackageManager(options);
|
|
56
|
+
// `homeDir` also redirects Pi's automatic `<home>/.agents/skills` root (nredd/pi#13).
|
|
57
|
+
return new DefaultPackageManager({ ...options, homeDir, settingsManager: normalizeHomeResourceSettings(options.settingsManager, homeDir) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Use the real loader/manager while preventing ambient-home expansion of explicit settings. */
|
|
61
|
+
export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir?: string): DefaultResourceLoader {
|
|
62
|
+
if (homeDir === undefined) return new DefaultResourceLoader(options);
|
|
63
|
+
const settingsManager = options.settingsManager ?? SettingsManager.create(options.cwd, options.agentDir);
|
|
64
|
+
return new DefaultResourceLoader({ ...options, homeDir, settingsManager: normalizeHomeResourceSettings(settingsManager, homeDir) });
|
|
51
65
|
}
|
package/src/schedule.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface NewJobInput {
|
|
|
51
51
|
quotaParked?: boolean;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** What `start()` binds, captured by a fire so a later session switch cannot leak into it. */
|
|
55
|
+
interface BoundSession {
|
|
56
|
+
store: ScheduleStore;
|
|
57
|
+
pi: ExtensionAPI;
|
|
58
|
+
ctx: ExtensionContext;
|
|
59
|
+
manager: AgentManager;
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
/** Optional in-process scheduling dependencies; omitted clock uses native time. */
|
|
55
63
|
export interface SubagentSchedulerOptions {
|
|
56
64
|
homeDir?: string;
|
|
@@ -61,6 +69,10 @@ export class SubagentScheduler {
|
|
|
61
69
|
private readonly clock: Clock;
|
|
62
70
|
private jobs = new Map<string, Cron>();
|
|
63
71
|
private intervals = new Map<string, TimerHandle>();
|
|
72
|
+
private scheduled = new Map<string, object>();
|
|
73
|
+
private admissions = new Map<string, Set<AbortController>>();
|
|
74
|
+
/** Newest pending admission per job that wrote "running", with the status it replaced. */
|
|
75
|
+
private pendingRuns = new Map<string, { token: AbortController; prior: ScheduledSubagent["lastStatus"] }>();
|
|
64
76
|
private store: ScheduleStore | undefined;
|
|
65
77
|
private pi: ExtensionAPI | undefined;
|
|
66
78
|
private ctx: ExtensionContext | undefined;
|
|
@@ -72,6 +84,7 @@ export class SubagentScheduler {
|
|
|
72
84
|
|
|
73
85
|
/** Start the scheduler: bind to a session's store and arm enabled jobs. */
|
|
74
86
|
start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void {
|
|
87
|
+
this.stop();
|
|
75
88
|
this.pi = pi;
|
|
76
89
|
this.ctx = ctx;
|
|
77
90
|
this.manager = manager;
|
|
@@ -88,6 +101,10 @@ export class SubagentScheduler {
|
|
|
88
101
|
this.jobs.clear();
|
|
89
102
|
for (const t of this.intervals.values()) t.cancel();
|
|
90
103
|
this.intervals.clear();
|
|
104
|
+
for (const admissions of this.admissions.values()) for (const admission of admissions) admission.abort();
|
|
105
|
+
this.admissions.clear();
|
|
106
|
+
this.pendingRuns.clear();
|
|
107
|
+
this.scheduled.clear();
|
|
91
108
|
this.store = undefined;
|
|
92
109
|
this.pi = undefined;
|
|
93
110
|
this.ctx = undefined;
|
|
@@ -138,7 +155,7 @@ export class SubagentScheduler {
|
|
|
138
155
|
}
|
|
139
156
|
const job = this.buildJob(input);
|
|
140
157
|
store.add(job);
|
|
141
|
-
|
|
158
|
+
this.scheduleJob(job); // buildJob always creates an enabled job
|
|
142
159
|
this.emit({ type: "added", job });
|
|
143
160
|
return job;
|
|
144
161
|
}
|
|
@@ -183,8 +200,8 @@ export class SubagentScheduler {
|
|
|
183
200
|
// ── Scheduling primitives ────────────────────────────────────────────
|
|
184
201
|
|
|
185
202
|
private scheduleJob(job: ScheduledSubagent): void {
|
|
186
|
-
const store = this.
|
|
187
|
-
|
|
203
|
+
const store = this.requireStore();
|
|
204
|
+
this.scheduled.set(job.id, {});
|
|
188
205
|
try {
|
|
189
206
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
190
207
|
const t = this.clock.setInterval(() => this.executeJob(job.id), job.intervalMs);
|
|
@@ -206,6 +223,7 @@ export class SubagentScheduler {
|
|
|
206
223
|
this.intervals.set(job.id, t);
|
|
207
224
|
} else {
|
|
208
225
|
// Past timestamp — disable, mark error, never fire
|
|
226
|
+
this.scheduled.delete(job.id);
|
|
209
227
|
store.update(job.id, { enabled: false, lastStatus: "error" });
|
|
210
228
|
this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` });
|
|
211
229
|
}
|
|
@@ -214,11 +232,16 @@ export class SubagentScheduler {
|
|
|
214
232
|
this.jobs.set(job.id, cron);
|
|
215
233
|
}
|
|
216
234
|
} catch (err) {
|
|
235
|
+
// Nothing armed, so no token: a stale entry would admit a fire for a job with no timer.
|
|
236
|
+
this.scheduled.delete(job.id);
|
|
217
237
|
this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) });
|
|
218
238
|
}
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
private unscheduleJob(id: string): void {
|
|
242
|
+
this.scheduled.delete(id);
|
|
243
|
+
for (const admission of this.admissions.get(id) ?? []) admission.abort();
|
|
244
|
+
this.admissions.delete(id);
|
|
222
245
|
const cron = this.jobs.get(id);
|
|
223
246
|
if (cron) {
|
|
224
247
|
cron.stop();
|
|
@@ -241,10 +264,15 @@ export class SubagentScheduler {
|
|
|
241
264
|
const pi = this.pi;
|
|
242
265
|
const ctx = this.ctx;
|
|
243
266
|
const manager = this.manager;
|
|
244
|
-
|
|
267
|
+
const scheduled = this.scheduled.get(id);
|
|
268
|
+
if (!store || !pi || !ctx || !manager || !scheduled) return;
|
|
245
269
|
const job = store.get(id);
|
|
246
270
|
if (!job?.enabled) return;
|
|
247
271
|
|
|
272
|
+
// A fire that overlaps a pending admission inherits its prior status.
|
|
273
|
+
const previous = this.pendingRuns.get(id);
|
|
274
|
+
const prior = previous ? previous.prior : job.lastStatus;
|
|
275
|
+
this.pendingRuns.delete(id);
|
|
248
276
|
store.update(id, { lastStatus: "running" });
|
|
249
277
|
|
|
250
278
|
// Resolve the original request at fire time, using the same type and model
|
|
@@ -268,13 +296,28 @@ export class SubagentScheduler {
|
|
|
268
296
|
// waited for may have refilled while another is still exhausted.
|
|
269
297
|
const admissionModel = selection.model;
|
|
270
298
|
if ((job.quotaParked || selection.tierBinding) && admissionModel) {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const admissions = this.admissions.get(id) ?? new Set<AbortController>();
|
|
301
|
+
admissions.add(controller);
|
|
302
|
+
this.admissions.set(id, admissions);
|
|
303
|
+
this.pendingRuns.set(id, { token: controller, prior });
|
|
304
|
+
// Cancelled before spawn: undo this admission's "running", never a newer one's.
|
|
305
|
+
controller.signal.addEventListener("abort", () => {
|
|
306
|
+
if (this.pendingRuns.get(id)?.token !== controller) return;
|
|
307
|
+
this.pendingRuns.delete(id);
|
|
308
|
+
if (store.get(id)?.lastStatus === "running") store.update(id, { lastStatus: prior });
|
|
309
|
+
}, { once: true });
|
|
271
310
|
const admission = selection.tierBinding
|
|
272
|
-
? admitModelSelection(selection, ctx.modelRegistry, pi.events)
|
|
273
|
-
: checkAdmission(pi.events, modelRef(admissionModel));
|
|
311
|
+
? admitModelSelection(selection, ctx.modelRegistry, pi.events, this.clock, controller.signal)
|
|
312
|
+
: checkAdmission(pi.events, modelRef(admissionModel), undefined, this.clock, controller.signal);
|
|
313
|
+
const settle = (): void => {
|
|
314
|
+
if (this.pendingRuns.get(id)?.token === controller) this.pendingRuns.delete(id);
|
|
315
|
+
};
|
|
274
316
|
void admission.then(block => {
|
|
275
|
-
if (this.store !== store || this.ctx !== ctx) return;
|
|
317
|
+
if (this.store !== store || this.ctx !== ctx || this.scheduled.get(id) !== scheduled) return;
|
|
318
|
+
settle();
|
|
276
319
|
if (!block) {
|
|
277
|
-
this.spawnJob(id, selection);
|
|
320
|
+
this.spawnJob({ store, pi, ctx, manager }, id, selection);
|
|
278
321
|
} else if (mayPark(block) && block.resetAt > this.clock.now()) {
|
|
279
322
|
this.repark(id, block.resetAt);
|
|
280
323
|
} else {
|
|
@@ -285,18 +328,25 @@ export class SubagentScheduler {
|
|
|
285
328
|
this.emit({ type: "error", jobId: id, error: `${describeBlock(block)} The agent was not started.` });
|
|
286
329
|
}
|
|
287
330
|
}).catch(error => {
|
|
288
|
-
if (this.store !== store) return;
|
|
331
|
+
if (this.store !== store || this.scheduled.get(id) !== scheduled) return;
|
|
332
|
+
settle();
|
|
289
333
|
store.update(id, { lastStatus: "error" });
|
|
290
334
|
this.emit({ type: "error", jobId: id, error: error instanceof Error ? error.message : String(error) });
|
|
335
|
+
}).finally(() => {
|
|
336
|
+
admissions.delete(controller);
|
|
337
|
+
if (admissions.size === 0 && this.admissions.get(id) === admissions) this.admissions.delete(id);
|
|
291
338
|
});
|
|
292
339
|
return;
|
|
293
340
|
}
|
|
294
|
-
this.spawnJob(id, selection);
|
|
341
|
+
this.spawnJob({ store, pi, ctx, manager }, id, selection);
|
|
295
342
|
}
|
|
296
343
|
|
|
297
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Re-arm a quota-parked one-shot at a later reset instead of starting it.
|
|
346
|
+
* A job removed while admission was pending stays removed: `updateJob` is a
|
|
347
|
+
* no-op for an unknown id.
|
|
348
|
+
*/
|
|
298
349
|
private repark(id: string, resetAt: number): void {
|
|
299
|
-
if (!this.store?.get(id)) return;
|
|
300
350
|
this.updateJob(id, {
|
|
301
351
|
enabled: true,
|
|
302
352
|
schedule: new Date(resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
|
|
@@ -306,12 +356,7 @@ export class SubagentScheduler {
|
|
|
306
356
|
}
|
|
307
357
|
|
|
308
358
|
/** Spawn a fired job (bypassing the concurrency queue) and persist its outcome. */
|
|
309
|
-
private spawnJob(id: string, selection: ModelSelection): void {
|
|
310
|
-
const store = this.store;
|
|
311
|
-
const pi = this.pi;
|
|
312
|
-
const ctx = this.ctx;
|
|
313
|
-
const manager = this.manager;
|
|
314
|
-
if (!store || !pi || !ctx || !manager) return;
|
|
359
|
+
private spawnJob({ store, pi, ctx, manager }: BoundSession, id: string, selection: ModelSelection): void {
|
|
315
360
|
const job = store.get(id);
|
|
316
361
|
if (!job) return;
|
|
317
362
|
|
|
@@ -447,7 +492,8 @@ export class SubagentScheduler {
|
|
|
447
492
|
new Cron(expr, () => {});
|
|
448
493
|
return { valid: true };
|
|
449
494
|
} catch (e) {
|
|
450
|
-
|
|
495
|
+
// Croner only throws Error subclasses (TypeError, RangeError).
|
|
496
|
+
return { valid: false, error: (e as Error).message };
|
|
451
497
|
}
|
|
452
498
|
}
|
|
453
499
|
|
package/src/settings.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { NO_FALLBACK } from "./agent-types.js";
|
|
8
8
|
import { readEnterpriseLocalManifest } from "./enterprise-local.js";
|
|
9
|
+
import { errorMessage } from "./error-message.js";
|
|
9
10
|
import { resolveAgentDir } from "./home-paths.js";
|
|
10
11
|
import { type ModelResolution, parseTierBinding, setModelTiers } from "./model-tiers.js";
|
|
12
|
+
import { NOTIFICATION_RESULT_CHARS_CEILING } from "./notification-result.js";
|
|
11
13
|
import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
|
|
12
14
|
import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
|
|
13
15
|
|
|
@@ -66,6 +68,15 @@ export interface SubagentsSettings {
|
|
|
66
68
|
*/
|
|
67
69
|
defaultMaxTurns?: number;
|
|
68
70
|
graceTurns?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Result characters a background completion notice carries per agent,
|
|
73
|
+
* defaulting to `16000`. Past it the notice keeps the head and tail and
|
|
74
|
+
* names the transcript and `get_subagent_result`; agents that finish
|
|
75
|
+
* together share four times this. A result delivered whole counts as read.
|
|
76
|
+
* `0` restores the 500-character preview (300 per `group` member). See
|
|
77
|
+
* `notification-result.ts`.
|
|
78
|
+
*/
|
|
79
|
+
notificationResultChars?: number;
|
|
69
80
|
defaultJoinMode?: JoinMode;
|
|
70
81
|
/**
|
|
71
82
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -391,6 +402,7 @@ export interface SettingsAppliers {
|
|
|
391
402
|
setBudgetPolicy: (policy: BudgetPolicy) => void;
|
|
392
403
|
setDefaultMaxTurns: (n: number) => void;
|
|
393
404
|
setGraceTurns: (n: number) => void;
|
|
405
|
+
setNotificationResultChars: (n: number) => void;
|
|
394
406
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
395
407
|
setBackgroundByDefault: (b: boolean) => void;
|
|
396
408
|
setSchedulingEnabled: (b: boolean) => void;
|
|
@@ -502,6 +514,13 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
502
514
|
) {
|
|
503
515
|
out.graceTurns = r.graceTurns as number;
|
|
504
516
|
}
|
|
517
|
+
if (
|
|
518
|
+
Number.isInteger(r.notificationResultChars) &&
|
|
519
|
+
(r.notificationResultChars as number) >= 0 &&
|
|
520
|
+
(r.notificationResultChars as number) <= NOTIFICATION_RESULT_CHARS_CEILING
|
|
521
|
+
) {
|
|
522
|
+
out.notificationResultChars = r.notificationResultChars as number;
|
|
523
|
+
}
|
|
505
524
|
if (
|
|
506
525
|
Number.isInteger(r.maxSubagentDepth) &&
|
|
507
526
|
(r.maxSubagentDepth as number) >= 0 &&
|
|
@@ -671,7 +690,7 @@ function readSettingsFile(path: string): SubagentsSettings {
|
|
|
671
690
|
try {
|
|
672
691
|
return sanitize(JSON.parse(readFileSync(path, "utf-8")));
|
|
673
692
|
} catch (err) {
|
|
674
|
-
const reason =
|
|
693
|
+
const reason = errorMessage(err);
|
|
675
694
|
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
|
|
676
695
|
return {};
|
|
677
696
|
}
|
|
@@ -753,6 +772,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
753
772
|
}
|
|
754
773
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
755
774
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
775
|
+
if (typeof s.notificationResultChars === "number") appliers.setNotificationResultChars(s.notificationResultChars);
|
|
756
776
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
757
777
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
758
778
|
// Module state: reset when a session omits it, or the previous activation's aliases leak.
|