@bermudi/pi-delegate 0.1.1 → 0.1.3
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 +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +90 -10
- package/config.ts +61 -0
- package/delegate.ts +14 -0
- package/dispatch.ts +126 -21
- package/extension.ts +309 -62
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +129 -35
- package/manual.ts +38 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +204 -47
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/telemetry.ts +738 -0
- package/tickets.ts +196 -61
- package/tools.ts +16 -15
- package/types.ts +71 -13
- package/usage.ts +19 -0
package/schema.ts
CHANGED
|
@@ -18,10 +18,23 @@ 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:
|
|
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(
|
|
@@ -37,13 +50,14 @@ export const delegateTaskSchema = Type.Object({
|
|
|
37
50
|
),
|
|
38
51
|
systemPrompt: Type.Optional(
|
|
39
52
|
Type.String({
|
|
40
|
-
description:
|
|
53
|
+
description:
|
|
54
|
+
"Base system prompt; project resources from the task cwd are added automatically.",
|
|
41
55
|
}),
|
|
42
56
|
),
|
|
43
57
|
context: Type.Optional(
|
|
44
58
|
StringEnum(["fresh", "with-parent-transcript"], {
|
|
45
59
|
description:
|
|
46
|
-
"fresh omits
|
|
60
|
+
"fresh omits this chat; with-parent-transcript copies it (token-expensive).",
|
|
47
61
|
default: "fresh",
|
|
48
62
|
}),
|
|
49
63
|
),
|
|
@@ -55,7 +69,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
55
69
|
tools: Type.Optional(
|
|
56
70
|
Type.Array(Type.String(), {
|
|
57
71
|
description:
|
|
58
|
-
"
|
|
72
|
+
"Names/presets: *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only). Ad-hoc defaults to *.",
|
|
59
73
|
}),
|
|
60
74
|
),
|
|
61
75
|
thinking: Type.Optional(
|
|
@@ -70,7 +84,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
70
84
|
"Live pool key for multi-turn reuse; omit for one-shot tasks.",
|
|
71
85
|
}),
|
|
72
86
|
),
|
|
73
|
-
|
|
87
|
+
sessionAction: Type.Optional(
|
|
74
88
|
StringEnum(["prompt", "close", "list"], {
|
|
75
89
|
description:
|
|
76
90
|
"Session action; close needs sessionId; list shows active pooled sessions.",
|
|
@@ -83,12 +97,20 @@ export const delegateTaskSchema = Type.Object({
|
|
|
83
97
|
"Exact absolute .jsonl session path from retry output; never a ticket ID.",
|
|
84
98
|
}),
|
|
85
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
|
+
),
|
|
86
106
|
});
|
|
87
107
|
|
|
88
|
-
// Single source of truth for registration
|
|
89
|
-
//
|
|
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.
|
|
90
112
|
export const delegateArgumentsSchema = Type.Object({
|
|
91
|
-
|
|
113
|
+
ticketAction: Type.Optional(
|
|
92
114
|
StringEnum(["poll", "cancel", "wait"], {
|
|
93
115
|
description:
|
|
94
116
|
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
@@ -109,7 +131,7 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
109
131
|
force: Type.Optional(
|
|
110
132
|
Type.Boolean({
|
|
111
133
|
description:
|
|
112
|
-
"
|
|
134
|
+
"With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
|
|
113
135
|
default: false,
|
|
114
136
|
}),
|
|
115
137
|
),
|
|
@@ -117,14 +139,14 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
117
139
|
Type.Number({
|
|
118
140
|
minimum: 0,
|
|
119
141
|
description:
|
|
120
|
-
"Bounds wait (ms); omit to block until settled. Timeout
|
|
142
|
+
"Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
|
|
121
143
|
}),
|
|
122
144
|
),
|
|
123
145
|
tasks: Type.Optional(
|
|
124
146
|
Type.Array(delegateTaskSchema, {
|
|
125
147
|
minItems: 0,
|
|
126
148
|
description:
|
|
127
|
-
"
|
|
149
|
+
"Tasks share the real filesystem and run concurrently; separate dependent/shared-file work. []=full manual.",
|
|
128
150
|
}),
|
|
129
151
|
),
|
|
130
152
|
});
|
|
@@ -132,6 +154,7 @@ export const delegateArgumentsSchema = Type.Object({
|
|
|
132
154
|
/** Fields that belong to a task entry. Models sometimes place these at the
|
|
133
155
|
* top level of the arguments; the shim folds them back into a single task. */
|
|
134
156
|
const TASK_FIELD_NAMES = [
|
|
157
|
+
"id",
|
|
135
158
|
"prompt",
|
|
136
159
|
"agent",
|
|
137
160
|
"cwd",
|
|
@@ -141,7 +164,9 @@ const TASK_FIELD_NAMES = [
|
|
|
141
164
|
"tools",
|
|
142
165
|
"thinking",
|
|
143
166
|
"sessionId",
|
|
167
|
+
"sessionAction",
|
|
144
168
|
"resumeFrom",
|
|
169
|
+
"deadlineMs",
|
|
145
170
|
] as const;
|
|
146
171
|
|
|
147
172
|
/** Every field a task entry may carry. Anything else is a model mistake —
|
|
@@ -149,37 +174,81 @@ const TASK_FIELD_NAMES = [
|
|
|
149
174
|
* corrective message instead of being silently ignored (observed in the
|
|
150
175
|
* wild: a task-level `async: true` the caller believed had backgrounded
|
|
151
176
|
* the work while the call in fact ran synchronously). */
|
|
152
|
-
const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "
|
|
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]);
|
|
153
189
|
|
|
154
190
|
/** Validate the three operation modes after compatibility reshaping. */
|
|
155
191
|
export function validateDelegateOperation(
|
|
156
192
|
params: DelegateArguments,
|
|
157
193
|
): string | undefined {
|
|
194
|
+
const rawParams = params as Record<string, unknown>;
|
|
158
195
|
const tasks = params.tasks ?? [];
|
|
159
|
-
|
|
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;
|
|
160
219
|
|
|
161
220
|
if (isTicketControl) {
|
|
162
|
-
|
|
163
|
-
|
|
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.`;
|
|
229
|
+
}
|
|
230
|
+
if (params.async === true) {
|
|
231
|
+
return "ticket control cannot include async; call it separately.";
|
|
164
232
|
}
|
|
165
|
-
if (
|
|
166
|
-
return `
|
|
233
|
+
if (ticketAction !== "poll" && !params.ticket) {
|
|
234
|
+
return `ticketAction '${ticketAction}' requires ticket.`;
|
|
167
235
|
}
|
|
168
|
-
if (
|
|
169
|
-
return "force is valid only with
|
|
236
|
+
if (ticketAction !== "cancel" && params.force === true) {
|
|
237
|
+
return "force is valid only with ticketAction 'cancel'.";
|
|
170
238
|
}
|
|
171
|
-
if (
|
|
172
|
-
return "timeoutMs is valid only with
|
|
239
|
+
if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
|
|
240
|
+
return "timeoutMs is valid only with ticketAction 'wait'.";
|
|
173
241
|
}
|
|
174
242
|
return undefined;
|
|
175
243
|
}
|
|
176
244
|
|
|
177
245
|
if (params.ticket !== undefined) {
|
|
178
|
-
return "ticket requires
|
|
246
|
+
return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
|
|
179
247
|
}
|
|
180
|
-
if (params.force === true)
|
|
248
|
+
if (params.force === true)
|
|
249
|
+
return "force is valid only with ticketAction 'cancel'.";
|
|
181
250
|
if (params.timeoutMs !== undefined) {
|
|
182
|
-
return "timeoutMs is valid only with
|
|
251
|
+
return "timeoutMs is valid only with ticketAction 'wait'.";
|
|
183
252
|
}
|
|
184
253
|
if (!tasks.length) {
|
|
185
254
|
return params.async === true
|
|
@@ -187,10 +256,48 @@ export function validateDelegateOperation(
|
|
|
187
256
|
: undefined; // Intentional help request.
|
|
188
257
|
}
|
|
189
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
|
+
|
|
190
276
|
for (const [index, task] of tasks.entries()) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
});
|
|
194
301
|
if (unknownKeys.length) {
|
|
195
302
|
const asyncHint = unknownKeys.includes("async")
|
|
196
303
|
? " 'async' is a top-level flag; move it out of the task entry."
|
|
@@ -202,21 +309,35 @@ export function validateDelegateOperation(
|
|
|
202
309
|
`Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
|
|
203
310
|
);
|
|
204
311
|
}
|
|
205
|
-
if (
|
|
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") {
|
|
206
321
|
if (!task.sessionId) {
|
|
207
|
-
return `task ${index + 1}:
|
|
322
|
+
return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
|
|
208
323
|
}
|
|
209
|
-
const extras = Object.keys(
|
|
210
|
-
(key) =>
|
|
324
|
+
const extras = Object.keys(rawTask).filter(
|
|
325
|
+
(key) =>
|
|
326
|
+
key !== "sessionAction" &&
|
|
327
|
+
key !== "sessionId" &&
|
|
328
|
+
key !== "action" &&
|
|
329
|
+
key !== "id",
|
|
211
330
|
);
|
|
212
331
|
if (extras.length) {
|
|
213
|
-
return `task ${index + 1}:
|
|
332
|
+
return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
|
|
214
333
|
}
|
|
215
334
|
}
|
|
216
|
-
if (
|
|
217
|
-
const extras = Object.keys(
|
|
335
|
+
if (sessionAction === "list") {
|
|
336
|
+
const extras = Object.keys(rawTask).filter(
|
|
337
|
+
(key) => key !== "sessionAction" && key !== "action" && key !== "id",
|
|
338
|
+
);
|
|
218
339
|
if (extras.length) {
|
|
219
|
-
return `task ${index + 1}:
|
|
340
|
+
return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
|
|
220
341
|
}
|
|
221
342
|
}
|
|
222
343
|
}
|
|
@@ -224,11 +345,6 @@ export function validateDelegateOperation(
|
|
|
224
345
|
return undefined;
|
|
225
346
|
}
|
|
226
347
|
|
|
227
|
-
/** Actions that are only valid at task level. The top-level `action` is
|
|
228
|
-
* ticket-scoped (poll/cancel/wait), so a flat close/list/prompt belongs to
|
|
229
|
-
* the wrapped task. */
|
|
230
|
-
const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
|
|
231
|
-
|
|
232
348
|
function parseStringifiedArray(value: string): unknown[] | undefined {
|
|
233
349
|
try {
|
|
234
350
|
const parsed: unknown = JSON.parse(value);
|
|
@@ -256,9 +372,13 @@ function normalizeToolsField(value: string): unknown {
|
|
|
256
372
|
* - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
|
|
257
373
|
* level instead of inside a `tasks` entry — wrapped into a single task;
|
|
258
374
|
* - `tools` as a JSON string (or bare token) inside a task entry;
|
|
259
|
-
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc)
|
|
260
|
-
*
|
|
261
|
-
* for
|
|
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.
|
|
262
382
|
*
|
|
263
383
|
* Silent by design: these rewrites are lossless re-shaping, so unlike the
|
|
264
384
|
* model-suffix warning in task-resolution (which fires because thinking
|
|
@@ -275,11 +395,26 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
|
|
|
275
395
|
if (parsed) record.tasks = parsed;
|
|
276
396
|
}
|
|
277
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
|
+
|
|
278
410
|
// Flat task fields at the top level → wrap into a single task. Only fires
|
|
279
411
|
// when there is no usable tasks array and no ticket action (`ticket`,
|
|
280
412
|
// poll/cancel/wait) — those calls are legitimately taskless.
|
|
281
413
|
const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
|
|
282
414
|
const isTicketAction =
|
|
415
|
+
record.ticketAction === "poll" ||
|
|
416
|
+
record.ticketAction === "cancel" ||
|
|
417
|
+
record.ticketAction === "wait" ||
|
|
283
418
|
record.action === "poll" ||
|
|
284
419
|
record.action === "cancel" ||
|
|
285
420
|
record.action === "wait";
|
|
@@ -291,28 +426,50 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
|
|
|
291
426
|
delete record[key];
|
|
292
427
|
}
|
|
293
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.
|
|
294
439
|
if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
|
|
295
|
-
task.
|
|
440
|
+
if (task.sessionAction === undefined) {
|
|
441
|
+
task.sessionAction = record.action;
|
|
442
|
+
} else {
|
|
443
|
+
task.action = record.action;
|
|
444
|
+
}
|
|
296
445
|
delete record.action;
|
|
297
446
|
}
|
|
298
447
|
if (Object.keys(task).length > 0) record.tasks = [task];
|
|
299
448
|
}
|
|
300
449
|
|
|
301
450
|
// Per-entry recovery: stringified (or bare-token) `tools` → real arrays,
|
|
302
|
-
//
|
|
303
|
-
// without this it only works by truthiness accident downstream).
|
|
451
|
+
// `agent: ""` → omitted, and legacy `action` → `sessionAction`.
|
|
304
452
|
if (Array.isArray(record.tasks)) {
|
|
305
453
|
record.tasks = record.tasks.map((entry: unknown) => {
|
|
306
454
|
if (!entry || typeof entry !== "object") return entry;
|
|
307
455
|
const e = entry as Record<string, unknown>;
|
|
308
456
|
const rawTools = e.tools;
|
|
309
457
|
const fixAgent = e.agent === "";
|
|
310
|
-
|
|
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;
|
|
311
464
|
const out = { ...e };
|
|
312
465
|
if (typeof rawTools === "string") {
|
|
313
466
|
out.tools = normalizeToolsField(rawTools);
|
|
314
467
|
}
|
|
315
468
|
if (fixAgent) delete out.agent;
|
|
469
|
+
if (needsActionNorm) {
|
|
470
|
+
out.sessionAction = out.action;
|
|
471
|
+
delete out.action;
|
|
472
|
+
}
|
|
316
473
|
return out;
|
|
317
474
|
});
|
|
318
475
|
}
|
package/status.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* active ticket — the moment a user is most likely to assume everything
|
|
14
14
|
* is done and close the session.
|
|
15
15
|
* 3. A confirm guard on the session-replacement paths pi lets extensions
|
|
16
|
-
* cancel (`session_before_switch`, `session_before_fork`)
|
|
16
|
+
* cancel (`session_before_switch`, `session_before_fork`), plus a distinct
|
|
17
|
+
* prompt for `/tree` navigation, which re-targets results rather than
|
|
18
|
+
* killing them (see `guardTreeNavigation`).
|
|
19
|
+
* 4. A notification when a ticket completes after the session navigated away
|
|
20
|
+
* from its spawn leaf: delivery is downgraded to non-waking, so without
|
|
21
|
+
* this the human gets no signal that the ticket finished at all.
|
|
17
22
|
*
|
|
18
23
|
* Quit (Ctrl+C×2 / Ctrl+D / /quit) and /reload CANNOT be intercepted from an
|
|
19
24
|
* extension — `session_shutdown` is advisory, not cancellable. The footer
|
|
@@ -21,7 +26,7 @@
|
|
|
21
26
|
* there.
|
|
22
27
|
*/
|
|
23
28
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
-
import { ticketRegistry } from "./tickets.ts";
|
|
29
|
+
import { requestTicketCancel, ticketRegistry } from "./tickets.ts";
|
|
25
30
|
import type { AsyncTicket } from "./types.ts";
|
|
26
31
|
|
|
27
32
|
const STATUS_KEY = "delegate";
|
|
@@ -178,6 +183,67 @@ export async function guardSessionReplacement(
|
|
|
178
183
|
return proceed ? undefined : { cancel: true };
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
/** `/tree` navigation is not a session replacement: the runtime survives and
|
|
187
|
+
* the subagents keep running, but the eventual result no longer belongs to
|
|
188
|
+
* the branch the user is on. Offer the three honest outcomes instead of the
|
|
189
|
+
* destructive switch/fork confirm. Dismissal keeps the user where they are —
|
|
190
|
+
* the conservative choice, since navigating is what creates the hazard.
|
|
191
|
+
*
|
|
192
|
+
* This guard is UX, not the correctness mechanism: navigation that never
|
|
193
|
+
* reaches it (dismissed dialog, headless ctx, `ctx.navigateTree` from another
|
|
194
|
+
* extension) is still handled at delivery time via leaf affinity. */
|
|
195
|
+
export async function guardTreeNavigation(
|
|
196
|
+
ctx: ExtensionContext,
|
|
197
|
+
): Promise<{ cancel: true } | undefined> {
|
|
198
|
+
lastCtx = ctx;
|
|
199
|
+
const summary = activeTicketSummary();
|
|
200
|
+
if (!summary.tickets.length || !ctx.hasUI) return undefined;
|
|
201
|
+
|
|
202
|
+
const ids = summary.tickets.map((t) => t.id).join(", ");
|
|
203
|
+
const hold = "Navigate — hold results (poll to read them)";
|
|
204
|
+
const cancel = "Navigate — cancel the background subagents";
|
|
205
|
+
const stay = "Stay on this branch";
|
|
206
|
+
let choice: string | undefined;
|
|
207
|
+
try {
|
|
208
|
+
choice = await ctx.ui.select(
|
|
209
|
+
`${plural(summary.activeSubagents, "background subagent")} (${ids}) still running — ` +
|
|
210
|
+
"navigating means their results arrive on a different branch",
|
|
211
|
+
[hold, cancel, stay],
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
// pi does not surface handler rejections, so a throwing dialog (stale ctx,
|
|
215
|
+
// TUI failure) would become an unhandled rejection. Let the navigation
|
|
216
|
+
// through rather than trapping the user: leaf affinity at delivery time is
|
|
217
|
+
// the correctness mechanism, not this prompt.
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (choice === hold) return undefined;
|
|
222
|
+
if (choice === cancel) {
|
|
223
|
+
for (const ticket of summary.tickets) requestTicketCancel(ticket);
|
|
224
|
+
syncDelegateStatus(ctx);
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return { cancel: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Tell the human a ticket landed on a branch they had left. Delivery used
|
|
231
|
+
* `nextTurn` (no wake-up), so this notification and the pollable ticket are
|
|
232
|
+
* the only signals that the work finished. */
|
|
233
|
+
export function notifyCrossLeafDelivery(ticket: AsyncTicket): void {
|
|
234
|
+
if (!lastCtx) return;
|
|
235
|
+
try {
|
|
236
|
+
lastCtx.ui.notify(
|
|
237
|
+
`Ticket ${ticket.id} finished (${ticket.status}) on a branch you navigated away from — ` +
|
|
238
|
+
`results are held for your next message; read them with delegate poll.`,
|
|
239
|
+
"warning",
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
lastCtx = undefined;
|
|
243
|
+
lastStatusText = undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
181
247
|
/** One-line description of live work for shutdown traces. */
|
|
182
248
|
export function describeActiveTickets(
|
|
183
249
|
summary: ActiveTicketSummary = activeTicketSummary(),
|