@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/lifecycle.ts
CHANGED
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
persistSessionHeader,
|
|
21
21
|
setParentSession,
|
|
22
22
|
} from "./sessions.ts";
|
|
23
|
-
import { runAgentSession } from "./runner.ts";
|
|
23
|
+
import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
|
|
24
24
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
25
25
|
import { getHostDeps } from "./host.ts";
|
|
26
26
|
import { resolveCwd, validateResumeFromPath } from "./utils.ts";
|
|
27
27
|
import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
|
|
28
28
|
import { addUsage, emptyUsage } from "./usage.ts";
|
|
29
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
30
|
+
import { recordTask } from "./telemetry.ts";
|
|
29
31
|
|
|
30
32
|
/** Internal seam for lifecycle-level tests without replacing session ownership. */
|
|
31
33
|
type RunAgentSession = typeof runAgentSession;
|
|
@@ -72,6 +74,7 @@ function failTask(
|
|
|
72
74
|
sessionFile?: string,
|
|
73
75
|
): TaskResult {
|
|
74
76
|
return {
|
|
77
|
+
id: task.id,
|
|
75
78
|
agent: task.agentName,
|
|
76
79
|
output: "",
|
|
77
80
|
error,
|
|
@@ -80,6 +83,7 @@ function failTask(
|
|
|
80
83
|
usage: emptyUsage(),
|
|
81
84
|
sessionFile,
|
|
82
85
|
touchedFiles: [],
|
|
86
|
+
attributedFiles: [],
|
|
83
87
|
};
|
|
84
88
|
}
|
|
85
89
|
|
|
@@ -91,6 +95,7 @@ function completeSessionAction(
|
|
|
91
95
|
elapsedMs?: number,
|
|
92
96
|
): TaskResult {
|
|
93
97
|
return {
|
|
98
|
+
id: task.id,
|
|
94
99
|
agent: task.agentName,
|
|
95
100
|
output,
|
|
96
101
|
durationMs: elapsedMs ?? 0,
|
|
@@ -98,6 +103,7 @@ function completeSessionAction(
|
|
|
98
103
|
usage: emptyUsage(),
|
|
99
104
|
sessionFile: undefined,
|
|
100
105
|
touchedFiles: [],
|
|
106
|
+
attributedFiles: [],
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -182,8 +188,22 @@ function finishTask(
|
|
|
182
188
|
env: TaskRunEnv,
|
|
183
189
|
p: TaskProgress,
|
|
184
190
|
r: TaskResult,
|
|
191
|
+
task: ResolvedTask,
|
|
192
|
+
retries = 0,
|
|
185
193
|
): TaskResult {
|
|
186
194
|
updateProgressFromResult(p, r);
|
|
195
|
+
if (env.telemetryCallId) {
|
|
196
|
+
recordTask({
|
|
197
|
+
callId: env.telemetryCallId,
|
|
198
|
+
generation: env.telemetryGeneration,
|
|
199
|
+
async: env.async ?? false,
|
|
200
|
+
taskIndex: p.index,
|
|
201
|
+
task,
|
|
202
|
+
progress: p,
|
|
203
|
+
result: r,
|
|
204
|
+
retries,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
187
207
|
env.onStatusChange?.();
|
|
188
208
|
return r;
|
|
189
209
|
}
|
|
@@ -266,6 +286,7 @@ function canRetryWholeTask(
|
|
|
266
286
|
return (
|
|
267
287
|
result.failureKind !== "stalled" &&
|
|
268
288
|
result.failureKind !== "model_error" &&
|
|
289
|
+
result.failureKind !== "deadline_exceeded" &&
|
|
269
290
|
!task.sessionId &&
|
|
270
291
|
!task.resumeFrom &&
|
|
271
292
|
result.touchedFiles.length === 0 &&
|
|
@@ -277,16 +298,23 @@ function canRetryWholeTask(
|
|
|
277
298
|
async function sleepForWholeTaskRetry(
|
|
278
299
|
signal: AbortSignal | undefined,
|
|
279
300
|
delayMs: number,
|
|
301
|
+
deadlineAt?: number,
|
|
280
302
|
): Promise<void> {
|
|
281
303
|
if (signal?.aborted) return;
|
|
304
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt) return;
|
|
305
|
+
|
|
306
|
+
const retryAt = Date.now() + delayMs;
|
|
307
|
+
const wakeAt =
|
|
308
|
+
deadlineAt !== undefined ? Math.min(retryAt, deadlineAt) : retryAt;
|
|
309
|
+
|
|
282
310
|
await new Promise<void>((resolve) => {
|
|
283
|
-
let
|
|
311
|
+
let clear: (() => void) | undefined;
|
|
284
312
|
const done = () => {
|
|
285
|
-
|
|
313
|
+
clear?.();
|
|
286
314
|
signal?.removeEventListener("abort", done);
|
|
287
315
|
resolve();
|
|
288
316
|
};
|
|
289
|
-
|
|
317
|
+
clear = scheduleDeadline(wakeAt, done);
|
|
290
318
|
if (!signal) return;
|
|
291
319
|
signal.addEventListener("abort", done, { once: true });
|
|
292
320
|
});
|
|
@@ -516,7 +544,7 @@ function resolveResumableSessionFile(
|
|
|
516
544
|
* Used by both sync (params.async === false) and async (params.async === true) paths.
|
|
517
545
|
* When task.sessionId is set, the entire acquire/run/close lifecycle runs under
|
|
518
546
|
* a per-session mutex so concurrent tasks with the same sessionId serialize
|
|
519
|
-
* cleanly. The lock also covers
|
|
547
|
+
* cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
|
|
520
548
|
export async function runResolvedTask(
|
|
521
549
|
env: TaskRunEnv,
|
|
522
550
|
task: ResolvedTask,
|
|
@@ -540,7 +568,7 @@ async function runResolvedTaskUnlocked(
|
|
|
540
568
|
try {
|
|
541
569
|
// ── Aborted before we started? ───────────────────────────────────
|
|
542
570
|
if (env.signal?.aborted) {
|
|
543
|
-
return finishTask(env, p, failTask(task, "Aborted"));
|
|
571
|
+
return finishTask(env, p, failTask(task, "Aborted"), task);
|
|
544
572
|
}
|
|
545
573
|
|
|
546
574
|
// ── Session busy guard (defense-in-depth) ────────────────────────
|
|
@@ -550,7 +578,7 @@ async function runResolvedTaskUnlocked(
|
|
|
550
578
|
const busyTicketId = isSessionBusy(task.sessionId);
|
|
551
579
|
if (busyTicketId && busyTicketId !== env.ticketId) {
|
|
552
580
|
const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
|
|
553
|
-
return finishTask(env, p, failTask(task, msg));
|
|
581
|
+
return finishTask(env, p, failTask(task, msg), task);
|
|
554
582
|
}
|
|
555
583
|
}
|
|
556
584
|
|
|
@@ -558,12 +586,13 @@ async function runResolvedTaskUnlocked(
|
|
|
558
586
|
p.model = task.model?.id;
|
|
559
587
|
|
|
560
588
|
// ── Session action handling ───────────────────────────────────────
|
|
561
|
-
if (task.
|
|
589
|
+
if (task.sessionAction === "close") {
|
|
562
590
|
if (!task.sessionId) {
|
|
563
591
|
return finishTask(
|
|
564
592
|
env,
|
|
565
593
|
p,
|
|
566
|
-
failTask(task, "
|
|
594
|
+
failTask(task, "sessionAction='close' requires sessionId."),
|
|
595
|
+
task,
|
|
567
596
|
);
|
|
568
597
|
}
|
|
569
598
|
// The per-session lock for action-based operations is already held by the
|
|
@@ -580,10 +609,11 @@ async function runResolvedTaskUnlocked(
|
|
|
580
609
|
: `Session '${task.sessionId}' not found.`,
|
|
581
610
|
Date.now() - env.delegateStartedAt,
|
|
582
611
|
),
|
|
612
|
+
task,
|
|
583
613
|
);
|
|
584
614
|
}
|
|
585
615
|
|
|
586
|
-
if (task.
|
|
616
|
+
if (task.sessionAction === "list") {
|
|
587
617
|
return finishTask(
|
|
588
618
|
env,
|
|
589
619
|
p,
|
|
@@ -592,6 +622,7 @@ async function runResolvedTaskUnlocked(
|
|
|
592
622
|
`Active sessions:\n${pool.listPooledAgents().join("\n")}`,
|
|
593
623
|
Date.now() - env.delegateStartedAt,
|
|
594
624
|
),
|
|
625
|
+
task,
|
|
595
626
|
);
|
|
596
627
|
}
|
|
597
628
|
|
|
@@ -599,6 +630,10 @@ async function runResolvedTaskUnlocked(
|
|
|
599
630
|
let cumulativeTokens = 0;
|
|
600
631
|
let cumulativeToolUses = 0;
|
|
601
632
|
const taskStartedAt = Date.now();
|
|
633
|
+
const deadlineAt =
|
|
634
|
+
task.deadlineMs && task.deadlineMs > 0
|
|
635
|
+
? taskStartedAt + task.deadlineMs
|
|
636
|
+
: undefined;
|
|
602
637
|
let accumulatedUsage = emptyUsage();
|
|
603
638
|
|
|
604
639
|
const onAttemptProgress = (u: AgentProgressUpdate): void => {
|
|
@@ -651,7 +686,10 @@ async function runResolvedTaskUnlocked(
|
|
|
651
686
|
|
|
652
687
|
// Snapshot git status before the run so touchedFiles can diff after.
|
|
653
688
|
// AgentSession owns retry/compaction internally — runAgentSession just
|
|
654
|
-
// drives the prompt and maps events to the progress model.
|
|
689
|
+
// drives the prompt and maps events to the progress model. Git failures
|
|
690
|
+
// degrade to an undefined baseline, which tells the runner to skip
|
|
691
|
+
// git-based attribution entirely; see getGitChangedFiles for the
|
|
692
|
+
// contract.
|
|
655
693
|
const gitBaseline = await getGitChangedFiles(task.cwd);
|
|
656
694
|
let r = await runAgentSessionForTesting(
|
|
657
695
|
acquired.session,
|
|
@@ -660,7 +698,8 @@ async function runResolvedTaskUnlocked(
|
|
|
660
698
|
env.signal,
|
|
661
699
|
onProgress,
|
|
662
700
|
gitBaseline,
|
|
663
|
-
|
|
701
|
+
taskStartedAt,
|
|
702
|
+
deadlineAt,
|
|
664
703
|
);
|
|
665
704
|
|
|
666
705
|
cumulativeTokens += r.tokens;
|
|
@@ -682,7 +721,11 @@ async function runResolvedTaskUnlocked(
|
|
|
682
721
|
// Pool misses (including resumeFrom) transfer ownership only on
|
|
683
722
|
// successful completion; failures are owned by lifecycle and must
|
|
684
723
|
// be disposed in this finally path.
|
|
685
|
-
if (
|
|
724
|
+
if (
|
|
725
|
+
!r.error &&
|
|
726
|
+
r.failureKind !== "stalled" &&
|
|
727
|
+
r.failureKind !== "deadline_exceeded"
|
|
728
|
+
) {
|
|
686
729
|
const committed = pool.commit(task.sessionId, {
|
|
687
730
|
session: acquired.session,
|
|
688
731
|
sessionManager: acquired.sessionManager,
|
|
@@ -699,28 +742,37 @@ async function runResolvedTaskUnlocked(
|
|
|
699
742
|
sessionReleased = sessionReleased || committed;
|
|
700
743
|
}
|
|
701
744
|
} else {
|
|
702
|
-
// A stalled
|
|
703
|
-
//
|
|
704
|
-
|
|
745
|
+
// A stalled, parent-aborted, or mid-prompt deadline-exceeded pooled
|
|
746
|
+
// attempt is not safe to keep; the session may have been mutated.
|
|
747
|
+
// A pre-prompt deadline (runner never called session.prompt()) left
|
|
748
|
+
// the session in its pre-task state, so return it to the pool intact.
|
|
749
|
+
if (
|
|
750
|
+
r.failureKind === "stalled" ||
|
|
751
|
+
r.error === "Aborted" ||
|
|
752
|
+
(r.failureKind === "deadline_exceeded" && r.prompted !== false)
|
|
753
|
+
) {
|
|
705
754
|
try {
|
|
706
755
|
sessionReleased =
|
|
707
756
|
(await pool._closePooledAgentWithoutLock(task.sessionId)) ||
|
|
708
757
|
sessionReleased;
|
|
709
758
|
} catch (error) {
|
|
710
|
-
// Preserve the primary
|
|
759
|
+
// Preserve the primary failure result while logging the cleanup
|
|
711
760
|
// failure explicitly. A pooled session may still be removed by
|
|
712
761
|
// the pool; a pool-miss remains lifecycle-owned and is handled
|
|
713
762
|
// by the finally path above.
|
|
714
763
|
console.error(
|
|
715
|
-
`[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
|
|
764
|
+
`[delegate] failed to dispose aborted, stalled, or deadline-exceeded pooled session '${task.sessionId}'`,
|
|
716
765
|
error,
|
|
717
766
|
);
|
|
718
767
|
}
|
|
719
|
-
} else {
|
|
720
|
-
// Pool hits stay owned by the pool, and non-stalled
|
|
721
|
-
// (including failed attempts) must still count usage.
|
|
768
|
+
} else if (r.failureKind !== "deadline_exceeded") {
|
|
769
|
+
// Pool hits stay owned by the pool, and non-stalled, non-aborted
|
|
770
|
+
// completions (including failed attempts) must still count usage.
|
|
722
771
|
pool.recordUse(task.sessionId, r.tokens);
|
|
723
772
|
}
|
|
773
|
+
// Pre-prompt deadline (prompted === false): the pooled session was
|
|
774
|
+
// checked out but never used. Leave it in the pool with no usage
|
|
775
|
+
// recorded.
|
|
724
776
|
}
|
|
725
777
|
}
|
|
726
778
|
|
|
@@ -728,6 +780,7 @@ async function runResolvedTaskUnlocked(
|
|
|
728
780
|
cumulativeToolUses += attemptToolUsesObserved;
|
|
729
781
|
|
|
730
782
|
return {
|
|
783
|
+
id: task.id,
|
|
731
784
|
agent: task.agentName,
|
|
732
785
|
output: r.output,
|
|
733
786
|
error: r.error,
|
|
@@ -747,6 +800,7 @@ async function runResolvedTaskUnlocked(
|
|
|
747
800
|
usage: r.usage,
|
|
748
801
|
sessionFile,
|
|
749
802
|
touchedFiles: r.touchedFiles,
|
|
803
|
+
attributedFiles: r.attributedFiles ?? [],
|
|
750
804
|
};
|
|
751
805
|
} finally {
|
|
752
806
|
// This runs for ordinary success, normal provider failure, whole-task
|
|
@@ -756,24 +810,51 @@ async function runResolvedTaskUnlocked(
|
|
|
756
810
|
}
|
|
757
811
|
};
|
|
758
812
|
|
|
813
|
+
const buildDeadlineExceededResult = (prior?: TaskResult): TaskResult => {
|
|
814
|
+
const budgetMs = Math.max(0, (deadlineAt ?? 0) - taskStartedAt);
|
|
815
|
+
return {
|
|
816
|
+
id: task.id,
|
|
817
|
+
agent: task.agentName,
|
|
818
|
+
output: prior?.output ?? "",
|
|
819
|
+
error: formatDeadlineExceededError(budgetMs),
|
|
820
|
+
failureKind: "deadline_exceeded",
|
|
821
|
+
durationMs: prior?.durationMs ?? 0,
|
|
822
|
+
tokens: prior?.tokens ?? 0,
|
|
823
|
+
usage: prior?.usage ?? emptyUsage(),
|
|
824
|
+
sessionFile: prior?.sessionFile,
|
|
825
|
+
touchedFiles: prior?.touchedFiles ?? [],
|
|
826
|
+
attributedFiles: prior?.attributedFiles ?? [],
|
|
827
|
+
};
|
|
828
|
+
};
|
|
829
|
+
|
|
759
830
|
let result: TaskResult;
|
|
760
831
|
try {
|
|
761
|
-
|
|
832
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
833
|
+
result = buildDeadlineExceededResult(undefined);
|
|
834
|
+
} else {
|
|
835
|
+
result = await runAttempt();
|
|
836
|
+
}
|
|
762
837
|
} catch (err) {
|
|
763
838
|
const failure = failTask(
|
|
764
839
|
task,
|
|
765
840
|
err instanceof Error ? err.message : String(err),
|
|
766
841
|
);
|
|
767
|
-
return finishTask(
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
842
|
+
return finishTask(
|
|
843
|
+
env,
|
|
844
|
+
p,
|
|
845
|
+
{
|
|
846
|
+
...failure,
|
|
847
|
+
durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
|
|
848
|
+
tokens: accumulatedUsage.totalTokens,
|
|
849
|
+
usage: accumulatedUsage,
|
|
850
|
+
},
|
|
851
|
+
task,
|
|
852
|
+
);
|
|
773
853
|
}
|
|
774
854
|
|
|
775
855
|
const maxRetries = resolvedWholeTaskMaxRetries();
|
|
776
856
|
const baseDelayMs = resolvedWholeTaskBaseDelayMs();
|
|
857
|
+
let retriesExecuted = 0;
|
|
777
858
|
for (
|
|
778
859
|
let retry = 0;
|
|
779
860
|
retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
|
|
@@ -781,7 +862,7 @@ async function runResolvedTaskUnlocked(
|
|
|
781
862
|
) {
|
|
782
863
|
const delayMs = baseDelayMs * 2 ** retry;
|
|
783
864
|
p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
|
|
784
|
-
await sleepForWholeTaskRetry(env.signal, delayMs);
|
|
865
|
+
await sleepForWholeTaskRetry(env.signal, delayMs, deadlineAt);
|
|
785
866
|
if (env.signal?.aborted) {
|
|
786
867
|
// Preserve any partial output/session path from the last failed attempt
|
|
787
868
|
// while recording that the retry loop was aborted. The task already
|
|
@@ -798,10 +879,16 @@ async function runResolvedTaskUnlocked(
|
|
|
798
879
|
break;
|
|
799
880
|
}
|
|
800
881
|
|
|
882
|
+
if (deadlineAt && Date.now() >= deadlineAt) {
|
|
883
|
+
result = buildDeadlineExceededResult(result);
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
|
|
801
887
|
p.status = "running";
|
|
802
888
|
p.error = undefined;
|
|
803
889
|
p.failureKind = undefined;
|
|
804
890
|
env.onStatusChange?.();
|
|
891
|
+
retriesExecuted++;
|
|
805
892
|
try {
|
|
806
893
|
result = await runAttempt();
|
|
807
894
|
} catch (err) {
|
|
@@ -819,12 +906,18 @@ async function runResolvedTaskUnlocked(
|
|
|
819
906
|
}
|
|
820
907
|
}
|
|
821
908
|
|
|
822
|
-
return finishTask(
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
909
|
+
return finishTask(
|
|
910
|
+
env,
|
|
911
|
+
p,
|
|
912
|
+
{
|
|
913
|
+
...result,
|
|
914
|
+
durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
|
|
915
|
+
tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
|
|
916
|
+
usage: accumulatedUsage,
|
|
917
|
+
},
|
|
918
|
+
task,
|
|
919
|
+
retriesExecuted,
|
|
920
|
+
);
|
|
828
921
|
} catch (err) {
|
|
829
922
|
// Any acquired session is released by runAttempt's finally before an
|
|
830
923
|
// exception reaches this boundary. This outer catch handles unexpected
|
|
@@ -834,6 +927,7 @@ async function runResolvedTaskUnlocked(
|
|
|
834
927
|
env,
|
|
835
928
|
p,
|
|
836
929
|
failTask(task, err instanceof Error ? err.message : String(err)),
|
|
930
|
+
task,
|
|
837
931
|
);
|
|
838
932
|
}
|
|
839
933
|
}
|
package/manual.ts
CHANGED
|
@@ -73,13 +73,33 @@ export function getSubagentManualMarkdown(
|
|
|
73
73
|
'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
|
|
74
74
|
"```",
|
|
75
75
|
"",
|
|
76
|
-
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent
|
|
76
|
+
"Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
|
|
77
|
+
"",
|
|
78
|
+
"The three handles have different lifetimes:",
|
|
79
|
+
"",
|
|
80
|
+
"- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
|
|
81
|
+
"- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
|
|
82
|
+
"- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
|
|
83
|
+
"",
|
|
84
|
+
"Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
|
|
85
|
+
"",
|
|
86
|
+
"Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
|
|
87
|
+
"",
|
|
88
|
+
"## Touched Files (best-effort)",
|
|
89
|
+
"",
|
|
90
|
+
"The `touched:` list in each task result is a **best-effort lower bound**, not an authoritative record.",
|
|
91
|
+
"",
|
|
92
|
+
"- `write` and `edit` tool calls are captured reliably from the activity log.",
|
|
93
|
+
"- `bash` mutations are captured only when the task `cwd` is inside a git repo and git is available, via `git status` against the pre-run baseline.",
|
|
94
|
+
"- In a non-git directory, bash-mutated files are not reported.",
|
|
95
|
+
"- Git failures degrade to an empty diff.",
|
|
96
|
+
"- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
|
|
77
97
|
"",
|
|
78
98
|
"## Built-in Agent",
|
|
79
99
|
"",
|
|
80
100
|
"- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
|
|
81
101
|
"",
|
|
82
|
-
"Parent extension/MCP tools are not copied
|
|
102
|
+
"Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context is rebuilt safely for the task's `cwd`; per-task fields remain explicit overrides.",
|
|
83
103
|
"",
|
|
84
104
|
"## Available Custom Agents",
|
|
85
105
|
"",
|
|
@@ -118,10 +138,10 @@ export function getSubagentManualMarkdown(
|
|
|
118
138
|
'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
|
|
119
139
|
"",
|
|
120
140
|
"// Clean up when done",
|
|
121
|
-
'delegate({ tasks: [{ sessionId: "auth-research",
|
|
141
|
+
'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
|
|
122
142
|
"```",
|
|
123
143
|
"",
|
|
124
|
-
'Pooled agents remain live until `
|
|
144
|
+
'Pooled agents remain live until `sessionAction: "close"` or parent Pi session shutdown.',
|
|
125
145
|
"",
|
|
126
146
|
"## Resuming Previous Sessions",
|
|
127
147
|
"",
|
|
@@ -158,28 +178,36 @@ export function getSubagentManualMarkdown(
|
|
|
158
178
|
"",
|
|
159
179
|
"## Async Mode",
|
|
160
180
|
"",
|
|
161
|
-
"Set `async: true` to run tasks in the background. The top-level `
|
|
181
|
+
"Set `async: true` to run tasks in the background. The top-level `ticketAction` controls the ticket:",
|
|
162
182
|
"",
|
|
163
183
|
"```ts",
|
|
164
184
|
'delegate({ async: true, tasks: [{ prompt: "Investigate auth", systemPrompt: "You are a focused investigator.", tools: ["read", "grep", "find", "ls"] }] })',
|
|
165
185
|
"```",
|
|
166
186
|
"",
|
|
167
|
-
'- `delegate({
|
|
168
|
-
'- `delegate({
|
|
169
|
-
'- `delegate({
|
|
170
|
-
'- `delegate({
|
|
171
|
-
'- `delegate({
|
|
187
|
+
'- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
|
|
188
|
+
'- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
|
|
189
|
+
'- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
|
|
190
|
+
'- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
|
|
191
|
+
'- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
|
|
192
|
+
'- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
|
|
172
193
|
"",
|
|
173
194
|
`See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
|
|
195
|
+
"Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
|
|
174
196
|
"",
|
|
175
197
|
"## Gotchas",
|
|
176
198
|
"",
|
|
199
|
+
"- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
|
|
177
200
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
178
201
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
179
202
|
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
|
|
180
203
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
181
204
|
"- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
|
|
182
205
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
206
|
+
"- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
|
|
207
|
+
"",
|
|
208
|
+
"## Legacy `action` compatibility",
|
|
209
|
+
"",
|
|
210
|
+
"The overloaded `action` field was split into `ticketAction` (poll/wait/cancel) and `sessionAction` (prompt/close/list). Legacy `action` values are still accepted at runtime through automatic normalization, but new calls should use the canonical fields. Programmatic TypeScript consumers should note the exported type `DelegateAction` is now `TicketAction`.",
|
|
183
211
|
"",
|
|
184
212
|
"## Config",
|
|
185
213
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bermudi/pi-delegate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Delegate tool for the Pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"prettier": "^3.8.4",
|
|
33
33
|
"typescript": "^5.9.0"
|
|
34
34
|
},
|
|
35
|
+
"patchedDependencies": {
|
|
36
|
+
"@marcfargas/pi-test-harness@0.6.1": "patches/@marcfargas%2Fpi-test-harness@0.6.1.patch"
|
|
37
|
+
},
|
|
35
38
|
"private": false,
|
|
36
39
|
"scripts": {
|
|
37
40
|
"test": "bun test",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
diff --git a/dist/session.js b/dist/session.js
|
|
2
|
+
index f7dc5de9be959c541ee4bab0ad1424656993bbe9..e515073926f0fbb0e2bc566a9b17f5f15f80a1a1 100644
|
|
3
|
+
--- a/dist/session.js
|
|
4
|
+
+++ b/dist/session.js
|
|
5
|
+
@@ -12,7 +12,7 @@ import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
-import { getModel } from "@earendil-works/pi-ai";
|
|
10
|
+
+import { getModel } from "@earendil-works/pi-ai/compat";
|
|
11
|
+
import { createPlaybookStreamFn } from "./playbook.js";
|
|
12
|
+
import { interceptToolExecution } from "./mock-tools.js";
|
|
13
|
+
import { createMockUIContext } from "./mock-ui.js";
|
package/render-branches.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import {
|
|
4
4
|
fmtDuration,
|
|
5
5
|
fmtTokens,
|
|
6
|
+
formatTaskId,
|
|
6
7
|
getActivityAge,
|
|
7
8
|
indent,
|
|
8
9
|
tree,
|
|
@@ -107,7 +108,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
107
108
|
case "done":
|
|
108
109
|
lines.push(
|
|
109
110
|
truncLine(
|
|
110
|
-
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
111
|
+
`${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
111
112
|
w,
|
|
112
113
|
),
|
|
113
114
|
);
|
|
@@ -126,7 +127,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
126
127
|
case "failed":
|
|
127
128
|
lines.push(
|
|
128
129
|
truncLine(
|
|
129
|
-
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
|
|
130
|
+
`${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
|
|
130
131
|
w,
|
|
131
132
|
),
|
|
132
133
|
);
|
|
@@ -146,14 +147,19 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
146
147
|
{
|
|
147
148
|
const activityAge = getActivityAge(p.lastActivityAt);
|
|
148
149
|
const ageTag = activityAge ? ` · ${activityAge}` : "";
|
|
149
|
-
const
|
|
150
|
-
p.failureKind === "
|
|
151
|
-
? theme.fg("
|
|
152
|
-
: ""
|
|
150
|
+
const issueTag =
|
|
151
|
+
p.failureKind === "deadline_exceeded"
|
|
152
|
+
? theme.fg("error", " · deadline exceeded · cancellation pending")
|
|
153
|
+
: p.failureKind === "stalled"
|
|
154
|
+
? theme.fg(
|
|
155
|
+
"warning",
|
|
156
|
+
" · stall detected · cancellation pending",
|
|
157
|
+
)
|
|
158
|
+
: "";
|
|
153
159
|
const glyph = theme.fg("warning", spinnerFrame());
|
|
154
160
|
lines.push(
|
|
155
161
|
truncLine(
|
|
156
|
-
`${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin(runParts)}${
|
|
162
|
+
`${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin(runParts)}${issueTag}${theme.fg("muted", ageTag)}`,
|
|
157
163
|
w,
|
|
158
164
|
),
|
|
159
165
|
);
|
|
@@ -229,7 +235,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
229
235
|
);
|
|
230
236
|
lines.push(
|
|
231
237
|
truncLine(
|
|
232
|
-
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${modelLabel(p)} ${queuedTag}`,
|
|
238
|
+
`${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)} ${queuedTag}`,
|
|
233
239
|
w,
|
|
234
240
|
),
|
|
235
241
|
);
|
|
@@ -260,12 +266,18 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
260
266
|
const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
|
|
261
267
|
const ticketId = ctx.ticketId;
|
|
262
268
|
const ticketStatus = ctx.ticketStatus;
|
|
263
|
-
|
|
269
|
+
// A terminal ticket can retain a stale running/pending row while its workers
|
|
270
|
+
// unwind. Keep the row presentation terminal in that case; an absent status
|
|
271
|
+
// is the synchronous-render path, where task status remains authoritative.
|
|
272
|
+
const ticketIsLive =
|
|
273
|
+
ticketStatus === undefined ||
|
|
274
|
+
ticketStatus === "running" ||
|
|
275
|
+
ticketStatus === "cancelling";
|
|
264
276
|
const elapsed = state.startedAt
|
|
265
277
|
? fmtDuration(Date.now() - state.startedAt)
|
|
266
278
|
: fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
|
|
267
279
|
|
|
268
|
-
if (ticketId &&
|
|
280
|
+
if (ticketId && ticketIsLive) {
|
|
269
281
|
// Background ticket — frame it as in-progress, not a finished result.
|
|
270
282
|
const ticketParts = [
|
|
271
283
|
`ticket ${ticketId}`,
|
|
@@ -307,9 +319,15 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
307
319
|
: p.status === "running"
|
|
308
320
|
? theme.fg("warning", "◐")
|
|
309
321
|
: theme.fg("muted", "○");
|
|
310
|
-
const
|
|
322
|
+
const taskId = p.id ? formatTaskId(p.id) : "";
|
|
323
|
+
const taskIdTag = p.id ? theme.fg("accent", taskId) : "";
|
|
324
|
+
const taskIdWidth = p.id ? taskId.length : 0;
|
|
325
|
+
const previewBudget = Math.max(1, w - 30 - taskIdWidth);
|
|
326
|
+
const taskPreview = theme.fg("muted", trunc(p.task, previewBudget));
|
|
311
327
|
const isLive =
|
|
312
|
-
|
|
328
|
+
ticketIsLive &&
|
|
329
|
+
(p.status === "running" ||
|
|
330
|
+
(p.status === "pending" && !isCancelledPending));
|
|
313
331
|
// Live tasks show an activity/waiting hint instead of final stats.
|
|
314
332
|
const liveTail =
|
|
315
333
|
p.status === "running"
|
|
@@ -322,7 +340,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
322
340
|
: "";
|
|
323
341
|
lines.push(
|
|
324
342
|
truncLine(
|
|
325
|
-
`${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
343
|
+
`${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)}${taskIdTag} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
|
|
326
344
|
w,
|
|
327
345
|
),
|
|
328
346
|
);
|
package/render-result.ts
CHANGED
|
@@ -166,6 +166,18 @@ export function renderDelegateResult(
|
|
|
166
166
|
ticketStatus,
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
+
// Surface the touched-file overlap warning at the top of the TUI. The same
|
|
170
|
+
// text already lives in the textual content, but the progress-based renderer
|
|
171
|
+
// ignores content, so we must render it explicitly from details. Rendering it
|
|
172
|
+
// before the progress tree places it at the top of the budgeted region, so it
|
|
173
|
+
// survives truncation from the bottom when many tasks collapse the view.
|
|
174
|
+
if (details?.overlapWarning) {
|
|
175
|
+
lines.push(
|
|
176
|
+
truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
|
|
177
|
+
"",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
if (options.isPartial) {
|
|
170
182
|
renderPartialBranch(branchCtx, helpers);
|
|
171
183
|
} else {
|