@bermudi/pi-delegate 0.1.1 → 0.1.2

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.
@@ -37,40 +37,32 @@ const PROJECT_CONTEXT_END = "\n</project_context>\n";
37
37
  *
38
38
  * The AGENTS.md/CLAUDE.md content is inserted verbatim inside
39
39
  * `<project_instructions>...</project_instructions>` blocks. A file that
40
- * itself contains `\n</project_context>\n` would otherwise terminate the
41
- * scan early and leak the remainder of the parent context (P1). We therefore
42
- * treat a candidate closing marker as valid only when it is *outside* any
43
- * open `<project_instructions>` block i.e. the first `PROJECT_CONTEXT_END`
44
- * after `start` that is not nested. That matches the generated section's
45
- * final closing marker while ignoring embedded fakes. A trailing
46
- * `</project_context>` in post-context prompt text (skills/cwd) would also be
47
- * outside, but such text is controlled by Pi and far less likely; picking the
48
- * first outside marker preserves trailing prompt content instead of
49
- * over-consuming it.
40
+ * itself contains `\n</project_context>\n` can therefore forge an early
41
+ * closing marker. It could also include a forged `</project_instructions>` to
42
+ * balance the open tag, making the fake `</project_context>` look like the
43
+ * section end. A content-controlled marker can only ever appear *inside* the
44
+ * generated `<project_context>` block, before the real `</project_context>`
45
+ * that Pi appends after the last file. Skills escape angle brackets, the
46
+ * `appendSystemPrompt` text is added before this section, and the trailing
47
+ * `Current working directory:` line contains no such marker, so the real
48
+ * closing marker is the final raw `\n</project_context>\n` in the prompt.
49
+ * We therefore match the Pi-generated opening marker to the final matching
50
+ * closing marker, deterministically stripping the whole generated section.
50
51
  */
51
52
  export function stripInheritedProjectContext(
52
53
  prompt: string | undefined,
53
54
  ): string | undefined {
54
55
  if (!prompt) return prompt;
56
+
55
57
  const start = prompt.indexOf(PROJECT_CONTEXT_START);
56
58
  if (start < 0) return prompt;
57
- let searchFrom = start + PROJECT_CONTEXT_START.length;
58
- let end = -1;
59
- while (true) {
60
- const candidate = prompt.indexOf(PROJECT_CONTEXT_END, searchFrom);
61
- if (candidate < 0) break;
62
- const before = prompt.slice(start, candidate);
63
- const openCount = (before.match(/<project_instructions/g) || []).length;
64
- const closeCount = (before.match(/<\/project_instructions>/g) || []).length;
65
- if (openCount > closeCount) {
66
- // Inside a file block — embedded fake, skip it.
67
- searchFrom = candidate + PROJECT_CONTEXT_END.length;
68
- continue;
69
- }
70
- end = candidate;
71
- break; // first valid outside block is the true closing
72
- }
73
- if (end < 0) return prompt;
59
+
60
+ // The real closing marker is the final occurrence: child content cannot
61
+ // place a `</project_context>` after the one Pi generates to close the
62
+ // section.
63
+ const end = prompt.lastIndexOf(PROJECT_CONTEXT_END);
64
+ if (end < start) return prompt;
65
+
74
66
  return `${prompt.slice(0, start)}${prompt.slice(end + PROJECT_CONTEXT_END.length)}`;
75
67
  }
76
68
 
@@ -86,6 +78,11 @@ function noticeResult(
86
78
  };
87
79
  }
88
80
 
81
+ /** Format a task reference for error messages: one-based index with an optional caller id. */
82
+ function formatTaskRef(index: number, id: string | undefined): string {
83
+ return `Task ${index + 1}${id ? `#${id}` : ""}`;
84
+ }
85
+
89
86
  /** Pre-dispatch validation: duplicate sessionIds, sessions busy with an async
90
87
  * ticket, and unknown agent names. Returns an error result to short-circuit
91
88
  * the call, or null when all checks pass. */
@@ -121,6 +118,24 @@ export function validateTasks(
121
118
  );
122
119
  }
123
120
 
121
+ // Disallow duplicate caller-provided task ids within one dispatch.
122
+ const seenIds = new Map<string, number>();
123
+ const duplicateIds: string[] = [];
124
+ for (const [index, task] of tasks.entries()) {
125
+ if (task.id) {
126
+ if (seenIds.has(task.id)) {
127
+ duplicateIds.push(
128
+ `task ${index + 1}: duplicate id '${task.id}' — ids must be unique within one dispatch.`,
129
+ );
130
+ } else {
131
+ seenIds.set(task.id, index);
132
+ }
133
+ }
134
+ }
135
+ if (duplicateIds.length) {
136
+ return noticeResult(duplicateIds.join(" "), tasks, parentModelId);
137
+ }
138
+
124
139
  const unknown: string[] = [];
125
140
  for (const t of tasks) {
126
141
  if (t.agent && t.agent !== DEFAULT_AGENT_NAME && !agents.has(t.agent)) {
@@ -187,17 +202,47 @@ export function resolveTasks(
187
202
  // it. The assembled parent project-context section was stripped above;
188
203
  // the child ResourceLoader supplies context for this task's cwd.
189
204
  const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
205
+ const isPoolHit = pooledConfig !== undefined;
206
+ const parentNativeTools = parentDefaults.tools.filter((name) =>
207
+ Object.hasOwn(TOOL_FACTORIES, name),
208
+ );
209
+ let tools: string[] = [];
210
+ const warnings: string[] = [];
190
211
 
191
212
  // Prompt is required for fresh tasks. ResumeFrom provides context already.
192
213
  if (
193
- t.action !== "close" &&
194
- t.action !== "list" &&
214
+ t.sessionAction !== "close" &&
215
+ t.sessionAction !== "list" &&
195
216
  !t.resumeFrom &&
196
217
  !t.prompt?.trim()
197
218
  ) {
198
219
  throw new Error(
199
- `Task ${i}: prompt is required unless action is 'close'/'list' or resumeFrom is set.`,
220
+ `${formatTaskRef(i, t.id)}: prompt is required unless sessionAction is 'close'/'list' or resumeFrom is set.`,
221
+ );
222
+ }
223
+
224
+ // Resolve tools — warn about unknown tool names.
225
+ // For active pooled sessions, fall back to the frozen pooled config so
226
+ // "continue with only sessionId" works without re-supplying tools.
227
+ // Explicit overrides that don't match get rejected by acquireAgentSession.
228
+ if (t.sessionAction !== "close" && t.sessionAction !== "list") {
229
+ tools = resolveToolGroups(
230
+ t.tools ??
231
+ agentOverride?.tools ??
232
+ agent?.tools ??
233
+ (isDefaultAgent ? parentNativeTools : undefined) ??
234
+ (isPoolHit ? pooledConfig?.tools : undefined) ??
235
+ DEFAULT_TOOLS,
236
+ );
237
+ const unknownTools = tools.filter(
238
+ (name) => !Object.hasOwn(TOOL_FACTORIES, name),
200
239
  );
240
+ if (unknownTools.length) {
241
+ warnings.push(
242
+ `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
243
+ );
244
+ }
245
+ tools = tools.filter((name) => Object.hasOwn(TOOL_FACTORIES, name));
201
246
  }
202
247
 
203
248
  // System prompt resolution. AgentSession's resource loader owns
@@ -206,21 +251,34 @@ export function resolveTasks(
206
251
  // explicit task prompt → named agent body → sanitized parent prompt →
207
252
  // default. The resolved base is passed as the loader's customPrompt (see
208
253
  // buildDelegateSession).
209
- // Keep explicit intent separate: a bare `{ prompt, sessionId }` continues
210
- // the frozen prompt even if the parent prompt has since changed, while an
211
- // explicit task/profile prompt must not be silently ignored on reuse.
254
+ //
255
+ // The *requested* system prompt is what the reuse check compares against the
256
+ // frozen pooled value. A bare `{ prompt, sessionId }` omits it so the pool
257
+ // can keep using the frozen prompt even if the parent prompt changed. An
258
+ // explicit task/profile prompt is used as-is. The built-in `default` profile
259
+ // intentionally mirrors the live parent, so its requested prompt is the
260
+ // *sanitized* parent prompt — the same form that would be stored as the
261
+ // frozen base, avoiding a false mismatch when the inherited default prompt
262
+ // gets its stale tool inventory stripped.
263
+ const resolvedBasePrompt = buildSubagentSystemPrompt({
264
+ taskSystemPrompt: t.systemPrompt,
265
+ agentSystemPrompt: agent?.systemPrompt,
266
+ parentSystemPrompt,
267
+ tools,
268
+ });
212
269
  const requestedSystemPrompt = t.systemPrompt?.trim()
213
270
  ? t.systemPrompt
214
271
  : agent?.systemPrompt?.trim()
215
272
  ? agent.systemPrompt
216
273
  : isDefaultAgent
217
- ? parentSystemPrompt
274
+ ? resolvedBasePrompt
218
275
  : undefined;
219
276
  const systemPrompt = buildSubagentSystemPrompt({
220
277
  taskSystemPrompt: t.systemPrompt,
221
278
  agentSystemPrompt: agent?.systemPrompt,
222
279
  parentSystemPrompt,
223
280
  pooledSystemPrompt: pooledConfig?.systemPrompt,
281
+ tools,
224
282
  });
225
283
 
226
284
  // Build prompt — wrap with parent context if using with-parent-transcript
@@ -252,11 +310,9 @@ export function resolveTasks(
252
310
  let model: Model<Api> | undefined;
253
311
  let requestedModel: Model<Api> | undefined;
254
312
  let modelSuffix: ThinkingLevel | undefined;
255
- let tools: string[] = [];
256
313
  let thinking: ThinkingLevel = "off";
257
- const warnings: string[] = [];
258
314
 
259
- if (t.action !== "close" && t.action !== "list") {
315
+ if (t.sessionAction !== "close" && t.sessionAction !== "list") {
260
316
  // A pool hit always runs its frozen model, but an explicitly requested
261
317
  // task/profile model still has to be resolved so checkout can reject a
262
318
  // contradictory request rather than silently discarding it. Naming the
@@ -278,7 +334,7 @@ export function resolveTasks(
278
334
  modelSuffix = requested.strippedSuffix;
279
335
  if (!requestedModel) {
280
336
  throw new Error(
281
- `Task ${i}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
337
+ `${formatTaskRef(i, t.id)}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
282
338
  );
283
339
  }
284
340
  } else if (isDefaultAgent) {
@@ -305,11 +361,13 @@ export function resolveTasks(
305
361
  // it is honored only as a last-resort thinking default (see below).
306
362
  modelSuffix = resolvedRequest?.strippedSuffix;
307
363
 
308
- // If the task or settings explicitly set a model but it couldn't resolve, fail loudly
309
- const explicitRequest = t.model ?? agentOverride?.model;
364
+ // The selected model spec is explicit regardless of whether it came
365
+ // from the task, settings, or named-agent frontmatter. If it cannot
366
+ // resolve, fail loudly instead of silently falling back to the parent.
367
+ const explicitRequest = modelSpec;
310
368
  if (explicitRequest && !resolvedModel) {
311
369
  throw new Error(
312
- `Task ${i}: requested model '${explicitRequest}' is not available. Check provider config or remove the model field to use the parent model.`,
370
+ `${formatTaskRef(i, t.id)}: requested model '${explicitRequest}' is not available. Check provider config or remove the model field to use the parent model.`,
313
371
  );
314
372
  }
315
373
 
@@ -322,30 +380,7 @@ export function resolveTasks(
322
380
 
323
381
  if (!model) {
324
382
  throw new Error(
325
- `Task ${i}: no model available — parent session has no model set.`,
326
- );
327
- }
328
-
329
- // Resolve tools — warn about unknown tool names.
330
- // For active pooled sessions, fall back to the frozen pooled config so
331
- // "continue with only sessionId" works without re-supplying tools.
332
- // Explicit overrides that don't match get rejected by acquireAgentSession.
333
- const isPoolHit = pooledConfig !== undefined;
334
- const parentNativeTools = parentDefaults.tools.filter(
335
- (name) => name in TOOL_FACTORIES,
336
- );
337
- tools = resolveToolGroups(
338
- t.tools ??
339
- agentOverride?.tools ??
340
- agent?.tools ??
341
- (isDefaultAgent ? parentNativeTools : undefined) ??
342
- (isPoolHit ? pooledConfig?.tools : undefined) ??
343
- DEFAULT_TOOLS,
344
- );
345
- const unknownTools = tools.filter((name) => !(name in TOOL_FACTORIES));
346
- if (unknownTools.length) {
347
- warnings.push(
348
- `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
383
+ `${formatTaskRef(i, t.id)}: no model available — parent session has no model set.`,
349
384
  );
350
385
  }
351
386
 
@@ -381,6 +416,7 @@ export function resolveTasks(
381
416
  }
382
417
  return {
383
418
  ...t,
419
+ id: t.id,
384
420
  cwd,
385
421
  systemPrompt,
386
422
  model: model!,