@bermudi/pi-delegate 0.1.0 → 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.
package/schema.ts CHANGED
@@ -18,15 +18,29 @@ function StringEnum<const T extends readonly string[]>(
18
18
  });
19
19
  }
20
20
 
21
+ const TASK_ID_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
22
+ const TASK_ID_RE = new RegExp(TASK_ID_PATTERN);
23
+
21
24
  export const delegateTaskSchema = Type.Object({
25
+ id: Type.Optional(
26
+ Type.String({
27
+ pattern: TASK_ID_PATTERN,
28
+ minLength: 1,
29
+ maxLength: 64,
30
+ description:
31
+ "Optional task correlation key; 1-64 chars; A-Z a-z 0-9 . _ - only; duplicate ids rejected. Omit for index.",
32
+ }),
33
+ ),
22
34
  prompt: Type.Optional(
23
35
  Type.String({
24
- description: "Task prompt; omit only for close, list, or resumeFrom.",
36
+ description:
37
+ "Self-contained task prompt; fresh context cannot see this chat. Omit only for close, list, or resumeFrom.",
25
38
  }),
26
39
  ),
27
40
  agent: Type.Optional(
28
41
  Type.String({
29
- description: "Named agent profile; omit for an ad-hoc subagent.",
42
+ description:
43
+ "Use `default`: parent model/thinking/native tools/base prompt. Omit=ad-hoc; unknown fails call.",
30
44
  }),
31
45
  ),
32
46
  cwd: Type.Optional(
@@ -36,13 +50,14 @@ export const delegateTaskSchema = Type.Object({
36
50
  ),
37
51
  systemPrompt: Type.Optional(
38
52
  Type.String({
39
- description: "Base system prompt; AgentSession adds project resources.",
53
+ description:
54
+ "Base system prompt; project resources from the task cwd are added automatically.",
40
55
  }),
41
56
  ),
42
57
  context: Type.Optional(
43
58
  StringEnum(["fresh", "with-parent-transcript"], {
44
59
  description:
45
- "fresh omits parent transcript; with-parent-transcript copies it (token-expensive).",
60
+ "fresh omits this chat; with-parent-transcript copies it (token-expensive).",
46
61
  default: "fresh",
47
62
  }),
48
63
  ),
@@ -54,22 +69,22 @@ export const delegateTaskSchema = Type.Object({
54
69
  tools: Type.Optional(
55
70
  Type.Array(Type.String(), {
56
71
  description:
57
- "Omit to inherit; `*`=read/write/edit/bash (mutating); `ro`=read/grep/find/ls (read-only).",
72
+ "Names/presets: *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only). Ad-hoc defaults to *.",
58
73
  }),
59
74
  ),
60
75
  thinking: Type.Optional(
61
76
  StringEnum(VALID_THINKING_LEVELS, {
62
77
  description:
63
- "Thinking: off/minimal/low/medium/high/xhigh/max; defaults to agent/off.",
78
+ "Thinking: off/minimal/low/medium/high/xhigh/max; default=parent; others=agent/off.",
64
79
  }),
65
80
  ),
66
81
  sessionId: Type.Optional(
67
82
  Type.String({
68
83
  description:
69
- "Optional live pool key for multi-turn reuse; omit for one-shot tasks.",
84
+ "Live pool key for multi-turn reuse; omit for one-shot tasks.",
70
85
  }),
71
86
  ),
72
- action: Type.Optional(
87
+ sessionAction: Type.Optional(
73
88
  StringEnum(["prompt", "close", "list"], {
74
89
  description:
75
90
  "Session action; close needs sessionId; list shows active pooled sessions.",
@@ -82,21 +97,29 @@ export const delegateTaskSchema = Type.Object({
82
97
  "Exact absolute .jsonl session path from retry output; never a ticket ID.",
83
98
  }),
84
99
  ),
100
+ deadlineMs: Type.Optional(
101
+ Type.Number({
102
+ description:
103
+ "Wall-clock budget (ms) from run start after queueing. Cooperative abort; side effects remain. Omit disables.",
104
+ }),
105
+ ),
85
106
  });
86
107
 
87
- // Single source of truth for registration, generated help, and the
88
- // DelegateArguments/TaskDef projections in types.ts.
108
+ // Single source of truth for registration and generated help. The exported
109
+ // argument types in types.ts project this canonical schema and add deprecated
110
+ // `action` aliases as a type-only compatibility overlay; providers never see
111
+ // those legacy fields in this schema.
89
112
  export const delegateArgumentsSchema = Type.Object({
90
- action: Type.Optional(
113
+ ticketAction: Type.Optional(
91
114
  StringEnum(["poll", "cancel", "wait"], {
92
115
  description:
93
- "Ticket control: poll, cancel, or wait. Prefer wait; do not cancel for time.",
116
+ "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
94
117
  }),
95
118
  ),
96
119
  async: Type.Optional(
97
120
  Type.Boolean({
98
121
  description:
99
- "Detach work and return a ticket; results auto-deliver. Wait only when blocked.",
122
+ "Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
100
123
  default: false,
101
124
  }),
102
125
  ),
@@ -108,7 +131,7 @@ export const delegateArgumentsSchema = Type.Object({
108
131
  force: Type.Optional(
109
132
  Type.Boolean({
110
133
  description:
111
- "True cancels after preview; completed writes/commands remain.",
134
+ "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
112
135
  default: false,
113
136
  }),
114
137
  ),
@@ -116,47 +139,116 @@ export const delegateArgumentsSchema = Type.Object({
116
139
  Type.Number({
117
140
  minimum: 0,
118
141
  description:
119
- "How long wait blocks (ms); timeout does not cancel the ticket.",
142
+ "Bounds wait (ms); omit to block until settled. Timeout never cancels the ticket.",
120
143
  }),
121
144
  ),
122
145
  tasks: Type.Optional(
123
146
  Type.Array(delegateTaskSchema, {
124
147
  minItems: 0,
125
148
  description:
126
- "Fields in entries; tasks run concurrently; separate dependent/shared-file work. []=help.",
149
+ "Tasks share the real filesystem and run concurrently; separate dependent/shared-file work. []=full manual.",
127
150
  }),
128
151
  ),
129
152
  });
130
153
 
154
+ /** Fields that belong to a task entry. Models sometimes place these at the
155
+ * top level of the arguments; the shim folds them back into a single task. */
156
+ const TASK_FIELD_NAMES = [
157
+ "id",
158
+ "prompt",
159
+ "agent",
160
+ "cwd",
161
+ "systemPrompt",
162
+ "context",
163
+ "model",
164
+ "tools",
165
+ "thinking",
166
+ "sessionId",
167
+ "sessionAction",
168
+ "resumeFrom",
169
+ "deadlineMs",
170
+ ] as const;
171
+
172
+ /** Every field a task entry may carry. Anything else is a model mistake —
173
+ * e.g. `async` placed inside a task — and must fail loudly with a
174
+ * corrective message instead of being silently ignored (observed in the
175
+ * wild: a task-level `async: true` the caller believed had backgrounded
176
+ * the work while the call in fact ran synchronously). */
177
+ const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "sessionAction"]);
178
+
179
+ /** Top-level ticket actions the legacy `action` field may map to. */
180
+ const TICKET_ACTIONS = new Set(["poll", "cancel", "wait"]);
181
+
182
+ /** Session actions that are valid at the task level. A flat `action` at the
183
+ * top level may also fold into a wrapped task's `sessionAction`. */
184
+ const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
185
+
186
+ /** Every value the legacy `action` field can carry before it is normalized to
187
+ * `ticketAction` or `sessionAction`. */
188
+ const LEGACY_ACTIONS = new Set([...TICKET_ACTIONS, ...TASK_ACTIONS]);
189
+
131
190
  /** Validate the three operation modes after compatibility reshaping. */
132
191
  export function validateDelegateOperation(
133
192
  params: DelegateArguments,
134
193
  ): string | undefined {
194
+ const rawParams = params as Record<string, unknown>;
135
195
  const tasks = params.tasks ?? [];
136
- const isTicketControl = params.action !== undefined;
196
+
197
+ const hasLegacyAction = typeof rawParams.action === "string";
198
+ const hasTicketAction = params.ticketAction !== undefined;
199
+
200
+ if (hasLegacyAction) {
201
+ if (!LEGACY_ACTIONS.has(rawParams.action as string)) {
202
+ return `unknown action '${rawParams.action}'; valid ticket actions are poll/cancel/wait, valid session actions are prompt/close/list.`;
203
+ }
204
+ if (hasTicketAction) {
205
+ return "ambiguous: supply only ticketAction (or only legacy action), not both.";
206
+ }
207
+ if (TASK_ACTIONS.has(rawParams.action as string) && tasks.length > 0) {
208
+ return `legacy top-level action '${rawParams.action}' cannot be combined with an explicit tasks array; move it into the task's sessionAction or remove tasks.`;
209
+ }
210
+ }
211
+
212
+ const ticketAction: string | undefined =
213
+ params.ticketAction ??
214
+ (hasLegacyAction && TICKET_ACTIONS.has(rawParams.action as string)
215
+ ? (rawParams.action as string)
216
+ : undefined);
217
+
218
+ const isTicketControl = ticketAction !== undefined;
137
219
 
138
220
  if (isTicketControl) {
139
- if (params.tasks !== undefined || params.async === true) {
140
- return "ticket control cannot include tasks or async; call it separately.";
221
+ const topLevelTaskIntentFields = [...TASK_FIELD_NAMES, "tasks"] as const;
222
+ const taskIntentFields = topLevelTaskIntentFields.filter(
223
+ (field) => rawParams[field] !== undefined,
224
+ );
225
+ if (taskIntentFields.length) {
226
+ return `ticket control cannot be combined with task-intent field(s) ${taskIntentFields
227
+ .map((field) => `'${field}'`)
228
+ .join(", ")}; call it separately.`;
141
229
  }
142
- if (params.action !== "poll" && !params.ticket) {
143
- return `action '${params.action}' requires ticket.`;
230
+ if (params.async === true) {
231
+ return "ticket control cannot include async; call it separately.";
144
232
  }
145
- if (params.action !== "cancel" && params.force === true) {
146
- return "force is valid only with action 'cancel'.";
233
+ if (ticketAction !== "poll" && !params.ticket) {
234
+ return `ticketAction '${ticketAction}' requires ticket.`;
147
235
  }
148
- if (params.action !== "wait" && params.timeoutMs !== undefined) {
149
- return "timeoutMs is valid only with action 'wait'.";
236
+ if (ticketAction !== "cancel" && params.force === true) {
237
+ return "force is valid only with ticketAction 'cancel'.";
238
+ }
239
+ if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
240
+ return "timeoutMs is valid only with ticketAction 'wait'.";
150
241
  }
151
242
  return undefined;
152
243
  }
153
244
 
154
245
  if (params.ticket !== undefined) {
155
- return "ticket requires action 'poll', 'cancel', or 'wait'.";
246
+ return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
156
247
  }
157
- if (params.force === true) return "force is valid only with action 'cancel'.";
248
+ if (params.force === true)
249
+ return "force is valid only with ticketAction 'cancel'.";
158
250
  if (params.timeoutMs !== undefined) {
159
- return "timeoutMs is valid only with action 'wait'.";
251
+ return "timeoutMs is valid only with ticketAction 'wait'.";
160
252
  }
161
253
  if (!tasks.length) {
162
254
  return params.async === true
@@ -164,22 +256,88 @@ export function validateDelegateOperation(
164
256
  : undefined; // Intentional help request.
165
257
  }
166
258
 
259
+ // Reject mixed shapes: flat task fields at the top level alongside a
260
+ // nonempty tasks array. The normalize shim only wraps flat fields when
261
+ // there is no tasks array, so a mixed call silently lets tasks win —
262
+ // a model mistake that should fail loudly.
263
+ if (tasks.length > 0) {
264
+ const flatTaskFields = [
265
+ ...new Set([...TASK_FIELD_NAMES, "sessionAction", "action"]),
266
+ ].filter((field) => rawParams[field] !== undefined);
267
+ if (flatTaskFields.length) {
268
+ return `cannot mix top-level task field(s) ${flatTaskFields
269
+ .map((field) => `'${field}'`)
270
+ .join(
271
+ ", ",
272
+ )} with an explicit tasks array; move them into a task entry or remove tasks.`;
273
+ }
274
+ }
275
+
167
276
  for (const [index, task] of tasks.entries()) {
168
- if (task.action === "close") {
277
+ const rawTask = task as Record<string, unknown>;
278
+ const hasLegacyTaskAction = typeof rawTask.action === "string";
279
+ const hasSessionAction = task.sessionAction !== undefined;
280
+
281
+ if (hasLegacyTaskAction) {
282
+ if (!TASK_ACTIONS.has(rawTask.action as string)) {
283
+ return `task ${index + 1}: unknown action '${rawTask.action}'; valid session actions are prompt/close/list.`;
284
+ }
285
+ if (hasSessionAction) {
286
+ return `task ${index + 1}: ambiguous: supply only sessionAction (or only legacy action), not both.`;
287
+ }
288
+ }
289
+
290
+ const sessionAction: string | undefined =
291
+ task.sessionAction ??
292
+ (hasLegacyTaskAction && TASK_ACTIONS.has(rawTask.action as string)
293
+ ? (rawTask.action as string)
294
+ : undefined);
295
+
296
+ const unknownKeys = Object.keys(rawTask).filter((key) => {
297
+ if (VALID_TASK_KEYS.has(key)) return false;
298
+ if (key === "action" && sessionAction !== undefined) return false;
299
+ return true;
300
+ });
301
+ if (unknownKeys.length) {
302
+ const asyncHint = unknownKeys.includes("async")
303
+ ? " 'async' is a top-level flag; move it out of the task entry."
304
+ : "";
305
+ return (
306
+ `task ${index + 1}: unknown field(s) ${unknownKeys
307
+ .map((key) => `'${key}'`)
308
+ .join(", ")}.${asyncHint} ` +
309
+ `Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
310
+ );
311
+ }
312
+ if (rawTask.id !== undefined) {
313
+ if (typeof rawTask.id !== "string" || !TASK_ID_RE.test(rawTask.id)) {
314
+ return `task ${index + 1}: id must be 1-64 characters using only A-Z, a-z, 0-9, '.', '_', or '-'.`;
315
+ }
316
+ }
317
+ if (typeof rawTask.deadlineMs === "number" && !(rawTask.deadlineMs > 0)) {
318
+ return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
319
+ }
320
+ if (sessionAction === "close") {
169
321
  if (!task.sessionId) {
170
- return `task ${index + 1}: action 'close' requires sessionId.`;
322
+ return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
171
323
  }
172
- const extras = Object.keys(task).filter(
173
- (key) => key !== "action" && key !== "sessionId",
324
+ const extras = Object.keys(rawTask).filter(
325
+ (key) =>
326
+ key !== "sessionAction" &&
327
+ key !== "sessionId" &&
328
+ key !== "action" &&
329
+ key !== "id",
174
330
  );
175
331
  if (extras.length) {
176
- return `task ${index + 1}: action 'close' accepts only action and sessionId.`;
332
+ return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
177
333
  }
178
334
  }
179
- if (task.action === "list") {
180
- const extras = Object.keys(task).filter((key) => key !== "action");
335
+ if (sessionAction === "list") {
336
+ const extras = Object.keys(rawTask).filter(
337
+ (key) => key !== "sessionAction" && key !== "action" && key !== "id",
338
+ );
181
339
  if (extras.length) {
182
- return `task ${index + 1}: action 'list' accepts only action.`;
340
+ return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
183
341
  }
184
342
  }
185
343
  }
@@ -187,26 +345,6 @@ export function validateDelegateOperation(
187
345
  return undefined;
188
346
  }
189
347
 
190
- /** Fields that belong to a task entry. Models sometimes place these at the
191
- * top level of the arguments; the shim folds them back into a single task. */
192
- const TASK_FIELD_NAMES = [
193
- "prompt",
194
- "agent",
195
- "cwd",
196
- "systemPrompt",
197
- "context",
198
- "model",
199
- "tools",
200
- "thinking",
201
- "sessionId",
202
- "resumeFrom",
203
- ] as const;
204
-
205
- /** Actions that are only valid at task level. The top-level `action` is
206
- * ticket-scoped (poll/cancel/wait), so a flat close/list/prompt belongs to
207
- * the wrapped task. */
208
- const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
209
-
210
348
  function parseStringifiedArray(value: string): unknown[] | undefined {
211
349
  try {
212
350
  const parsed: unknown = JSON.parse(value);
@@ -233,9 +371,14 @@ function normalizeToolsField(value: string): unknown {
233
371
  * - `tasks` as a JSON string instead of an array;
234
372
  * - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
235
373
  * level instead of inside a `tasks` entry — wrapped into a single task;
236
- * - `tools` as a JSON string (or bare token) inside a task entry.
237
- * Skipped when a ticket action is in play. All other invalid input is left
238
- * for normal schema validation to reject loudly.
374
+ * - `tools` as a JSON string (or bare token) inside a task entry;
375
+ * - `agent: ""` inside a task entry treated as omitted (ad-hoc);
376
+ * - legacy `action` folded into `ticketAction` (top level) or `sessionAction`
377
+ * (per task) for runtime compatibility.
378
+ * Skipped when a ticket action is in play. Conflicts between the legacy
379
+ * `action` field and its canonical replacement are left for
380
+ * `validateDelegateOperation` to report. All other invalid input is left for
381
+ * normal schema validation to reject loudly.
239
382
  *
240
383
  * Silent by design: these rewrites are lossless re-shaping, so unlike the
241
384
  * model-suffix warning in task-resolution (which fires because thinking
@@ -252,11 +395,26 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
252
395
  if (parsed) record.tasks = parsed;
253
396
  }
254
397
 
398
+ // Legacy top-level `action` (ticket verb) → canonical `ticketAction`.
399
+ // If both are present, leave the conflict for validateDelegateOperation.
400
+ if (
401
+ typeof record.action === "string" &&
402
+ ["poll", "cancel", "wait"].includes(record.action)
403
+ ) {
404
+ if (record.ticketAction === undefined) {
405
+ record.ticketAction = record.action;
406
+ delete record.action;
407
+ }
408
+ }
409
+
255
410
  // Flat task fields at the top level → wrap into a single task. Only fires
256
411
  // when there is no usable tasks array and no ticket action (`ticket`,
257
412
  // poll/cancel/wait) — those calls are legitimately taskless.
258
413
  const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
259
414
  const isTicketAction =
415
+ record.ticketAction === "poll" ||
416
+ record.ticketAction === "cancel" ||
417
+ record.ticketAction === "wait" ||
260
418
  record.action === "poll" ||
261
419
  record.action === "cancel" ||
262
420
  record.action === "wait";
@@ -268,20 +426,51 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
268
426
  delete record[key];
269
427
  }
270
428
  }
429
+ // Canonical `sessionAction` at the top level folds into the wrapped task.
430
+ if (typeof record.sessionAction === "string") {
431
+ if (task.sessionAction === undefined) {
432
+ task.sessionAction = record.sessionAction;
433
+ }
434
+ delete record.sessionAction;
435
+ }
436
+ // Legacy top-level session `action` folds into the wrapped task's
437
+ // `sessionAction`. A conflict with an explicit `sessionAction` is left
438
+ // for validateDelegateOperation to report.
271
439
  if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
272
- task.action = record.action;
440
+ if (task.sessionAction === undefined) {
441
+ task.sessionAction = record.action;
442
+ } else {
443
+ task.action = record.action;
444
+ }
273
445
  delete record.action;
274
446
  }
275
447
  if (Object.keys(task).length > 0) record.tasks = [task];
276
448
  }
277
449
 
278
- // Stringified (or bare-token) `tools` inside task entries → real arrays.
450
+ // Per-entry recovery: stringified (or bare-token) `tools` → real arrays,
451
+ // `agent: ""` → omitted, and legacy `action` → `sessionAction`.
279
452
  if (Array.isArray(record.tasks)) {
280
453
  record.tasks = record.tasks.map((entry: unknown) => {
281
454
  if (!entry || typeof entry !== "object") return entry;
282
455
  const e = entry as Record<string, unknown>;
283
- if (typeof e.tools !== "string") return entry;
284
- return { ...e, tools: normalizeToolsField(e.tools) };
456
+ const rawTools = e.tools;
457
+ const fixAgent = e.agent === "";
458
+ const needsActionNorm =
459
+ typeof e.action === "string" &&
460
+ TASK_ACTIONS.has(e.action) &&
461
+ e.sessionAction === undefined;
462
+ if (typeof rawTools !== "string" && !fixAgent && !needsActionNorm)
463
+ return entry;
464
+ const out = { ...e };
465
+ if (typeof rawTools === "string") {
466
+ out.tools = normalizeToolsField(rawTools);
467
+ }
468
+ if (fixAgent) delete out.agent;
469
+ if (needsActionNorm) {
470
+ out.sessionAction = out.action;
471
+ delete out.action;
472
+ }
473
+ return out;
285
474
  });
286
475
  }
287
476