@bermudi/pi-delegate 0.1.10 → 0.1.12

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
@@ -7,6 +7,8 @@ import type { DelegateArguments } from "./types.ts";
7
7
  // `Type.Union([Type.Literal…])` keeps the literals but serializes as `anyOf`,
8
8
  // which some providers handle poorly. `Type.Unsafe` gives both: the wire
9
9
  // format stays `{ type: "string", enum: [...] }` and the type stays narrow.
10
+ // (TypeBox 0.34's `Type.Enum` targets numeric TS enums, not string arrays, so
11
+ // it is not a drop-in replacement here.)
10
12
  function StringEnum<const T extends readonly string[]>(
11
13
  values: T,
12
14
  options?: SchemaOptions,
@@ -104,61 +106,63 @@ export const delegateTaskSchema = Type.Object({
104
106
  }),
105
107
  ),
106
108
  workspace: Type.Optional(
107
- StringEnum(["shared", "scratch"], {
109
+ StringEnum(["shared", "scratch", "isolated"], {
108
110
  description:
109
- "shared source; scratch disposable copy, one-shot; not security isolation. Reviewer=scratch; others=shared.",
111
+ "shared; scratch discarded; isolated=sync one-shot Git apply; not security isolation. Reviewer=scratch.",
110
112
  }),
111
113
  ),
112
114
  });
113
115
 
114
116
  // Single source of truth for registration and generated help. The exported
115
- // argument types in types.ts project this canonical schema and add deprecated
116
- // `action` aliases as a type-only compatibility overlay; providers never see
117
- // those legacy fields in this schema.
118
- export const delegateArgumentsSchema = Type.Object({
119
- ticketAction: Type.Optional(
120
- StringEnum(["poll", "cancel", "wait"], {
121
- description:
122
- "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
123
- }),
124
- ),
125
- async: Type.Optional(
126
- Type.Boolean({
127
- description:
128
- "Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
129
- default: false,
130
- }),
131
- ),
132
- ticket: Type.Optional(
133
- Type.String({
134
- description: "Ticket ID; omit only when polling all tickets.",
135
- }),
136
- ),
137
- force: Type.Optional(
138
- Type.Boolean({
139
- description:
140
- "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
141
- default: false,
142
- }),
143
- ),
144
- timeoutMs: Type.Optional(
145
- Type.Number({
146
- minimum: 0,
147
- description:
148
- "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
149
- }),
150
- ),
151
- tasks: Type.Optional(
152
- Type.Array(delegateTaskSchema, {
153
- minItems: 0,
154
- description:
155
- "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
156
- }),
157
- ),
158
- });
117
+ // argument types in types.ts project this canonical schema; providers see
118
+ // only these fields.
119
+ export const delegateArgumentsSchema = Type.Object(
120
+ {
121
+ ticketAction: Type.Optional(
122
+ StringEnum(["poll", "cancel", "wait"], {
123
+ description:
124
+ "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
125
+ }),
126
+ ),
127
+ async: Type.Optional(
128
+ Type.Boolean({
129
+ description:
130
+ "Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
131
+ default: false,
132
+ }),
133
+ ),
134
+ ticket: Type.Optional(
135
+ Type.String({
136
+ description: "Ticket ID; omit only when polling all tickets.",
137
+ }),
138
+ ),
139
+ force: Type.Optional(
140
+ Type.Boolean({
141
+ description:
142
+ "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
143
+ default: false,
144
+ }),
145
+ ),
146
+ timeoutMs: Type.Optional(
147
+ Type.Number({
148
+ minimum: 0,
149
+ description:
150
+ "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
151
+ }),
152
+ ),
153
+ tasks: Type.Optional(
154
+ Type.Array(delegateTaskSchema, {
155
+ minItems: 0,
156
+ description:
157
+ "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
158
+ }),
159
+ ),
160
+ },
161
+ { additionalProperties: false },
162
+ );
159
163
 
160
- /** Fields that belong to a task entry. Models sometimes place these at the
161
- * top level of the arguments; the shim folds them back into a single task. */
164
+ /** Fields that belong to a task entry. Models sometimes place these at the top
165
+ * level of the arguments; the shim folds them back into a single task. */
162
166
  const TASK_FIELD_NAMES = [
163
167
  "id",
164
168
  "prompt",
@@ -181,52 +185,26 @@ const TASK_FIELD_NAMES = [
181
185
  * corrective message instead of being silently ignored (observed in the
182
186
  * wild: a task-level `async: true` the caller believed had backgrounded
183
187
  * the work while the call in fact ran synchronously). */
184
- const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "sessionAction"]);
185
-
186
- /** Top-level ticket actions the legacy `action` field may map to. */
187
- const TICKET_ACTIONS = new Set(["poll", "cancel", "wait"]);
188
-
189
- /** Session actions that are valid at the task level. A flat `action` at the
190
- * top level may also fold into a wrapped task's `sessionAction`. */
191
- const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
192
-
193
- /** Every value the legacy `action` field can carry before it is normalized to
194
- * `ticketAction` or `sessionAction`. */
195
- const LEGACY_ACTIONS = new Set([...TICKET_ACTIONS, ...TASK_ACTIONS]);
188
+ const VALID_TASK_KEYS = new Set<string>(TASK_FIELD_NAMES);
196
189
 
197
190
  /** Validate the three operation modes after compatibility reshaping. */
198
191
  export function validateDelegateOperation(
199
192
  params: DelegateArguments,
200
193
  ): string | undefined {
201
194
  const rawParams = params as Record<string, unknown>;
202
- const tasks = params.tasks ?? [];
203
-
204
- const hasLegacyAction = typeof rawParams.action === "string";
205
- const hasTicketAction = params.ticketAction !== undefined;
206
-
207
- if (hasLegacyAction) {
208
- if (!LEGACY_ACTIONS.has(rawParams.action as string)) {
209
- return `unknown action '${rawParams.action}'; valid ticket actions are poll/cancel/wait, valid session actions are prompt/close/list.`;
210
- }
211
- if (hasTicketAction) {
212
- return "ambiguous: supply only ticketAction (or only legacy action), not both.";
213
- }
214
- if (TASK_ACTIONS.has(rawParams.action as string) && tasks.length > 0) {
215
- 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.`;
216
- }
195
+ if ("action" in rawParams) {
196
+ return (
197
+ "unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
198
+ "or 'sessionAction' for prompt/close/list."
199
+ );
217
200
  }
201
+ const tasks = params.tasks ?? [];
218
202
 
219
- const ticketAction: string | undefined =
220
- params.ticketAction ??
221
- (hasLegacyAction && TICKET_ACTIONS.has(rawParams.action as string)
222
- ? (rawParams.action as string)
223
- : undefined);
224
-
203
+ const ticketAction = params.ticketAction;
225
204
  const isTicketControl = ticketAction !== undefined;
226
205
 
227
206
  if (isTicketControl) {
228
- const topLevelTaskIntentFields = [...TASK_FIELD_NAMES, "tasks"] as const;
229
- const taskIntentFields = topLevelTaskIntentFields.filter(
207
+ const taskIntentFields = ([...TASK_FIELD_NAMES, "tasks"] as const).filter(
230
208
  (field) => rawParams[field] !== undefined,
231
209
  );
232
210
  if (taskIntentFields.length) {
@@ -263,14 +241,18 @@ export function validateDelegateOperation(
263
241
  : undefined; // Intentional help request.
264
242
  }
265
243
 
244
+ if (params.async && tasks.some((task) => task.workspace === "isolated")) {
245
+ return 'workspace "isolated" is synchronous; remove async.';
246
+ }
247
+
266
248
  // Reject mixed shapes: flat task fields at the top level alongside a
267
249
  // nonempty tasks array. The normalize shim only wraps flat fields when
268
250
  // there is no tasks array, so a mixed call silently lets tasks win —
269
251
  // a model mistake that should fail loudly.
270
252
  if (tasks.length > 0) {
271
- const flatTaskFields = [
272
- ...new Set([...TASK_FIELD_NAMES, "sessionAction", "action"]),
273
- ].filter((field) => rawParams[field] !== undefined);
253
+ const flatTaskFields = TASK_FIELD_NAMES.filter(
254
+ (field) => rawParams[field] !== undefined,
255
+ );
274
256
  if (flatTaskFields.length) {
275
257
  return `cannot mix top-level task field(s) ${flatTaskFields
276
258
  .map((field) => `'${field}'`)
@@ -282,37 +264,20 @@ export function validateDelegateOperation(
282
264
 
283
265
  for (const [index, task] of tasks.entries()) {
284
266
  const rawTask = task as Record<string, unknown>;
285
- const hasLegacyTaskAction = typeof rawTask.action === "string";
286
- const hasSessionAction = task.sessionAction !== undefined;
267
+ const sessionAction = task.sessionAction;
287
268
 
288
- if (hasLegacyTaskAction) {
289
- if (!TASK_ACTIONS.has(rawTask.action as string)) {
290
- return `task ${index + 1}: unknown action '${rawTask.action}'; valid session actions are prompt/close/list.`;
291
- }
292
- if (hasSessionAction) {
293
- return `task ${index + 1}: ambiguous: supply only sessionAction (or only legacy action), not both.`;
294
- }
295
- }
296
-
297
- const sessionAction: string | undefined =
298
- task.sessionAction ??
299
- (hasLegacyTaskAction && TASK_ACTIONS.has(rawTask.action as string)
300
- ? (rawTask.action as string)
301
- : undefined);
302
-
303
- const unknownKeys = Object.keys(rawTask).filter((key) => {
304
- if (VALID_TASK_KEYS.has(key)) return false;
305
- if (key === "action" && sessionAction !== undefined) return false;
306
- return true;
307
- });
269
+ const unknownKeys = Object.keys(rawTask).filter(
270
+ (key) => !VALID_TASK_KEYS.has(key),
271
+ );
308
272
  if (unknownKeys.length) {
309
- const asyncHint = unknownKeys.includes("async")
310
- ? " 'async' is a top-level flag; move it out of the task entry."
273
+ const misplacedTopLevel = unknownKeys.filter((key) => key === "async");
274
+ const topLevelHint = misplacedTopLevel.length
275
+ ? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level flag${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
311
276
  : "";
312
277
  return (
313
278
  `task ${index + 1}: unknown field(s) ${unknownKeys
314
279
  .map((key) => `'${key}'`)
315
- .join(", ")}.${asyncHint} ` +
280
+ .join(", ")}.${topLevelHint} ` +
316
281
  `Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
317
282
  );
318
283
  }
@@ -325,22 +290,17 @@ export function validateDelegateOperation(
325
290
  return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
326
291
  }
327
292
  if (
328
- task.workspace === "scratch" &&
329
- !task.agent &&
293
+ (task.workspace === "scratch" || task.workspace === "isolated") &&
330
294
  (task.sessionId || task.resumeFrom || sessionAction !== undefined)
331
295
  ) {
332
- return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
296
+ return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
333
297
  }
334
298
  if (sessionAction === "close") {
335
299
  if (!task.sessionId) {
336
300
  return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
337
301
  }
338
302
  const extras = Object.keys(rawTask).filter(
339
- (key) =>
340
- key !== "sessionAction" &&
341
- key !== "sessionId" &&
342
- key !== "action" &&
343
- key !== "id",
303
+ (key) => key !== "sessionAction" && key !== "sessionId" && key !== "id",
344
304
  );
345
305
  if (extras.length) {
346
306
  return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
@@ -348,7 +308,7 @@ export function validateDelegateOperation(
348
308
  }
349
309
  if (sessionAction === "list") {
350
310
  const extras = Object.keys(rawTask).filter(
351
- (key) => key !== "sessionAction" && key !== "action" && key !== "id",
311
+ (key) => key !== "sessionAction" && key !== "id",
352
312
  );
353
313
  if (extras.length) {
354
314
  return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
@@ -378,6 +338,52 @@ function normalizeToolsField(value: string): unknown {
378
338
  return trimmed && !/[\s,]/.test(trimmed) ? [trimmed] : value;
379
339
  }
380
340
 
341
+ /** True when `record` carries a top-level ticket-control intent that makes a
342
+ * flat task-field wrap illegitimate: an explicit `ticketAction`, or a bare
343
+ * `ticket` id (which only makes sense with poll/cancel/wait). */
344
+ function hasTicketControlIntent(record: Record<string, unknown>): boolean {
345
+ return (
346
+ record.ticketAction === "poll" ||
347
+ record.ticketAction === "cancel" ||
348
+ record.ticketAction === "wait" ||
349
+ record.ticket !== undefined
350
+ );
351
+ }
352
+
353
+ /** Fold top-level task fields into a single `tasks` entry. Only fires when
354
+ * there is no usable tasks array and no ticket-control intent — those calls
355
+ * are legitimately taskless. `sessionAction` is part of `TASK_FIELD_NAMES`,
356
+ * so a top-level `sessionAction` rides along into the wrapped task. */
357
+ function wrapFlatTaskFields(record: Record<string, unknown>): void {
358
+ const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
359
+ if (hasTasks || hasTicketControlIntent(record)) return;
360
+ const task: Record<string, unknown> = {};
361
+ for (const key of TASK_FIELD_NAMES) {
362
+ if (record[key] !== undefined) {
363
+ task[key] = record[key];
364
+ delete record[key];
365
+ }
366
+ }
367
+ if (Object.keys(task).length > 0) record.tasks = [task];
368
+ }
369
+
370
+ /** Per-entry recovery for one task: stringified (or bare-token) `tools` → a
371
+ * real array, and `agent: ""` → omitted (ad-hoc). Other malformed input is
372
+ * left for schema validation to reject loudly. */
373
+ function normalizeTaskEntry(entry: unknown): unknown {
374
+ if (!entry || typeof entry !== "object") return entry;
375
+ const e = entry as Record<string, unknown>;
376
+ const rawTools = e.tools;
377
+ const fixAgent = e.agent === "";
378
+ if (typeof rawTools !== "string" && !fixAgent) return entry;
379
+ const out = { ...e };
380
+ if (typeof rawTools === "string") {
381
+ out.tools = normalizeToolsField(rawTools);
382
+ }
383
+ if (fixAgent) delete out.agent;
384
+ return out;
385
+ }
386
+
381
387
  /** Compatibility shim run by pi before schema validation. Recovers the
382
388
  * malformed shapes weaker models emit, instead of letting them silently
383
389
  * degrade to the help response (an empty `tasks` returns the manual, which
@@ -386,13 +392,9 @@ function normalizeToolsField(value: string): unknown {
386
392
  * - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
387
393
  * level instead of inside a `tasks` entry — wrapped into a single task;
388
394
  * - `tools` as a JSON string (or bare token) inside a task entry;
389
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
390
- * - legacy `action` folded into `ticketAction` (top level) or `sessionAction`
391
- * (per task) for runtime compatibility.
392
- * Skipped when a ticket action is in play. Conflicts between the legacy
393
- * `action` field and its canonical replacement are left for
394
- * `validateDelegateOperation` to report. All other invalid input is left for
395
- * normal schema validation to reject loudly.
395
+ * - `agent: ""` inside a task entry — treated as omitted (ad-hoc).
396
+ * All other invalid input is left for normal schema validation to reject
397
+ * loudly.
396
398
  *
397
399
  * Silent by design: these rewrites are lossless re-shaping, so unlike the
398
400
  * model-suffix warning in task-resolution (which fires because thinking
@@ -409,83 +411,12 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
409
411
  if (parsed) record.tasks = parsed;
410
412
  }
411
413
 
412
- // Legacy top-level `action` (ticket verb) canonical `ticketAction`.
413
- // If both are present, leave the conflict for validateDelegateOperation.
414
- if (
415
- typeof record.action === "string" &&
416
- ["poll", "cancel", "wait"].includes(record.action)
417
- ) {
418
- if (record.ticketAction === undefined) {
419
- record.ticketAction = record.action;
420
- delete record.action;
421
- }
422
- }
414
+ // Flat task fields at the top level wrap into a single task.
415
+ wrapFlatTaskFields(record);
423
416
 
424
- // Flat task fields at the top level → wrap into a single task. Only fires
425
- // when there is no usable tasks array and no ticket action (`ticket`,
426
- // poll/cancel/wait) — those calls are legitimately taskless.
427
- const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
428
- const isTicketAction =
429
- record.ticketAction === "poll" ||
430
- record.ticketAction === "cancel" ||
431
- record.ticketAction === "wait" ||
432
- record.action === "poll" ||
433
- record.action === "cancel" ||
434
- record.action === "wait";
435
- if (!hasTasks && !isTicketAction && record.ticket === undefined) {
436
- const task: Record<string, unknown> = {};
437
- for (const key of TASK_FIELD_NAMES) {
438
- if (record[key] !== undefined) {
439
- task[key] = record[key];
440
- delete record[key];
441
- }
442
- }
443
- // Canonical `sessionAction` at the top level folds into the wrapped task.
444
- if (typeof record.sessionAction === "string") {
445
- if (task.sessionAction === undefined) {
446
- task.sessionAction = record.sessionAction;
447
- }
448
- delete record.sessionAction;
449
- }
450
- // Legacy top-level session `action` folds into the wrapped task's
451
- // `sessionAction`. A conflict with an explicit `sessionAction` is left
452
- // for validateDelegateOperation to report.
453
- if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
454
- if (task.sessionAction === undefined) {
455
- task.sessionAction = record.action;
456
- } else {
457
- task.action = record.action;
458
- }
459
- delete record.action;
460
- }
461
- if (Object.keys(task).length > 0) record.tasks = [task];
462
- }
463
-
464
- // Per-entry recovery: stringified (or bare-token) `tools` → real arrays,
465
- // `agent: ""` → omitted, and legacy `action` → `sessionAction`.
417
+ // Per-entry recovery: stringified/bare-token `tools` and `agent: ""`.
466
418
  if (Array.isArray(record.tasks)) {
467
- record.tasks = record.tasks.map((entry: unknown) => {
468
- if (!entry || typeof entry !== "object") return entry;
469
- const e = entry as Record<string, unknown>;
470
- const rawTools = e.tools;
471
- const fixAgent = e.agent === "";
472
- const needsActionNorm =
473
- typeof e.action === "string" &&
474
- TASK_ACTIONS.has(e.action) &&
475
- e.sessionAction === undefined;
476
- if (typeof rawTools !== "string" && !fixAgent && !needsActionNorm)
477
- return entry;
478
- const out = { ...e };
479
- if (typeof rawTools === "string") {
480
- out.tools = normalizeToolsField(rawTools);
481
- }
482
- if (fixAgent) delete out.agent;
483
- if (needsActionNorm) {
484
- out.sessionAction = out.action;
485
- delete out.action;
486
- }
487
- return out;
488
- });
419
+ record.tasks = record.tasks.map(normalizeTaskEntry);
489
420
  }
490
421
 
491
422
  return record as DelegateArguments;