@bermudi/pi-delegate 0.1.10 → 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 +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +11 -3
- package/dispatch.ts +436 -65
- package/extension.ts +16 -4
- package/format.ts +52 -6
- package/host-cache.ts +70 -0
- package/host.ts +169 -811
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +699 -520
- package/manual.ts +4 -7
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/pool.ts +23 -1
- package/provider-extensions.ts +537 -0
- package/quiescence.ts +262 -0
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +35 -141
- package/schema.ts +133 -202
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +121 -32
- package/telemetry.ts +135 -68
- package/ticket-format.ts +331 -0
- package/tickets.ts +101 -258
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +83 -16
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`
|
|
58
|
-
`
|
|
59
|
-
Markdown file, while `default` ignores
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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 () => {
|