@cr1ms0n/pi-subagent 0.8.8 → 0.9.0
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/CHANGELOG.md +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
package/src/policy.ts
CHANGED
|
@@ -1,561 +1,562 @@
|
|
|
1
|
-
import * as path from "node:path";
|
|
2
|
-
import type { AgentDefinition } from "./agents.js";
|
|
3
|
-
import { resolveAgent } from "./agents.js";
|
|
4
|
-
import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
|
|
5
|
-
import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
|
|
6
|
-
import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
|
|
7
|
-
import type { ParallelTaskInput, SubagentParams } from "./schema.js";
|
|
8
|
-
import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
|
|
9
|
-
import { resolveBackend } from "./backends/index.js";
|
|
10
|
-
import {
|
|
11
|
-
import { isThinkingLevel } from "./thinking.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (item.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const resolved = resolveTools(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
return
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { AgentDefinition } from "./agents.js";
|
|
3
|
+
import { resolveAgent } from "./agents.js";
|
|
4
|
+
import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
|
|
5
|
+
import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
|
|
6
|
+
import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
|
|
7
|
+
import type { ParallelTaskInput, SubagentParams } from "./schema.js";
|
|
8
|
+
import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
|
|
9
|
+
import { resolveBackend } from "./backends/index.js";
|
|
10
|
+
import type { JevRoutingConfig, RoutingDecision, RoutingModelCandidate } from "./routing-types.js";
|
|
11
|
+
import { isThinkingLevel } from "./thinking.js";
|
|
12
|
+
|
|
13
|
+
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
14
|
+
export const SPAWNS_ENV_VAR = "PI_SUBAGENT_SPAWNS";
|
|
15
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
16
|
+
"read",
|
|
17
|
+
"grep",
|
|
18
|
+
"find",
|
|
19
|
+
"ls",
|
|
20
|
+
"fffind",
|
|
21
|
+
"ffgrep",
|
|
22
|
+
"fff-multi-grep",
|
|
23
|
+
"firecrawl_scrape",
|
|
24
|
+
"firecrawl_search",
|
|
25
|
+
"firecrawl_map",
|
|
26
|
+
"firecrawl_crawl",
|
|
27
|
+
"web_search",
|
|
28
|
+
"web_fetch",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Pi context-management tools are control-plane capabilities: they may update
|
|
32
|
+
* continuity notes or the remote context window, but they cannot modify the
|
|
33
|
+
* child checkout. Keep them separate from ordinary source-inspection tools so
|
|
34
|
+
* the read-only profile's exception remains explicit.
|
|
35
|
+
*/
|
|
36
|
+
export const CONTEXT_MANAGEMENT_TOOLS = new Set([
|
|
37
|
+
"new_context",
|
|
38
|
+
"get_context_remaining",
|
|
39
|
+
"history",
|
|
40
|
+
"notes",
|
|
41
|
+
]);
|
|
42
|
+
const NON_WRITING_TOOLS = new Set([...READ_ONLY_TOOLS, ...CONTEXT_MANAGEMENT_TOOLS]);
|
|
43
|
+
export const KNOWN_WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
44
|
+
/** Backward-compatible export; policy uses fail-closed classification above. */
|
|
45
|
+
export const WRITE_TOOLS = KNOWN_WRITE_TOOLS;
|
|
46
|
+
|
|
47
|
+
export interface ParentContext {
|
|
48
|
+
cwd: string;
|
|
49
|
+
model?: string;
|
|
50
|
+
thinking?: TaskSpec["thinking"];
|
|
51
|
+
availableTools: string[];
|
|
52
|
+
activeTools?: string[];
|
|
53
|
+
depth?: number;
|
|
54
|
+
/** Persisted parent session file; required for context:'fork'. */
|
|
55
|
+
sessionFile?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ResolvedTask extends TaskSpec {
|
|
59
|
+
label: string;
|
|
60
|
+
canWrite: boolean;
|
|
61
|
+
effectiveTools: string[];
|
|
62
|
+
resolutionNotes: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Local preparation cannot launch: it has candidates, not an execution model/tools. */
|
|
66
|
+
export interface PreparedTask extends Omit<ResolvedTask, "model" | "canWrite" | "effectiveTools" | "routing"> {
|
|
67
|
+
candidateTools: string[];
|
|
68
|
+
mandatoryTools: string[];
|
|
69
|
+
/** Request > agent > profile. Selected candidate and parent are applied only after routing. */
|
|
70
|
+
requestedThinking?: TaskSpec["thinking"];
|
|
71
|
+
parentThinking?: TaskSpec["thinking"];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PreparationOptions {
|
|
75
|
+
maxDepth?: number;
|
|
76
|
+
maxTasks?: number;
|
|
77
|
+
defaultTimeoutMs?: number;
|
|
78
|
+
taskDefaults?: TaskDefaultsByProfile;
|
|
79
|
+
agents?: Map<string, AgentDefinition>;
|
|
80
|
+
jevRouting?: JevRoutingConfig;
|
|
81
|
+
jevRoutingError?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ManagementMode = "status" | "wait" | "cancel" | "steer" | "diff" | "apply" | "discard";
|
|
85
|
+
|
|
86
|
+
export type ValidationResult =
|
|
87
|
+
| {
|
|
88
|
+
ok: true;
|
|
89
|
+
mode: "single" | "parallel" | ManagementMode;
|
|
90
|
+
async: boolean;
|
|
91
|
+
id?: string;
|
|
92
|
+
message?: string;
|
|
93
|
+
index?: number;
|
|
94
|
+
synthesis?: string;
|
|
95
|
+
tasks: PreparedTask[];
|
|
96
|
+
/** True when action:"plan" requested a dry-run — no spawn. */
|
|
97
|
+
planOnly?: boolean;
|
|
98
|
+
}
|
|
99
|
+
| { ok: false; error: string };
|
|
100
|
+
|
|
101
|
+
function resolvePath(cwd: string, value?: string): string {
|
|
102
|
+
return value ? (path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) : path.resolve(cwd);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveTools(
|
|
106
|
+
profile: TaskProfile,
|
|
107
|
+
requested: string[] | undefined,
|
|
108
|
+
availableTools: string[],
|
|
109
|
+
backend: BackendName,
|
|
110
|
+
): { tools?: string[]; canWrite?: boolean; error?: string } {
|
|
111
|
+
const available = new Set(availableTools);
|
|
112
|
+
const contextTools = backend === "pi"
|
|
113
|
+
? [...CONTEXT_MANAGEMENT_TOOLS].filter((tool) => available.has(tool))
|
|
114
|
+
: [];
|
|
115
|
+
const nonWritingTools = backend === "pi" ? NON_WRITING_TOOLS : READ_ONLY_TOOLS;
|
|
116
|
+
// Keep Pi's context-management control plane available to every child when
|
|
117
|
+
// the parent exposes it, even if the task requested a narrower tool subset.
|
|
118
|
+
const addContextTools = (tools: readonly string[]): string[] =>
|
|
119
|
+
[...new Set([...tools, ...contextTools])];
|
|
120
|
+
|
|
121
|
+
if (requested) {
|
|
122
|
+
const unknown = requested.filter((tool) => !available.has(tool));
|
|
123
|
+
if (unknown.length) return { error: `Unknown or unavailable tools: ${unknown.join(", ")}` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (profile === "explore" || profile === "review") {
|
|
127
|
+
const source = addContextTools(requested ?? [...nonWritingTools].filter((tool) => available.has(tool)));
|
|
128
|
+
const unsafe = source.filter((tool) => !nonWritingTools.has(tool));
|
|
129
|
+
if (unsafe.length) {
|
|
130
|
+
return {
|
|
131
|
+
error: `${profile} is strictly read-only. Unclassified or writable tools are not allowed: ${unsafe.join(", ")}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { tools: source, canWrite: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const source = addContextTools(requested ?? availableTools);
|
|
138
|
+
const unknown = source.filter((tool) => !available.has(tool));
|
|
139
|
+
if (unknown.length) return { error: `Candidate tools are unavailable: ${unknown.join(", ")}` };
|
|
140
|
+
// General-profile custom tools are conservatively write-capable unless explicitly known non-writing.
|
|
141
|
+
return {
|
|
142
|
+
tools: source,
|
|
143
|
+
canWrite: source.some((tool) => KNOWN_WRITE_TOOLS.has(tool) || !nonWritingTools.has(tool)),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeTask(
|
|
148
|
+
item: {
|
|
149
|
+
task: string;
|
|
150
|
+
agent?: string;
|
|
151
|
+
description?: string;
|
|
152
|
+
system_prompt?: string;
|
|
153
|
+
model?: string;
|
|
154
|
+
thinking?: TaskSpec["thinking"];
|
|
155
|
+
tools?: string[];
|
|
156
|
+
profile?: TaskProfile;
|
|
157
|
+
cwd?: string;
|
|
158
|
+
timeout_ms?: number;
|
|
159
|
+
max_turns?: number;
|
|
160
|
+
max_cost?: number;
|
|
161
|
+
grace_turns?: number;
|
|
162
|
+
fallback_models?: string[];
|
|
163
|
+
max_retries?: number;
|
|
164
|
+
context?: "fresh" | "fork";
|
|
165
|
+
output?: string;
|
|
166
|
+
output_mode?: OutputMode;
|
|
167
|
+
output_schema?: Record<string, unknown>;
|
|
168
|
+
resume?: string;
|
|
169
|
+
fork_resume?: boolean;
|
|
170
|
+
isolation?: "shared" | "worktree";
|
|
171
|
+
allow_shared_writes?: boolean;
|
|
172
|
+
keep_background?: boolean;
|
|
173
|
+
include_wip?: boolean;
|
|
174
|
+
backend?: BackendName;
|
|
175
|
+
},
|
|
176
|
+
index: number,
|
|
177
|
+
parent: ParentContext,
|
|
178
|
+
defaultProfile: TaskProfile,
|
|
179
|
+
defaults: PreparationOptions = {},
|
|
180
|
+
): { task?: PreparedTask; error?: string } {
|
|
181
|
+
if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
|
|
182
|
+
|
|
183
|
+
// Named agent resolution is still used for persona/profile/tool behavior;
|
|
184
|
+
// its legacy model/fallback fields are deliberately ignored below.
|
|
185
|
+
// request params still win field-by-field. The agent body is the child's
|
|
186
|
+
// system prompt; an explicit system_prompt is appended after it.
|
|
187
|
+
let agent: AgentDefinition | undefined;
|
|
188
|
+
if ((item as { agent?: string }).agent) {
|
|
189
|
+
const lookup = resolveAgent(defaults.agents ?? new Map(), (item as { agent?: string }).agent!);
|
|
190
|
+
if (!lookup.agent) return { error: `Task ${index + 1}: ${lookup.error}` };
|
|
191
|
+
agent = lookup.agent;
|
|
192
|
+
}
|
|
193
|
+
if (item.model !== undefined || item.fallback_models !== undefined) {
|
|
194
|
+
return { error: `Task ${index + 1}: omit model and fallback_models (including empty lists). Jev must select from jevRouting.models; manual/fixed routing is no longer supported.` };
|
|
195
|
+
}
|
|
196
|
+
if (item.output_mode && !item.output) return { error: `Task ${index + 1}: output_mode requires output` };
|
|
197
|
+
if (item.fork_resume && !item.resume) return { error: `Task ${index + 1}: fork_resume requires resume` };
|
|
198
|
+
if (item.timeout_ms !== undefined && (!Number.isInteger(item.timeout_ms) || item.timeout_ms < 1)) {
|
|
199
|
+
return { error: `Task ${index + 1}: timeout_ms must be a positive integer` };
|
|
200
|
+
}
|
|
201
|
+
if (item.max_turns !== undefined && (!Number.isInteger(item.max_turns) || item.max_turns < 1)) {
|
|
202
|
+
return { error: `Task ${index + 1}: max_turns must be a positive integer` };
|
|
203
|
+
}
|
|
204
|
+
if (item.max_cost !== undefined && (!Number.isFinite(item.max_cost) || item.max_cost < 0)) {
|
|
205
|
+
return { error: `Task ${index + 1}: max_cost must be >= 0` };
|
|
206
|
+
}
|
|
207
|
+
if (item.grace_turns !== undefined && (!Number.isInteger(item.grace_turns) || item.grace_turns < 0)) {
|
|
208
|
+
return { error: `Task ${index + 1}: grace_turns must be a non-negative integer` };
|
|
209
|
+
}
|
|
210
|
+
if (item.max_retries !== undefined && (!Number.isInteger(item.max_retries) || item.max_retries < 0)) {
|
|
211
|
+
return { error: `Task ${index + 1}: max_retries must be a non-negative integer` };
|
|
212
|
+
}
|
|
213
|
+
if (item.context === "fork") {
|
|
214
|
+
if (item.resume) return { error: `Task ${index + 1}: context:'fork' cannot be combined with resume (resume already carries its own context)` };
|
|
215
|
+
if (!parent.sessionFile) {
|
|
216
|
+
return { error: `Task ${index + 1}: context:'fork' requires a persisted parent session; this session has no session file. Use context:'fresh'.` };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (item.output_schema !== undefined && !isPlausibleSchema(item.output_schema)) {
|
|
220
|
+
return { error: `Task ${index + 1}: output_schema must be a JSON Schema object (type/properties/required)` };
|
|
221
|
+
}
|
|
222
|
+
if (item.include_wip === true) {
|
|
223
|
+
const isolation = item.isolation ?? agent?.isolation ?? "shared";
|
|
224
|
+
if (isolation !== "worktree") {
|
|
225
|
+
return { error: `Task ${index + 1}: include_wip requires isolation:"worktree" (dirty-baseline works only on isolated worktrees)` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const backend: BackendName = item.backend ?? agent?.backend ?? "pi";
|
|
230
|
+
if (!BACKEND_NAMES.includes(backend)) {
|
|
231
|
+
return { error: `Task ${index + 1}: unknown backend '${backend}' (expected ${BACKEND_NAMES.join(", ")})` };
|
|
232
|
+
}
|
|
233
|
+
if (backend !== "pi") return { error: `Task ${index + 1}: new Jev-routed work supports backend:"pi" only; ${backend} is not supported. Existing-run management remains available.` };
|
|
234
|
+
const profile = item.profile ?? agent?.profile ?? defaultProfile;
|
|
235
|
+
const requestedTools = item.tools;
|
|
236
|
+
const childDepth = (parent.depth ?? parseDepth()) + 1;
|
|
237
|
+
const nestedAllowed = profile === "general" && childDepth < (defaults.maxDepth ?? defaultConfig.maxDepth)
|
|
238
|
+
&& agent?.spawns !== false && (!Array.isArray(agent?.spawns) || agent.spawns.length > 0);
|
|
239
|
+
const availableTools = parent.availableTools.filter((tool) => nestedAllowed || !["subagent", "subagent_wait"].includes(tool));
|
|
240
|
+
const resolved = resolveTools(profile, requestedTools, availableTools, backend);
|
|
241
|
+
if (resolved.error || !resolved.tools || resolved.canWrite === undefined) return { error: resolved.error ?? "Tool resolution failed" };
|
|
242
|
+
const cwd = resolvePath(parent.cwd, item.cwd);
|
|
243
|
+
const output = item.output ? resolvePath(cwd, item.output) : undefined;
|
|
244
|
+
// Non-model fields retain the existing precedence: explicit request > agent
|
|
245
|
+
// file > per-profile config defaults; candidate/parent thinking waits for routing.
|
|
246
|
+
const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
|
|
247
|
+
const requestedThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking;
|
|
248
|
+
const effectiveThinking = requestedThinking ?? parent.thinking;
|
|
249
|
+
if (effectiveThinking !== undefined && !isThinkingLevel(effectiveThinking)) {
|
|
250
|
+
return { error: `Task ${index + 1}: thinking must be a non-empty Pi thinking level string without whitespace or control characters` };
|
|
251
|
+
}
|
|
252
|
+
const label = item.description?.trim()
|
|
253
|
+
? item.description.trim().slice(0, 60)
|
|
254
|
+
: agent
|
|
255
|
+
? agent.name
|
|
256
|
+
: `task-${index + 1}`;
|
|
257
|
+
const systemPrompt = [agent?.systemPrompt, item.system_prompt].filter(Boolean).join("\n\n") || undefined;
|
|
258
|
+
|
|
259
|
+
// Backend capability gate. Refuse combinations the backend cannot honor
|
|
260
|
+
// rather than silently dropping a budget or a read-only guarantee.
|
|
261
|
+
const capabilities = resolveBackend(backend).capabilities;
|
|
262
|
+
const problems = checkCapabilities(
|
|
263
|
+
{
|
|
264
|
+
maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
|
|
265
|
+
resume: item.resume,
|
|
266
|
+
forkResume: item.fork_resume,
|
|
267
|
+
contextFork: item.context === "fork",
|
|
268
|
+
// Only report a tool-restriction problem when the profile actually
|
|
269
|
+
// restricts: profile 'general' inherits the parent set and does not
|
|
270
|
+
// promise a read-only sandbox.
|
|
271
|
+
tools: profile === "general" ? undefined : resolved.tools,
|
|
272
|
+
thinking: effectiveThinking,
|
|
273
|
+
outputSchema: item.output_schema ?? agent?.outputSchema,
|
|
274
|
+
profile,
|
|
275
|
+
canWrite: resolved.canWrite,
|
|
276
|
+
},
|
|
277
|
+
capabilities,
|
|
278
|
+
backend,
|
|
279
|
+
);
|
|
280
|
+
if (problems.length) {
|
|
281
|
+
return { error: `Task ${index + 1}: ${problems.join("; ")}` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
task: {
|
|
286
|
+
backend,
|
|
287
|
+
label,
|
|
288
|
+
task: repairDoubleEncodedText(item.task.trim()),
|
|
289
|
+
systemPrompt: systemPrompt ? repairDoubleEncodedText(systemPrompt) : systemPrompt,
|
|
290
|
+
// No model or effective tool set exists until finalizeRoutedTasks succeeds.
|
|
291
|
+
requestedThinking,
|
|
292
|
+
parentThinking: parent.thinking,
|
|
293
|
+
thinking: requestedThinking,
|
|
294
|
+
candidateTools: resolved.tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool)),
|
|
295
|
+
mandatoryTools: resolved.tools.filter((tool) => CONTEXT_MANAGEMENT_TOOLS.has(tool)),
|
|
296
|
+
profile,
|
|
297
|
+
cwd,
|
|
298
|
+
timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.defaultTimeoutMs ?? defaultConfig.defaultTimeoutMs,
|
|
299
|
+
maxTurns: item.max_turns ?? agent?.maxTurns ?? profileDefaults.maxTurns,
|
|
300
|
+
maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
|
|
301
|
+
graceTurns: item.grace_turns ?? agent?.graceTurns,
|
|
302
|
+
fallbackModels: [],
|
|
303
|
+
maxRetries: item.max_retries ?? agent?.maxRetries ?? profileDefaults.maxRetries,
|
|
304
|
+
contextFork: item.context === "fork",
|
|
305
|
+
parentSessionFile: item.context === "fork" ? parent.sessionFile : undefined,
|
|
306
|
+
output,
|
|
307
|
+
outputMode: item.output_mode,
|
|
308
|
+
outputSchema: item.output_schema ?? agent?.outputSchema,
|
|
309
|
+
resume: item.resume,
|
|
310
|
+
forkResume: item.fork_resume,
|
|
311
|
+
isolation: item.isolation ?? agent?.isolation ?? "shared",
|
|
312
|
+
allowSharedWrites: item.allow_shared_writes === true,
|
|
313
|
+
keepBackground: item.keep_background === true,
|
|
314
|
+
includeWip: item.include_wip === true,
|
|
315
|
+
// Child's own future-spawn allowlist never inherits from boot env —
|
|
316
|
+
// only the named persona's frontmatter `spawns` restricts grandchildren.
|
|
317
|
+
spawns: agent?.spawns,
|
|
318
|
+
|
|
319
|
+
resolutionNotes: [
|
|
320
|
+
`backend=${backend}`,
|
|
321
|
+
`profile=${profile}`,
|
|
322
|
+
|
|
323
|
+
...(agent ? [`agent=${agent.name}`] : []),
|
|
324
|
+
"routing=jev (pending)",
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateParallel(tasks: Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>): string | undefined {
|
|
331
|
+
const outputs = new Set<string>();
|
|
332
|
+
for (const task of tasks) {
|
|
333
|
+
if (task.output && outputs.has(task.output)) return `Duplicate output path: ${task.output}`;
|
|
334
|
+
if (task.output) outputs.add(task.output);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const sharedByCwd = new Map<string, Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>>();
|
|
338
|
+
for (const task of tasks.filter((task) => task.canWrite && task.isolation !== "worktree")) {
|
|
339
|
+
const list = sharedByCwd.get(task.cwd!) ?? [];
|
|
340
|
+
list.push(task);
|
|
341
|
+
sharedByCwd.set(task.cwd!, list);
|
|
342
|
+
}
|
|
343
|
+
for (const [cwd, writers] of sharedByCwd) {
|
|
344
|
+
if (writers.length > 1 && !writers.every((task) => task.allowSharedWrites)) {
|
|
345
|
+
return `Parallel writers share ${cwd}. Use isolation:"worktree", distinct cwd values, or explicit allow_shared_writes:true.`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse nesting depth. Missing (undefined / empty) means top-level (0).
|
|
353
|
+
* Malformed or negative values fail *closed* by returning a large sentinel so
|
|
354
|
+
* the depth-cap check rejects nested work rather than resetting the counter
|
|
355
|
+
* after env scrubbing after a forged zero.
|
|
356
|
+
*/
|
|
357
|
+
export function parseDepth(value = process.env[DEPTH_ENV_VAR]): number {
|
|
358
|
+
if (value === undefined || value === "") return 0;
|
|
359
|
+
const parsed = Number.parseInt(value, 10);
|
|
360
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 100; // fail closed
|
|
361
|
+
return Math.min(parsed, 100);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type SpawnPolicy = { kind: "unrestricted" } | { kind: "disabled" } | { kind: "allowlist"; agents: string[] };
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Parse the spawn allowlist env var.
|
|
368
|
+
* - unset / "*" → unrestricted
|
|
369
|
+
* - empty / "false" / "off" / "none" → disabled
|
|
370
|
+
* - "a,b" / "[a, b]" → allowlist (agentless also rejected)
|
|
371
|
+
* Malformed values fail closed to disabled.
|
|
372
|
+
*/
|
|
373
|
+
export function parseSpawnPolicy(value?: string): SpawnPolicy {
|
|
374
|
+
if (value === undefined) return { kind: "unrestricted" };
|
|
375
|
+
// Empty after trim is intentional disable; also treat bare false synonyms.
|
|
376
|
+
const trimmed = value.trim();
|
|
377
|
+
if (trimmed === "" || /^false|off|none$/i.test(trimmed)) return { kind: "disabled" };
|
|
378
|
+
if (trimmed === "*") return { kind: "unrestricted" };
|
|
379
|
+
// Reject control characters / bad forms before any permissive poke.
|
|
380
|
+
if (/[\x00-\x1f]/.test(trimmed)) return { kind: "disabled" };
|
|
381
|
+
const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
382
|
+
? trimmed.slice(1, -1)
|
|
383
|
+
: trimmed;
|
|
384
|
+
const agents = inner
|
|
385
|
+
.split(",")
|
|
386
|
+
.map((item) => item.trim().replace(/^["']|["']$/g, "").toLowerCase())
|
|
387
|
+
.filter(Boolean);
|
|
388
|
+
if (agents.length === 0) return { kind: "disabled" };
|
|
389
|
+
// Agent names must stay simple identifiers; anything else is a forged policy.
|
|
390
|
+
if (agents.some((name) => !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name))) return { kind: "disabled" };
|
|
391
|
+
return { kind: "allowlist", agents };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function describeSpawnPolicy(policy: SpawnPolicy): string {
|
|
395
|
+
if (policy.kind === "disabled") return "spawning disabled";
|
|
396
|
+
if (policy.kind === "allowlist") return `spawn allowlist: ${policy.agents.join(", ")}`;
|
|
397
|
+
return "unrestricted";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** True when this process should avoid spawning further nested subagents. */
|
|
401
|
+
export function shouldRegisterSubagentTool(
|
|
402
|
+
depth = parseDepth(),
|
|
403
|
+
maxDepth = defaultConfig.maxDepth,
|
|
404
|
+
): boolean {
|
|
405
|
+
return depth < maxDepth;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function validateSubagentRequest(
|
|
409
|
+
params: SubagentParams,
|
|
410
|
+
parent: ParentContext,
|
|
411
|
+
options: PreparationOptions = {},
|
|
412
|
+
): ValidationResult {
|
|
413
|
+
const defaults = options;
|
|
414
|
+
const hasAction = params.action !== undefined;
|
|
415
|
+
const hasTask = typeof params.task === "string";
|
|
416
|
+
const hasTasks = Array.isArray(params.tasks);
|
|
417
|
+
const planOnly = params.action === "plan";
|
|
418
|
+
|
|
419
|
+
// action:"plan" is a dry-run of spawn modes: it MUST combine with task/tasks.
|
|
420
|
+
// Other actions remain exclusive with task/tasks.
|
|
421
|
+
if (planOnly) {
|
|
422
|
+
if (hasTask === hasTasks) {
|
|
423
|
+
// Both or neither: plan alone is invalid; task+tasks is also invalid.
|
|
424
|
+
if (!hasTask && !hasTasks) {
|
|
425
|
+
return { ok: false, error: "action:\"plan\" requires task or tasks[] (dry-run of a spawn request)" };
|
|
426
|
+
}
|
|
427
|
+
return { ok: false, error: "Provide exactly one of: task or tasks" };
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
const modes = [hasAction, hasTask, hasTasks].filter(Boolean).length;
|
|
431
|
+
if (modes === 0) {
|
|
432
|
+
return { ok: false, error: "Provide task, tasks, or action (status|wait|cancel|plan)" };
|
|
433
|
+
}
|
|
434
|
+
if (modes > 1) {
|
|
435
|
+
return { ok: false, error: "Provide exactly one of: action, task, or tasks" };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (hasAction && !planOnly) {
|
|
440
|
+
if (params.action !== "status" && !params.id) {
|
|
441
|
+
return { ok: false, error: `${params.action} requires a run id` };
|
|
442
|
+
}
|
|
443
|
+
if (params.action === "steer" && !params.message?.trim()) {
|
|
444
|
+
return { ok: false, error: "steer requires a non-empty message" };
|
|
445
|
+
}
|
|
446
|
+
// Management actions ignore task-config fields; reject obvious conflict residues.
|
|
447
|
+
if (params.async !== undefined) {
|
|
448
|
+
return { ok: false, error: "async cannot be combined with action" };
|
|
449
|
+
}
|
|
450
|
+
// `!planOnly` above excludes "plan"; TS cannot narrow the union across the flag.
|
|
451
|
+
return { ok: true, mode: params.action as ManagementMode, async: false, id: params.id, message: params.message, index: params.index, tasks: [] };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const depth = parent.depth ?? parseDepth();
|
|
455
|
+
const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
|
|
456
|
+
if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
|
|
457
|
+
if (!options.jevRouting) return { ok: false, error: options.jevRoutingError ?? "Jev routing is not configured. Add jevRouting.models with exact IDs and descriptions to ~/.pi/subagent.json; existing-run management remains available." };
|
|
458
|
+
// Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
|
|
459
|
+
const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
|
|
460
|
+
if (spawnPolicy.kind !== "unrestricted") {
|
|
461
|
+
const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
|
|
462
|
+
if (hasSpawnWork) {
|
|
463
|
+
if (spawnPolicy.kind === "disabled") {
|
|
464
|
+
return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
|
|
465
|
+
}
|
|
466
|
+
// Allowlist requires a named agent from the list; agentless is rejected.
|
|
467
|
+
const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
|
|
468
|
+
? params.tasks.map((t) => t.agent)
|
|
469
|
+
: [params.agent];
|
|
470
|
+
for (let i = 0; i < requestedAgents.length; i++) {
|
|
471
|
+
const agentName = requestedAgents[i]?.trim().toLowerCase();
|
|
472
|
+
if (!agentName) {
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (!spawnPolicy.agents.includes(agentName)) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (hasTasks) {
|
|
489
|
+
const rawTasks = params.tasks!;
|
|
490
|
+
const maxTasks = options.maxTasks ?? defaultConfig.maxTasksPerRun;
|
|
491
|
+
if (!rawTasks.length || rawTasks.length > maxTasks) return { ok: false, error: `Expected 1..${maxTasks} tasks (configurable via maxTasksPerRun)` };
|
|
492
|
+
// Top-level TaskFields apply only to single-task mode.
|
|
493
|
+
if (params.system_prompt !== undefined || params.model !== undefined || params.fallback_models !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
|
|
494
|
+
return { ok: false, error: "Top-level task options cannot be combined with tasks[]; set them on each tasks[] item" };
|
|
495
|
+
}
|
|
496
|
+
// Context forking duplicates the whole parent conversation per child;
|
|
497
|
+
// that cost is intentional for one focused writer, not an 8-way fanout.
|
|
498
|
+
if (rawTasks.length > 1 && rawTasks.some((task) => task.context === "fork")) {
|
|
499
|
+
return { ok: false, error: "context:'fork' is single-task only; parallel fanout would duplicate the parent conversation per child" };
|
|
500
|
+
}
|
|
501
|
+
const tasks: PreparedTask[] = [];
|
|
502
|
+
for (let index = 0; index < rawTasks.length; index++) {
|
|
503
|
+
const normalized = normalizeTask(rawTasks[index] as ParallelTaskInput, index, parent, "explore", defaults);
|
|
504
|
+
if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
|
|
505
|
+
tasks.push(normalized.task);
|
|
506
|
+
}
|
|
507
|
+
const parallelError = validateParallel(tasks);
|
|
508
|
+
if (parallelError) return { ok: false, error: parallelError };
|
|
509
|
+
return {
|
|
510
|
+
ok: true,
|
|
511
|
+
mode: tasks.length > 1 ? "parallel" : "single",
|
|
512
|
+
async: params.async === true,
|
|
513
|
+
synthesis: params.synthesis?.trim() || undefined,
|
|
514
|
+
tasks,
|
|
515
|
+
planOnly: planOnly || undefined,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (params.synthesis !== undefined) {
|
|
520
|
+
return { ok: false, error: "synthesis applies to parallel mode only (tasks[])" };
|
|
521
|
+
}
|
|
522
|
+
// Single-task mode: top-level fields form the one task.
|
|
523
|
+
const normalized = normalizeTask(params as ParallelTaskInput, 0, parent, "general", defaults);
|
|
524
|
+
if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
|
|
525
|
+
return { ok: true, mode: "single", async: params.async === true, tasks: [normalized.task], planOnly: planOnly || undefined };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function describeCapability(task: ResolvedTask): string {
|
|
529
|
+
return `${task.profile}/${task.canWrite ? "RW" : "RO"} tools=[${task.effectiveTools.join(",")}]`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Final local authority: no raw caller model field is synthesized to bypass policy. */
|
|
533
|
+
export function finalizeRoutedTasks(
|
|
534
|
+
prepared: readonly PreparedTask[],
|
|
535
|
+
decisions: readonly RoutingDecision[],
|
|
536
|
+
models: readonly RoutingModelCandidate[],
|
|
537
|
+
): { ok: true; tasks: ResolvedTask[] } | { ok: false; error: string } {
|
|
538
|
+
if (prepared.length !== decisions.length) return { ok: false, error: "Routing decision count does not match the prepared tasks." };
|
|
539
|
+
const tasks: ResolvedTask[] = [];
|
|
540
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
541
|
+
const item = prepared[index]!;
|
|
542
|
+
const decision = decisions[index]!;
|
|
543
|
+
const candidate = models.find((entry) => entry.model === decision.selectedModel);
|
|
544
|
+
if (!candidate) return { ok: false, error: `Task ${index + 1}: selector chose a model outside the available dedicated candidates.` };
|
|
545
|
+
if (new Set(decision.selectedTools).size !== decision.selectedTools.length || decision.selectedTools.some((tool) => !item.candidateTools.includes(tool))) {
|
|
546
|
+
return { ok: false, error: `Task ${index + 1}: selector chose tools outside the locally permitted candidates.` };
|
|
547
|
+
}
|
|
548
|
+
const tools = [...new Set([...decision.selectedTools, ...item.mandatoryTools])];
|
|
549
|
+
const canWrite = tools.some((tool) => !NON_WRITING_TOOLS.has(tool));
|
|
550
|
+
if (item.profile !== "general" && canWrite) return { ok: false, error: `Task ${index + 1}: writable selector choice violates ${item.profile}.` };
|
|
551
|
+
const { candidateTools: _candidates, mandatoryTools, requestedThinking, parentThinking, ...spec } = item;
|
|
552
|
+
const thinking = requestedThinking ?? candidate.thinking ?? parentThinking;
|
|
553
|
+
tasks.push({
|
|
554
|
+
...spec, model: candidate.model, thinking, tools, effectiveTools: tools, canWrite,
|
|
555
|
+
fallbackModels: [],
|
|
556
|
+
routing: { ...decision, mandatoryTools: [...mandatoryTools], outcome: "success" },
|
|
557
|
+
resolutionNotes: [...item.resolutionNotes.filter((note) => !note.startsWith("routing=")), "routing=jev", `access=${canWrite ? "RW" : "RO"}`],
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
const problem = validateParallel(tasks);
|
|
561
|
+
return problem ? { ok: false, error: problem } : { ok: true, tasks };
|
|
562
|
+
}
|