@bermudi/pi-delegate 0.1.3 → 0.1.6

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 CHANGED
@@ -38,6 +38,31 @@ Parent extension/MCP tools are not copied, and project instructions are rebuilt
38
38
  for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
39
39
  normal inline defaults instead.
40
40
 
41
+ ### Disposable scratch workspace
42
+
43
+ For review, tests, or other commands whose project changes should be thrown
44
+ away, run a one-shot task in a CoW copy:
45
+
46
+ ```ts
47
+ delegate({
48
+ tasks: [
49
+ { prompt: "Review this change and run its tests", workspace: "scratch" },
50
+ ],
51
+ });
52
+ ```
53
+
54
+ Delegate reflink-copies the containing Git repository beside the original, runs
55
+ the subagent in the corresponding copied directory, then deletes the copy. It
56
+ requires Linux with `/proc/self/fd`, GNU `cp`, and a reflink-capable filesystem
57
+ such as Btrfs; it never falls back to an expensive full copy. Scratch mode
58
+ cannot use `sessionId`,
59
+ `resumeFrom`, session actions, linked Git worktrees, or project symlinks that
60
+ point outside the copied tree.
61
+
62
+ This protects the real project from ordinary relative writes. It is not a
63
+ security sandbox: unrestricted commands and absolute paths can still reach the
64
+ host filesystem.
65
+
41
66
  ### Token accounting
42
67
 
43
68
  Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
@@ -98,7 +123,8 @@ over an installed extension.
98
123
 
99
124
  - **Delegate task** — One item in `delegate({ tasks: [...] })`. This is the
100
125
  core unit of work: a prompt plus optional overrides such as `agent`, `tools`,
101
- `systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or `resumeFrom`.
126
+ `systemPrompt`, `thinking`, `cwd`, `context`, `workspace`, `sessionId`, or
127
+ `resumeFrom`.
102
128
  `model` is also accepted but should be rare — subagents inherit the parent
103
129
  model by default.
104
130
  - **Default subagent** — The reserved built-in `agent: "default"` profile. It
package/config.ts CHANGED
@@ -131,7 +131,7 @@ function normalizeProviderExtensions(
131
131
  // `providerExtensions` replaces a provider's entries (it does not append); an
132
132
  // empty array is ignored so the default persists.
133
133
  const DEFAULT_PROVIDER_EXTENSIONS: Record<string, readonly string[]> = {
134
- "openai-codex": ["npm:@ogulcancelik/pi-codex-compaction"],
134
+ "openai-codex": ["git:github.com/bermudi/manaflow-pi-codex"],
135
135
  };
136
136
 
137
137
  function resolveProviderExtensions(
package/delegate.ts CHANGED
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
3
3
  export type {
4
4
  AgentConfig,
5
5
  SessionAction,
6
+ WorkspaceMode,
6
7
  TicketAction,
7
8
  DelegateAction,
8
9
  DelegateArguments,
@@ -77,6 +78,8 @@ export {
77
78
  isCrossLeafTicket,
78
79
  } from "./leaf.ts";
79
80
  export { runAgentSession } from "./runner.ts";
81
+ export { createScratchWorkspace } from "./workspace.ts";
82
+ export type { ScratchWorkspace } from "./workspace.ts";
80
83
  export {
81
84
  activeTicketSummary,
82
85
  buildStatusText,
package/format.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  TaskProgress,
8
8
  TaskResult,
9
9
  ToolActivity,
10
+ WorkspaceMode,
10
11
  } from "./types.ts";
11
12
 
12
13
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -517,7 +518,10 @@ export function relativeTouchedSummary(
517
518
  * in the same repository do not fabricate false conflicts from shared
518
519
  * repository-wide git snapshots. */
519
520
  export function findTouchedOverlaps(
520
- results: readonly { attributedFiles?: string[] }[],
521
+ results: readonly {
522
+ attributedFiles?: string[];
523
+ workspace?: WorkspaceMode;
524
+ }[],
521
525
  ): string[] {
522
526
  const counts = new Map<string, number>();
523
527
  for (const r of results) {
package/host-compat.ts CHANGED
@@ -23,6 +23,7 @@ const REQUIRED_EXPORTS: ExportCheck[] = [
23
23
  { name: "SettingsManager", requiredMember: "create" },
24
24
  { name: "SessionManager", requiredMember: "create" },
25
25
  { name: "SessionManager", requiredMember: "open" },
26
+ { name: "SessionManager", requiredMember: "inMemory" },
26
27
  { name: "DefaultResourceLoader" },
27
28
  { name: "DefaultPackageManager" },
28
29
  { name: "createAgentSession" },
package/lifecycle.ts CHANGED
@@ -28,6 +28,7 @@ import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
28
28
  import { addUsage, emptyUsage } from "./usage.ts";
29
29
  import { scheduleDeadline } from "./timer.ts";
30
30
  import { recordTask } from "./telemetry.ts";
31
+ import { createScratchWorkspace, ScratchDeadlineError } from "./workspace.ts";
31
32
 
32
33
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
33
34
  type RunAgentSession = typeof runAgentSession;
@@ -181,6 +182,11 @@ function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
181
182
  p.failureKind = r.failureKind;
182
183
  }
183
184
 
185
+ const taskRetryCounts = new WeakMap<TaskResult, number>();
186
+ /** The SQLite task id is part of the logical task, not the transient result
187
+ * object. Scratch wrapping clones results, so keep this alongside retry data. */
188
+ const taskTelemetryIds = new WeakMap<TaskResult, string>();
189
+
184
190
  /** Apply a TaskResult to progress and notify the env (sync fires onUpdate).
185
191
  * Used at every return point in runResolvedTask — mirrors the old fire() pattern
186
192
  * that the duplicated sync/async bodies used after every early-return. */
@@ -192,8 +198,9 @@ function finishTask(
192
198
  retries = 0,
193
199
  ): TaskResult {
194
200
  updateProgressFromResult(p, r);
201
+ taskRetryCounts.set(r, retries);
195
202
  if (env.telemetryCallId) {
196
- recordTask({
203
+ const id = recordTask({
197
204
  callId: env.telemetryCallId,
198
205
  generation: env.telemetryGeneration,
199
206
  async: env.async ?? false,
@@ -203,6 +210,7 @@ function finishTask(
203
210
  result: r,
204
211
  retries,
205
212
  });
213
+ if (id) taskTelemetryIds.set(r, id);
206
214
  }
207
215
  env.onStatusChange?.();
208
216
  return r;
@@ -494,15 +502,24 @@ async function acquireAgentSession(
494
502
  }
495
503
 
496
504
  // ── Fresh session (no resume) ────────────────────────────────────────────
497
- const fresh = createSubagentSessionManager(
498
- env.parentSessionManager,
499
- task.cwd,
500
- );
501
- if (!fresh) {
502
- return { error: failTask(task, "Internal: could not create session file") };
505
+ if (task.workspace === "scratch") {
506
+ // A discarded filesystem must not advertise a resumable conversation: a
507
+ // later resume would run against the source cwd and silently lose scratch
508
+ // isolation. Keep scratch transcripts in memory only.
509
+ sessionManager = SessionManager.inMemory(task.cwd);
510
+ } else {
511
+ const fresh = createSubagentSessionManager(
512
+ env.parentSessionManager,
513
+ task.cwd,
514
+ );
515
+ if (!fresh) {
516
+ return {
517
+ error: failTask(task, "Internal: could not create session file"),
518
+ };
519
+ }
520
+ sessionManager = fresh.manager;
521
+ sessionFile = fresh.file;
503
522
  }
504
- sessionManager = fresh.manager;
505
- sessionFile = fresh.file;
506
523
 
507
524
  const session = await buildDelegateSession(
508
525
  task,
@@ -564,6 +581,166 @@ async function runResolvedTaskUnlocked(
564
581
  task: ResolvedTask,
565
582
  p: TaskProgress,
566
583
  taskIndex: number,
584
+ ): Promise<TaskResult> {
585
+ if (task.workspace !== "scratch") {
586
+ return runResolvedTaskCore(env, task, p, taskIndex);
587
+ }
588
+ if (task.sessionId || task.resumeFrom || task.sessionAction) {
589
+ return finishTask(
590
+ env,
591
+ p,
592
+ failTask(
593
+ task,
594
+ "workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
595
+ ),
596
+ task,
597
+ );
598
+ }
599
+
600
+ const startedAt = Date.now();
601
+ const deadlineAt =
602
+ task.deadlineMs && task.deadlineMs > 0
603
+ ? startedAt + task.deadlineMs
604
+ : undefined;
605
+ let workspace: Awaited<ReturnType<typeof createScratchWorkspace>>;
606
+ try {
607
+ workspace = await createScratchWorkspace(task.cwd, env.signal, deadlineAt);
608
+ } catch (error) {
609
+ const setupError = error instanceof Error ? error.message : String(error);
610
+ const deadlineExceeded =
611
+ !env.signal?.aborted && error instanceof ScratchDeadlineError;
612
+ return finishTask(
613
+ env,
614
+ p,
615
+ {
616
+ ...failTask(
617
+ task,
618
+ env.signal?.aborted
619
+ ? "Aborted"
620
+ : deadlineExceeded
621
+ ? formatDeadlineExceededError(task.deadlineMs ?? 0)
622
+ : setupError,
623
+ ),
624
+ failureKind: deadlineExceeded ? "deadline_exceeded" : undefined,
625
+ durationMs: Date.now() - startedAt,
626
+ },
627
+ task,
628
+ );
629
+ }
630
+
631
+ const executionTask: ResolvedTask = {
632
+ ...task,
633
+ cwd: workspace.cwd,
634
+ };
635
+ let result: TaskResult | undefined;
636
+ let cleanupError: string | undefined;
637
+ let needsCorrection = false;
638
+ let telemetryTaskId: string | undefined;
639
+ let retries = 0;
640
+ try {
641
+ const preMappingResult = await runResolvedTaskCore(
642
+ env,
643
+ executionTask,
644
+ p,
645
+ taskIndex,
646
+ {
647
+ taskStartedAt: startedAt,
648
+ deadlineAt,
649
+ },
650
+ );
651
+ result = preMappingResult;
652
+ // Capture metadata before scratch wrapping creates a new result object.
653
+ // Both values belong to the logical task and must survive that clone.
654
+ telemetryTaskId = taskTelemetryIds.get(result);
655
+ retries = taskRetryCounts.get(result) ?? 0;
656
+ result = {
657
+ ...result,
658
+ workspace: "scratch",
659
+ sessionFile: undefined,
660
+ touchedFiles: await Promise.all(
661
+ result.touchedFiles.map((file) => workspace.resolveReportedPath(file)),
662
+ ),
663
+ // Writes inside scratch are discarded and cannot conflict. Explicit
664
+ // writes outside scratch (for example an absolute host path) persist and
665
+ // must remain attributable for overlap warnings. Resolve those paths
666
+ // physically so aliases to the same host file compare equally.
667
+ attributedFiles: (
668
+ await Promise.all(
669
+ (result.attributedFiles ?? []).map((file) =>
670
+ workspace.resolveAttributedPath(file),
671
+ ),
672
+ )
673
+ ).filter((file): file is string => file !== undefined),
674
+ };
675
+ } catch (error) {
676
+ result = {
677
+ ...failTask(task, error instanceof Error ? error.message : String(error)),
678
+ ...(result
679
+ ? {
680
+ tokens: result.tokens,
681
+ usage: result.usage,
682
+ }
683
+ : {}),
684
+ workspace: "scratch",
685
+ durationMs: Date.now() - startedAt,
686
+ };
687
+ // runResolvedTaskCore may already have recorded/notified a successful
688
+ // result before path mapping failed. Correct those observable outcomes.
689
+ needsCorrection = true;
690
+ } finally {
691
+ try {
692
+ await workspace.cleanup();
693
+ } catch (error) {
694
+ cleanupError = `Scratch workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
695
+ console.error("[delegate] scratch workspace cleanup failed", error);
696
+ }
697
+ }
698
+
699
+ if (!result) {
700
+ result = {
701
+ ...failTask(task, "Scratch task failed before producing a result."),
702
+ workspace: "scratch",
703
+ durationMs: Date.now() - startedAt,
704
+ };
705
+ }
706
+ if (cleanupError || needsCorrection) {
707
+ result = {
708
+ ...result,
709
+ error: cleanupError
710
+ ? result.error
711
+ ? `${result.error}\n${cleanupError}`
712
+ : cleanupError
713
+ : result.error,
714
+ durationMs: Math.max(result.durationMs, Date.now() - startedAt),
715
+ };
716
+ updateProgressFromResult(p, result);
717
+ if (env.telemetryCallId) {
718
+ // runResolvedTaskCore already recorded the pre-cleanup result. Telemetry
719
+ // rows are upserts, so replace it with the actual returned outcome while
720
+ // preserving the retry count captured by finishTask.
721
+ recordTask({
722
+ id: telemetryTaskId,
723
+ callId: env.telemetryCallId,
724
+ generation: env.telemetryGeneration,
725
+ async: env.async ?? false,
726
+ taskIndex: p.index,
727
+ task,
728
+ progress: p,
729
+ result,
730
+ retries,
731
+ });
732
+ }
733
+ env.onStatusChange?.();
734
+ }
735
+ return result;
736
+ }
737
+
738
+ async function runResolvedTaskCore(
739
+ env: TaskRunEnv,
740
+ task: ResolvedTask,
741
+ p: TaskProgress,
742
+ taskIndex: number,
743
+ timing?: { taskStartedAt: number; deadlineAt: number | undefined },
567
744
  ): Promise<TaskResult> {
568
745
  try {
569
746
  // ── Aborted before we started? ───────────────────────────────────
@@ -629,11 +806,12 @@ async function runResolvedTaskUnlocked(
629
806
  let hasBashExecution = false;
630
807
  let cumulativeTokens = 0;
631
808
  let cumulativeToolUses = 0;
632
- const taskStartedAt = Date.now();
809
+ const taskStartedAt = timing?.taskStartedAt ?? Date.now();
633
810
  const deadlineAt =
634
- task.deadlineMs && task.deadlineMs > 0
811
+ timing?.deadlineAt ??
812
+ (task.deadlineMs && task.deadlineMs > 0
635
813
  ? taskStartedAt + task.deadlineMs
636
- : undefined;
814
+ : undefined);
637
815
  let accumulatedUsage = emptyUsage();
638
816
 
639
817
  const onAttemptProgress = (u: AgentProgressUpdate): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/schema.ts CHANGED
@@ -103,6 +103,13 @@ export const delegateTaskSchema = Type.Object({
103
103
  "Wall-clock budget (ms) from run start after queueing. Cooperative abort; side effects remain. Omit disables.",
104
104
  }),
105
105
  ),
106
+ workspace: Type.Optional(
107
+ StringEnum(["shared", "scratch"], {
108
+ description:
109
+ "scratch=disposable CoW project copy; relative edits are discarded. Not security isolation. Default=shared.",
110
+ default: "shared",
111
+ }),
112
+ ),
106
113
  });
107
114
 
108
115
  // Single source of truth for registration and generated help. The exported
@@ -146,7 +153,7 @@ export const delegateArgumentsSchema = Type.Object({
146
153
  Type.Array(delegateTaskSchema, {
147
154
  minItems: 0,
148
155
  description:
149
- "Tasks share the real filesystem and run concurrently; separate dependent/shared-file work. []=full manual.",
156
+ "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
150
157
  }),
151
158
  ),
152
159
  });
@@ -167,6 +174,7 @@ const TASK_FIELD_NAMES = [
167
174
  "sessionAction",
168
175
  "resumeFrom",
169
176
  "deadlineMs",
177
+ "workspace",
170
178
  ] as const;
171
179
 
172
180
  /** Every field a task entry may carry. Anything else is a model mistake —
@@ -317,6 +325,12 @@ export function validateDelegateOperation(
317
325
  if (typeof rawTask.deadlineMs === "number" && !(rawTask.deadlineMs > 0)) {
318
326
  return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
319
327
  }
328
+ if (
329
+ task.workspace === "scratch" &&
330
+ (task.sessionId || task.resumeFrom || sessionAction !== undefined)
331
+ ) {
332
+ return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.`;
333
+ }
320
334
  if (sessionAction === "close") {
321
335
  if (!task.sessionId) {
322
336
  return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
@@ -208,6 +208,11 @@ export function resolveTasks(
208
208
  );
209
209
  let tools: string[] = [];
210
210
  const warnings: string[] = [];
211
+ if (t.workspace === "scratch") {
212
+ warnings.push(
213
+ "Scratch workspace: relative file changes run in a disposable CoW copy and are discarded.",
214
+ );
215
+ }
211
216
 
212
217
  // Prompt is required for fresh tasks. ResumeFrom provides context already.
213
218
  if (
@@ -418,6 +423,7 @@ export function resolveTasks(
418
423
  ...t,
419
424
  id: t.id,
420
425
  cwd,
426
+ workspace: t.workspace ?? "shared",
421
427
  systemPrompt,
422
428
  model: model!,
423
429
  tools,
package/telemetry.ts CHANGED
@@ -604,6 +604,8 @@ export function recordCall(record: CallRecord, generation?: number): void {
604
604
  }
605
605
 
606
606
  export interface TaskSpanInput {
607
+ /** Stable identity for correction writes of the same logical task row. */
608
+ id?: string;
607
609
  callId: string;
608
610
  /** Runtime generation captured by the dispatch that owns this task. */
609
611
  generation?: number;
@@ -622,13 +624,15 @@ function outcomeFromResult(result: TaskResult): string {
622
624
  return "success";
623
625
  }
624
626
 
625
- export function recordTask(input: TaskSpanInput): void {
627
+ export function recordTask(input: TaskSpanInput): string | undefined {
626
628
  const b = getBackend(input.generation);
627
- if (!b) return;
629
+ if (!b) return undefined;
628
630
 
629
631
  const { callId, async, taskIndex, task, progress, result, retries } = input;
630
632
  const record: TaskRecord = {
631
- id: crypto.randomUUID(),
633
+ // Correction writes must reuse the provisional row's primary key. A fresh
634
+ // UUID here would make INSERT OR REPLACE append a second task row.
635
+ id: input.id ?? crypto.randomUUID(),
632
636
  call_id: callId,
633
637
  ts: Date.now(),
634
638
  version: getDelegateVersion(),
@@ -651,6 +655,7 @@ export function recordTask(input: TaskSpanInput): void {
651
655
  async: async ? 1 : 0,
652
656
  };
653
657
  b.recordTask(record);
658
+ return record.id;
654
659
  }
655
660
 
656
661
  /** Prevent this runtime's late workers from writing after a bounded shutdown
package/types.ts CHANGED
@@ -43,6 +43,8 @@ export type TicketAction = NonNullable<
43
43
  >;
44
44
  /** Per-task session action: "prompt" | "close" | "list". */
45
45
  export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
46
+ /** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
47
+ export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
46
48
 
47
49
  export type TaskDef = CanonicalTaskDef & {
48
50
  /** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
@@ -123,6 +125,7 @@ export interface ResolvedTask {
123
125
  thinking: ThinkingLevel;
124
126
  systemPrompt: string;
125
127
  cwd: string;
128
+ workspace?: WorkspaceMode;
126
129
  context?: "fresh" | "with-parent-transcript";
127
130
  sessionId?: string;
128
131
  sessionAction?: SessionAction;
@@ -211,6 +214,8 @@ export interface TaskResult {
211
214
  * stay 0 because `getSessionStats()` exposes only the aggregate cost — and
212
215
  * Pi sums `cost.total` for nested usage anyway. */
213
216
  usage: Usage;
217
+ /** Scratch results are excluded from shared-file conflict detection and never resumable. */
218
+ workspace?: WorkspaceMode;
214
219
  sessionFile?: string;
215
220
  /** All files the subagent is known to have touched, including bash mutations
216
221
  * captured via git diff and other tasks' concurrent git changes. This is a