@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.
Files changed (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. 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. */
@@ -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
- let done = false;
73
- const finish = (outcome: TierAdmission) => {
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
  }
@@ -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
- /** Use the real loader/manager while preventing ambient-home expansion of explicit settings. */
39
- export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir?: string): DefaultResourceLoader {
40
- if (homeDir === undefined) return new DefaultResourceLoader(options);
41
- const settingsManager = options.settingsManager ?? SettingsManager.create(options.cwd, options.agentDir);
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 new DefaultResourceLoader({ ...options, settingsManager });
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
- if (job.enabled) this.scheduleJob(job);
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.store;
187
- if (!store) return;
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
- if (!store || !pi || !ctx || !manager) return;
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
- /** Re-arm a quota-parked one-shot at a later reset instead of starting it. */
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
- return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" };
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 = err instanceof Error ? err.message : String(err);
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.