@bermudi/pi-delegate 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,20 @@ The other built-ins are:
47
47
  default. Set `workspace: "shared"` when a reviewer needs a persistent
48
48
  `sessionId`.
49
49
 
50
+ ### Shared-write safety
51
+
52
+ Before starting work, Delegate resolves each task's real tools and physical Git
53
+ root. If a task could mutate a shared tree that overlaps another task in the
54
+ same call or a still-running sync/async dispatch, the new call is rejected
55
+ before any subagent starts. Unknown tool names are treated as mutating. External
56
+ processes are outside this in-process gate.
57
+
58
+ Operators can deliberately bypass this check by setting
59
+ `"allowUnsafeSharedWrites": true` in `~/.pi/agent/delegate.json`. This setting
60
+ is intentionally absent from the model-facing tool API. It provides no
61
+ isolation or rollback, and Delegate marks running and final results with a
62
+ visible batch-level warning while it is active.
63
+
50
64
  A same-named Markdown file can override any built-in (first definition wins
51
65
  across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
52
66
  `~/.claude/agents/`). A prompt-only override keeps the built-in's tools and
@@ -54,10 +68,20 @@ workspace — `scout` stays read-only and `reviewer` stays scratch unless the
54
68
  file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
55
69
  parent's exact model object and thinking level; an explicit `model`/`thinking`
56
70
  in the Markdown file replaces that inheritance. Task fields always win, and for
57
- `scout`/`coder`/`reviewer` settings overrides (`settings.json`
58
- `delegate.agentOverrides` / `delegate.agentOverridesByParentModel`) win over the
59
- Markdown file, while `default` ignores settings and uses only an explicit
60
- Markdown `model`/`thinking` when present.
71
+ `scout`/`coder`/`reviewer` overrides in `~/.pi/agent/delegate.json`
72
+ (`agentOverrides` / `agentOverridesByParentModel`) win over the
73
+ Markdown file, while `default` ignores overrides and uses only an explicit
74
+ Markdown `model`/`thinking` when present. `delegate.json` is the permanent
75
+ config file (user scope, global), and edits apply from the next delegate call.
76
+
77
+ For the v0.1.12 migration release only, legacy user and nearest-project
78
+ `settings.json` `delegate.agentOverrides` /
79
+ `delegate.agentOverridesByParentModel` still supply `model` and `thinking`
80
+ when a modern value is absent. Modern `delegate.json` wins field-by-field.
81
+ Legacy `tools` is never honored because a project file must not restore shell
82
+ capability. This bridge is removed in v0.1.13. Project-local replacements are
83
+ `.pi/agents/*.md` profiles or explicit task `model`/`thinking` fields; there
84
+ will be no new project-level delegate config file.
61
85
 
62
86
  ### Disposable scratch workspace
63
87
 
@@ -84,6 +108,35 @@ This protects the real project from ordinary relative writes. It is not a
84
108
  security sandbox: unrestricted commands and absolute paths can still reach the
85
109
  host filesystem.
86
110
 
111
+ ### Git-native isolated writers
112
+
113
+ Use `workspace: "isolated"` for synchronous, one-shot coding tasks that should
114
+ run in parallel without sharing a working tree:
115
+
116
+ ```ts
117
+ delegate({
118
+ tasks: [
119
+ { prompt: "Implement the parser change", workspace: "isolated" },
120
+ { prompt: "Update the parser tests", workspace: "isolated" },
121
+ ],
122
+ });
123
+ ```
124
+
125
+ Delegate snapshots tracked changes, deletions, and non-ignored untracked files
126
+ into a private baseline commit without touching the user's index or branch.
127
+ Each task gets a detached worktree. Successful proposals are captured as
128
+ private refs and full binary patches, then reconciled in task-array order. A
129
+ proposal applies all-or-nothing; conflicts retain the proposal ref, full patch,
130
+ and conflict worktree. Before updating the source, Delegate checks that it
131
+ still matches the baseline.
132
+
133
+ A clean reconciliation reports `applied_unverified`: textual merging does not
134
+ prove the code builds or tests pass, so the result includes a suggested
135
+ verification call. Isolated mode currently requires Git, rejects async and
136
+ session reuse, disables whole-task retries, and rejects repositories with
137
+ submodules. It is separation, not confinement: absolute-path writes can still
138
+ reach the host.
139
+
87
140
  ### Token accounting
88
141
 
89
142
  Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
package/concurrency.ts CHANGED
@@ -61,11 +61,7 @@ function acquireGlobal(signal?: AbortSignal): Promise<boolean> {
61
61
  });
62
62
  }
63
63
 
64
- function releaseGlobal(): void {
65
- globalConcurrencyRunning--;
66
- // Never hand a slot to a waiter whose signal already aborted. The abort
67
- // listener normally removes it synchronously during abort() dispatch; this
68
- // drain is defensive for any ordering edge.
64
+ function removeAbortedWaiters(): void {
69
65
  while (
70
66
  globalConcurrencyWaiters.length > 0 &&
71
67
  globalConcurrencyWaiters[0]!.signal?.aborted
@@ -74,34 +70,77 @@ function releaseGlobal(): void {
74
70
  w.signal?.removeEventListener("abort", w.onAbort);
75
71
  w.resolve(false);
76
72
  }
77
- if (globalConcurrencyWaiters.length > 0) {
78
- globalConcurrencyRunning++;
79
- const w = globalConcurrencyWaiters.shift()!;
80
- w.signal?.removeEventListener("abort", w.onAbort);
81
- w.resolve(true);
73
+ }
74
+
75
+ /** Wake the next eligible waiter if capacity allows. Returns true if a slot was
76
+ * handed out (and `globalConcurrencyRunning` already incremented). */
77
+ function wakeNextWaiter(): boolean {
78
+ removeAbortedWaiters();
79
+ if (
80
+ globalConcurrencyWaiters.length === 0 ||
81
+ globalConcurrencyRunning >= globalConcurrencyLimit
82
+ ) {
83
+ return false;
84
+ }
85
+ const w = globalConcurrencyWaiters.shift()!;
86
+ globalConcurrencyRunning++;
87
+ w.signal?.removeEventListener("abort", w.onAbort);
88
+ w.resolve(true);
89
+ return true;
90
+ }
91
+
92
+ function releaseGlobal(): void {
93
+ globalConcurrencyRunning--;
94
+ wakeNextWaiter();
95
+ }
96
+
97
+ /** Safely change the global concurrency cap at runtime.
98
+ *
99
+ * Newly queued tasks observe the new limit immediately; already-running tasks
100
+ * keep their slots. If the limit was raised, this wakes as many eligible
101
+ * queued waiters as the new cap allows. Malformed limits fall back to 1. */
102
+ export function reconfigureGlobalConcurrency(limit: number): void {
103
+ const safe =
104
+ typeof limit === "number" && Number.isFinite(limit) && limit > 0
105
+ ? limit
106
+ : 1;
107
+ globalConcurrencyLimit = Math.max(1, safe);
108
+ while (wakeNextWaiter()) {
109
+ // Wake up to the new limit.
82
110
  }
83
111
  }
84
112
 
85
113
  /** Test-only hook: override the global concurrency cap. */
86
114
  export function _setGlobalConcurrencyLimitForTesting(limit: number): void {
87
- globalConcurrencyLimit = Math.max(1, limit);
115
+ reconfigureGlobalConcurrency(limit);
88
116
  }
89
117
 
90
- /** Test-only hook: reset the global semaphore to the configured cap. */
118
+ /** Test-only hook: reset the global semaphore to the configured cap.
119
+ * Counters and waiters are cleared *before* reconfiguring so the wake loop
120
+ * inside reconfigureGlobalConcurrency cannot hand out slots against stale
121
+ * bookkeeping (only to have them zeroed) or resolve waiters that the clears
122
+ * then abandon. Called when the semaphore should be idle; clearing semantics
123
+ * are unchanged — only the ordering is safe now. */
91
124
  export function _resetGlobalConcurrencyForTesting(): void {
92
- globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
93
125
  globalConcurrencyRunning = 0;
94
126
  globalConcurrencyWaiters.length = 0;
127
+ reconfigureGlobalConcurrency(getMaxConcurrent());
95
128
  }
96
129
 
97
- async function mapConcurrent<T, R>(
98
- items: T[],
130
+ export async function mapConcurrent<T, R>(
131
+ items: readonly T[],
99
132
  concurrency: number,
100
133
  fn: (item: T, index: number) => Promise<R>,
101
134
  signal?: AbortSignal,
102
135
  ): Promise<R[]> {
103
136
  if (items.length === 0) return [];
104
- const limit = Math.max(1, Math.min(concurrency, items.length));
137
+ const safeConcurrency =
138
+ typeof concurrency === "number" &&
139
+ Number.isFinite(concurrency) &&
140
+ concurrency > 0
141
+ ? concurrency
142
+ : 1;
143
+ const limit = Math.max(1, Math.min(safeConcurrency, items.length));
105
144
  const results: R[] = new Array(items.length);
106
145
  let next = 0;
107
146
  const worker = async () => {