pi-harness-delegate 0.7.1 → 0.7.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 +15 -4
- package/extensions/acp-runner.ts +7 -1
- package/extensions/activity.ts +3 -0
- package/extensions/command.ts +14 -4
- package/extensions/config.ts +49 -43
- package/extensions/engine.ts +182 -19
- package/extensions/fanout.ts +2 -1
- package/extensions/harnesses/amp.ts +5 -0
- package/extensions/harnesses/claude.ts +8 -0
- package/extensions/harnesses/codex.ts +3 -0
- package/extensions/harnesses/devin.ts +9 -1
- package/extensions/harnesses/opencode.ts +6 -0
- package/extensions/harnesses/registry.ts +78 -15
- package/extensions/harnesses/types.ts +12 -0
- package/extensions/index.ts +126 -126
- package/extensions/notify.ts +18 -5
- package/extensions/templates.ts +198 -30
- package/package.json +2 -1
package/extensions/templates.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface DelegateTemplate {
|
|
|
23
23
|
nativePermission?: string;
|
|
24
24
|
/** Legacy raw permissionMode for transcript compat. */
|
|
25
25
|
permissionMode: PermissionMode;
|
|
26
|
+
/**
|
|
27
|
+
* A problem with the template's permission keys, shown in `/delegate list` and at run time: an
|
|
28
|
+
* unrecognized legacy `permissionMode:`/`sandbox:` value (failed closed to `readonly`), or a
|
|
29
|
+
* legacy key that disagrees with `permission:` and was ignored (tier unchanged).
|
|
30
|
+
*/
|
|
31
|
+
permissionWarning?: string;
|
|
26
32
|
model?: string;
|
|
27
33
|
maxBudgetUsd?: number;
|
|
28
34
|
skill?: string;
|
|
@@ -36,35 +42,180 @@ export interface DelegateTemplate {
|
|
|
36
42
|
harness?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Tier order, least permissive first — used to pick the safer of two conflicting legacy keys. */
|
|
46
|
+
const TIER_RANK: Record<NormalizedPermission, number> = { readonly: 0, edit: 1, danger: 2 };
|
|
47
|
+
|
|
48
|
+
const TIER_PERMISSION_MODE: Record<NormalizedPermission, PermissionMode> = {
|
|
49
|
+
readonly: 'plan',
|
|
50
|
+
edit: 'acceptEdits',
|
|
51
|
+
danger: 'bypassPermissions',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Claude `PermissionMode` names, matched case- and `-`/`_`-insensitively (`accept-edits` → `acceptEdits`). */
|
|
55
|
+
const PERMISSION_MODES_BY_LOWER = new Map<string, PermissionMode>([...PERMISSION_MODES].map(m => [m.toLowerCase(), m]));
|
|
56
|
+
|
|
57
|
+
/** Codex `--sandbox` values (the legacy `sandbox:` key) → normalized tier. Keys are lowercase, `_`→`-`. */
|
|
58
|
+
const LEGACY_SANDBOX_TIERS: Record<string, NormalizedPermission> = {
|
|
59
|
+
'read-only': 'readonly',
|
|
60
|
+
readonly: 'readonly',
|
|
61
|
+
'workspace-write': 'edit',
|
|
62
|
+
'danger-full-access': 'danger',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A frontmatter value echoed back in a warning (`/delegate list`, run-time notes): JSON-quoted so
|
|
67
|
+
* control characters / ANSI escapes are inert, and capped so a huge value can't flood the line.
|
|
68
|
+
*/
|
|
69
|
+
function quoteValue(value: string): string {
|
|
70
|
+
return JSON.stringify(value).slice(0, 60);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface LegacyPermission {
|
|
74
|
+
permission: NormalizedPermission;
|
|
75
|
+
permissionMode: PermissionMode;
|
|
76
|
+
/** Set when a legacy value was unrecognized and the template was failed closed to `readonly`. */
|
|
77
|
+
permissionWarning?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map one legacy `permissionMode:`/`sandbox:` value onto a normalized tier. Both keys accept both
|
|
82
|
+
* vocabularies (claude PermissionMode names and codex sandbox values), case/whitespace/`-`/`_`-insensitive.
|
|
83
|
+
* Returns `null` for an unrecognized value — the caller fails that closed.
|
|
84
|
+
*/
|
|
85
|
+
function classifyLegacyValue(value: string): Omit<LegacyPermission, 'permissionWarning'> | null {
|
|
86
|
+
const lower = value.trim().toLowerCase();
|
|
87
|
+
// Claude names have no separators of their own, so `accept-edits`/`bypass_permissions` can only
|
|
88
|
+
// ever mean that same name (and tier) — danger stays gated behind allowDangerous downstream.
|
|
89
|
+
const mode = PERMISSION_MODES_BY_LOWER.get(lower.replace(/[-_]/g, ''));
|
|
90
|
+
if (mode) {
|
|
91
|
+
if (mode === 'plan') return { permission: 'readonly', permissionMode: mode };
|
|
92
|
+
if (mode === 'bypassPermissions') return { permission: 'danger', permissionMode: mode };
|
|
93
|
+
return { permission: 'edit', permissionMode: mode };
|
|
94
|
+
}
|
|
95
|
+
const tier = LEGACY_SANDBOX_TIERS[lower.replace(/_/g, '-')];
|
|
96
|
+
return tier ? { permission: tier, permissionMode: TIER_PERMISSION_MODE[tier] } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The legacy keys actually set (non-empty), as `[key, value]` pairs. */
|
|
100
|
+
function legacyEntries(permissionMode: FrontmatterValues, sandbox: FrontmatterValues): [string, string][] {
|
|
101
|
+
return [
|
|
102
|
+
...values(permissionMode).map(v => ['permissionMode', v] as [string, string]),
|
|
103
|
+
...values(sandbox).map(v => ['sandbox', v] as [string, string]),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A permission key's value(s): the keys are read case-insensitively and every occurrence is kept
|
|
109
|
+
* (`sandbox:` + `Sandbox:` is two values), so callers take an array; a single string still works.
|
|
110
|
+
*/
|
|
111
|
+
type FrontmatterValues = string | readonly string[] | undefined;
|
|
112
|
+
|
|
113
|
+
/** Non-empty trimmed values — an empty value means "not set". */
|
|
114
|
+
function values(v: FrontmatterValues): string[] {
|
|
115
|
+
return (typeof v === 'string' ? [v] : (v ?? [])).map(x => x.trim()).filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Legacy `permissionMode:`/`sandbox:` keys → normalized tier. Only consulted when `permission:` is
|
|
120
|
+
* absent. An empty/absent value means "not set" (default `edit`, as always).
|
|
121
|
+
*
|
|
122
|
+
* **Fails closed:** an unrecognized value (a typo, a value from some other harness's vocabulary)
|
|
123
|
+
* yields `readonly` plus `permissionWarning` — never the old silent `edit`, and never a refusal
|
|
124
|
+
* to load. Not loading would let a same-named lower tier (e.g. the builtin `implement`, `edit`)
|
|
125
|
+
* silently win instead, which can be MORE permissive than an author who wrote `sandbox: readonyl`
|
|
126
|
+
* meant; `readonly` is the one tier that can never exceed what any author meant (and it also skips
|
|
127
|
+
* `verify:`). When both keys (or several case variants of one) are set, the least permissive wins.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeLegacyPermission(
|
|
130
|
+
permissionMode: FrontmatterValues,
|
|
131
|
+
sandbox: FrontmatterValues,
|
|
132
|
+
): LegacyPermission {
|
|
133
|
+
const set = legacyEntries(permissionMode, sandbox);
|
|
134
|
+
if (set.length === 0) return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
135
|
+
let best: Omit<LegacyPermission, 'permissionWarning'> | null = null;
|
|
136
|
+
for (const [key, value] of set) {
|
|
137
|
+
const c = classifyLegacyValue(value);
|
|
138
|
+
if (!c) {
|
|
139
|
+
return {
|
|
140
|
+
permission: 'readonly',
|
|
141
|
+
permissionMode: 'plan',
|
|
142
|
+
permissionWarning: `unrecognized ${key}: ${quoteValue(value)} — loaded as readonly (fail closed)`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!best || TIER_RANK[c.permission] < TIER_RANK[best.permission]) best = c;
|
|
146
|
+
}
|
|
147
|
+
return best as LegacyPermission;
|
|
148
|
+
}
|
|
149
|
+
|
|
39
150
|
export function normalizePermission(
|
|
40
|
-
raw:
|
|
41
|
-
fallbackMode:
|
|
42
|
-
|
|
151
|
+
raw: FrontmatterValues,
|
|
152
|
+
fallbackMode: FrontmatterValues,
|
|
153
|
+
sandbox?: FrontmatterValues,
|
|
154
|
+
): {
|
|
155
|
+
permission: NormalizedPermission;
|
|
156
|
+
nativePermission?: string;
|
|
157
|
+
permissionMode: PermissionMode;
|
|
158
|
+
permissionWarning?: string;
|
|
159
|
+
} {
|
|
43
160
|
// Prefer normalized permission
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
161
|
+
const raws = values(raw);
|
|
162
|
+
if (raws.length > 1) {
|
|
163
|
+
// `permission:` given more than once (e.g. `permission:` and `Permission:`) — the same tier is
|
|
164
|
+
// fine; disagreeing tiers take the least permissive, and a disagreement involving a native value
|
|
165
|
+
// (whose tier is per-harness, so not comparable here) fails closed to readonly. Never "last wins".
|
|
166
|
+
const all = raws.map(normalizeTierValue);
|
|
167
|
+
const distinct = new Set(all.map(r => `${r.permission}|${r.nativePermission ?? ''}`));
|
|
168
|
+
if (distinct.size > 1) {
|
|
169
|
+
const listed = raws.map(quoteValue).join(', ');
|
|
170
|
+
if (all.some(r => r.nativePermission))
|
|
171
|
+
return {
|
|
172
|
+
permission: 'readonly',
|
|
173
|
+
permissionMode: 'plan',
|
|
174
|
+
permissionWarning: `conflicting permission: values ${listed} — loaded as readonly (fail closed)`,
|
|
175
|
+
};
|
|
176
|
+
const least = all.reduce((a, b) => (TIER_RANK[b.permission] < TIER_RANK[a.permission] ? b : a));
|
|
177
|
+
return {
|
|
178
|
+
...least,
|
|
179
|
+
permissionWarning: `conflicting permission: values ${listed} — using the least permissive, ${least.permission}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
59
182
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
183
|
+
if (raws.length > 0) {
|
|
184
|
+
const resolved = normalizeTierValue(raws[0]);
|
|
185
|
+
// `permission:` always wins; a legacy key that disagrees is ignored, but said so — an author who
|
|
186
|
+
// wrote `sandbox: read-only` next to `permission: edit` should see which one ran. Skipped for a
|
|
187
|
+
// native value: its tier is only known per harness at run time, so "disagrees" isn't decidable here.
|
|
188
|
+
const legacy = legacyEntries(fallbackMode, sandbox);
|
|
189
|
+
if (resolved.nativePermission || legacy.length === 0) return resolved;
|
|
190
|
+
const l = normalizeLegacyPermission(fallbackMode, sandbox);
|
|
191
|
+
if (!l.permissionWarning && l.permission === resolved.permission) return resolved;
|
|
192
|
+
const ignored = legacy.map(([k, v]) => `${k}: ${quoteValue(v)}`).join(', ');
|
|
193
|
+
return { ...resolved, permissionWarning: `permission: ${resolved.permission} overrides ${ignored} (ignored)` };
|
|
66
194
|
}
|
|
67
|
-
|
|
195
|
+
// Legacy permissionMode/sandbox mapping — fails closed to readonly on an unrecognized value
|
|
196
|
+
return normalizeLegacyPermission(fallbackMode, sandbox);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One `permission:` value → tier, or (unknown value) the native escape hatch. */
|
|
200
|
+
function normalizeTierValue(raw: string): {
|
|
201
|
+
permission: NormalizedPermission;
|
|
202
|
+
nativePermission?: string;
|
|
203
|
+
permissionMode: PermissionMode;
|
|
204
|
+
} {
|
|
205
|
+
const lower = raw.trim().toLowerCase();
|
|
206
|
+
if (lower === 'readonly' || lower === 'read-only' || lower === 'read_only')
|
|
207
|
+
return { permission: 'readonly', permissionMode: 'plan' };
|
|
208
|
+
if (lower === 'edit' || lower === 'acceptedits' || lower === 'accept-edits')
|
|
209
|
+
return { permission: 'edit', permissionMode: 'acceptEdits' };
|
|
210
|
+
if (
|
|
211
|
+
lower === 'danger' ||
|
|
212
|
+
lower === 'bypasspermissions' ||
|
|
213
|
+
lower === 'danger-full-access' ||
|
|
214
|
+
lower === 'danger_full_access'
|
|
215
|
+
)
|
|
216
|
+
return { permission: 'danger', permissionMode: 'bypassPermissions' };
|
|
217
|
+
// Unknown native — treat as native escape hatch
|
|
218
|
+
return { permission: 'edit', nativePermission: raw.trim(), permissionMode: 'acceptEdits' };
|
|
68
219
|
}
|
|
69
220
|
|
|
70
221
|
/** Comma-separated frontmatter list (`a, b`) — undefined when absent or empty. */
|
|
@@ -76,33 +227,50 @@ function parseList(raw: string | undefined): string[] | undefined {
|
|
|
76
227
|
return items.length > 0 ? items : undefined;
|
|
77
228
|
}
|
|
78
229
|
|
|
230
|
+
type PermissionKey = 'permission' | 'permissionMode' | 'sandbox';
|
|
231
|
+
const PERMISSION_KEYS = new Map<string, PermissionKey>([
|
|
232
|
+
['permission', 'permission'],
|
|
233
|
+
['permissionmode', 'permissionMode'],
|
|
234
|
+
['sandbox', 'sandbox'],
|
|
235
|
+
]);
|
|
236
|
+
|
|
79
237
|
/** Parse a template file: frontmatter (---\nkey: value\n---) + markdown body. */
|
|
80
238
|
export function parseTemplate(text: string): DelegateTemplate | null {
|
|
81
239
|
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text.trimStart());
|
|
82
240
|
if (!m) return null;
|
|
83
241
|
|
|
84
242
|
const meta: Record<string, string> = {};
|
|
243
|
+
// The keys that decide the tier are matched case-insensitively and keep every occurrence: an
|
|
244
|
+
// exact-case lookup let `Sandbox: read-only` be silently ignored (→ the `edit` default, verify on).
|
|
245
|
+
const perm: Record<PermissionKey, string[]> = { permission: [], permissionMode: [], sandbox: [] };
|
|
85
246
|
for (const line of m[1].split('\n')) {
|
|
86
247
|
const i = line.indexOf(':');
|
|
87
248
|
if (i <= 0) continue;
|
|
88
|
-
|
|
249
|
+
const key = line.slice(0, i).trim();
|
|
250
|
+
const value = line.slice(i + 1).trim();
|
|
251
|
+
const permKey = PERMISSION_KEYS.get(key.toLowerCase());
|
|
252
|
+
if (permKey) perm[permKey].push(value);
|
|
253
|
+
else meta[key] = value;
|
|
89
254
|
}
|
|
90
255
|
|
|
91
256
|
const name = meta.name?.trim();
|
|
92
257
|
if (!name) return null;
|
|
93
258
|
|
|
94
|
-
const
|
|
95
|
-
const permModeRaw = meta.permissionMode?.trim() ?? meta.sandbox?.trim();
|
|
96
|
-
const norm = normalizePermission(permRaw, permModeRaw);
|
|
259
|
+
const norm = normalizePermission(perm.permission, perm.permissionMode, perm.sandbox);
|
|
97
260
|
|
|
98
261
|
const budget = meta.maxBudgetUsd ? Number(meta.maxBudgetUsd) : NaN;
|
|
262
|
+
const description = meta.description ?? '';
|
|
99
263
|
|
|
100
264
|
return {
|
|
101
265
|
name,
|
|
102
|
-
|
|
266
|
+
// A permission-key problem is surfaced where the template is listed (`/delegate list`).
|
|
267
|
+
description: norm.permissionWarning
|
|
268
|
+
? `⚠ ${norm.permissionWarning}${description ? ` · ${description}` : ''}`
|
|
269
|
+
: description,
|
|
103
270
|
permission: norm.permission,
|
|
104
271
|
nativePermission: norm.nativePermission,
|
|
105
272
|
permissionMode: norm.permissionMode,
|
|
273
|
+
permissionWarning: norm.permissionWarning,
|
|
106
274
|
model: meta.model || undefined,
|
|
107
275
|
maxBudgetUsd: Number.isFinite(budget) && budget > 0 ? budget : undefined,
|
|
108
276
|
skill: meta.skill || undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp, Devin) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test": "bun test",
|
|
43
|
+
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov",
|
|
43
44
|
"lint": "biome check .",
|
|
44
45
|
"lint:fix": "biome check --write .",
|
|
45
46
|
"verify": "bun run lint && bun run typecheck && bun run test",
|