delegation-layer 0.1.28 → 0.1.29

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.
Files changed (35) hide show
  1. package/README.md +58 -14
  2. package/docs/assets/delegation-layer-hero.jpg +0 -0
  3. package/package.json +3 -2
  4. package/plugins/delegation-layer/.claude-plugin/plugin.json +1 -1
  5. package/plugins/delegation-layer/.codex-plugin/plugin.json +1 -1
  6. package/plugins/delegation-layer/skills/agent-integration/SKILL.md +27 -8
  7. package/plugins/delegation-layer/skills/agent-integration/examples/json-results.md +6 -7
  8. package/plugins/delegation-layer/skills/agent-integration/examples/managed-workspace.md +68 -0
  9. package/plugins/delegation-layer/skills/agent-integration/references/collaboration.md +22 -6
  10. package/plugins/delegation-layer/skills/agent-integration/references/command-reference.md +53 -20
  11. package/plugins/delegation-layer/skills/agent-integration/references/continuation.md +36 -0
  12. package/plugins/delegation-layer/skills/agent-integration/references/result-handling.md +46 -2
  13. package/plugins/delegation-layer/skills/agent-integration/references/workspaces.md +197 -0
  14. package/scripts/install-agent-integration.mjs +15 -829
  15. package/scripts/install-cli.mjs +26 -66
  16. package/scripts/installer/application.mjs +250 -0
  17. package/scripts/installer/arguments.mjs +123 -0
  18. package/scripts/installer/catalog.mjs +95 -0
  19. package/scripts/installer/detection.mjs +41 -0
  20. package/scripts/installer/filesystem.mjs +97 -0
  21. package/scripts/installer/paths.mjs +14 -0
  22. package/scripts/installer/picker-state.mjs +46 -0
  23. package/scripts/installer/routing-rules.mjs +99 -0
  24. package/scripts/installer/routing-store.mjs +151 -0
  25. package/scripts/installer/selection.mjs +34 -0
  26. package/scripts/installer/skill-store.mjs +33 -0
  27. package/scripts/installer/terminal.mjs +104 -0
  28. package/skills/agent-integration/SKILL.md +27 -8
  29. package/skills/agent-integration/examples/json-results.md +6 -7
  30. package/skills/agent-integration/examples/managed-workspace.md +68 -0
  31. package/skills/agent-integration/references/collaboration.md +22 -6
  32. package/skills/agent-integration/references/command-reference.md +53 -20
  33. package/skills/agent-integration/references/continuation.md +36 -0
  34. package/skills/agent-integration/references/result-handling.md +46 -2
  35. package/skills/agent-integration/references/workspaces.md +197 -0
@@ -0,0 +1,97 @@
1
+ import { lstat, mkdir, readdir, readFile, copyFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ export async function existingPath(path) {
5
+ try {
6
+ return await lstat(path);
7
+ } catch (error) {
8
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
9
+ return null;
10
+ }
11
+ throw error;
12
+ }
13
+ }
14
+
15
+ export async function assertNoSymlinkComponents(path) {
16
+ let current = resolve(path);
17
+ while (true) {
18
+ const stat = await existingPath(current);
19
+ if (stat?.isSymbolicLink()) {
20
+ throw new Error(`skill destination must not contain symlink components: ${current}`);
21
+ }
22
+ const parent = dirname(current);
23
+ if (parent === current) return;
24
+ current = parent;
25
+ }
26
+ }
27
+
28
+ export async function skillFiles(directory, relative = "") {
29
+ const entries = await readdir(join(directory, relative), { withFileTypes: true });
30
+ const files = [];
31
+ for (const entry of entries) {
32
+ const child = join(relative, entry.name);
33
+ const path = join(directory, child);
34
+ const stat = await lstat(path);
35
+ if (stat.isSymbolicLink()) {
36
+ throw new Error(`skill source or destination must not contain symlinks: ${path}`);
37
+ }
38
+ if (stat.isDirectory()) {
39
+ files.push(...(await skillFiles(directory, child)));
40
+ continue;
41
+ }
42
+ if (!stat.isFile()) throw new Error(`skill entry must be a regular file: ${path}`);
43
+ files.push(child);
44
+ }
45
+ return files.sort();
46
+ }
47
+
48
+ export async function validateSkillSource(sourceDirectory) {
49
+ const files = await skillFiles(sourceDirectory);
50
+ if (!files.includes("SKILL.md")) {
51
+ throw new Error(`skill package is missing SKILL.md: ${sourceDirectory}`);
52
+ }
53
+ return files;
54
+ }
55
+
56
+ export async function skillTreeMatches(sourceDirectory, target, files) {
57
+ for (const relative of files) {
58
+ const destination = join(target, relative);
59
+ await assertNoSymlinkComponents(destination);
60
+ const stat = await existingPath(destination);
61
+ if (!stat?.isFile() || stat.isSymbolicLink()) return false;
62
+ const [source, existing] = await Promise.all([
63
+ readFile(join(sourceDirectory, relative)),
64
+ readFile(destination),
65
+ ]);
66
+ if (!source.equals(existing)) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ export async function skillTreeHasExistingFiles(target, files) {
72
+ for (const relative of files) {
73
+ const destination = join(target, relative);
74
+ await assertNoSymlinkComponents(destination);
75
+ if (await existingPath(destination)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ export async function copySkillTree(sourceDirectory, target, files, force) {
81
+ for (const relative of files) {
82
+ const destination = join(target, relative);
83
+ await assertNoSymlinkComponents(destination);
84
+ await mkdir(dirname(destination), { recursive: true });
85
+ const stat = await existingPath(destination);
86
+ if (stat?.isSymbolicLink()) {
87
+ throw new Error(`existing skill file must not be a symlink: ${destination}`);
88
+ }
89
+ if (stat && !stat.isFile()) {
90
+ throw new Error(`existing skill entry is not a regular file: ${destination}`);
91
+ }
92
+ if (stat && !force) {
93
+ throw new Error(`skill files already exist; use --force to replace them: ${target}`);
94
+ }
95
+ await copyFile(join(sourceDirectory, relative), destination);
96
+ }
97
+ }
@@ -0,0 +1,14 @@
1
+ import { isAbsolute, join } from "node:path";
2
+
3
+ export function normalizeAbsolute(value) {
4
+ if (!isAbsolute(value)) throw new Error("Expected an absolute path");
5
+ return join(value, ".");
6
+ }
7
+
8
+ export function resolveFrom(base, value) {
9
+ if (isAbsolute(value)) return normalizeAbsolute(value);
10
+ if (typeof base !== "string" || !isAbsolute(base)) {
11
+ throw new Error("A relative configuration path requires an absolute working directory");
12
+ }
13
+ return join(base, value, ".");
14
+ }
@@ -0,0 +1,46 @@
1
+ export function createPickerState({ initialValues = [], multiple = false }) {
2
+ return {
3
+ index: 0,
4
+ multiple,
5
+ selectedValues: [...new Set(initialValues)],
6
+ error: undefined,
7
+ };
8
+ }
9
+
10
+ export function transitionPicker(state, action, optionValues) {
11
+ if (action === "cancel") {
12
+ return { state: { ...state, error: undefined }, done: true, value: null };
13
+ }
14
+ if (action === "up" || action === "down") {
15
+ if (optionValues.length === 0) return { state, done: false };
16
+ const offset = action === "up" ? -1 : 1;
17
+ const index = (state.index + optionValues.length + offset) % optionValues.length;
18
+ return { state: { ...state, index, error: undefined }, done: false };
19
+ }
20
+ if (action === "toggle" && state.multiple && optionValues[state.index] !== undefined) {
21
+ const value = optionValues[state.index];
22
+ const selectedValues = state.selectedValues.includes(value)
23
+ ? state.selectedValues.filter((selected) => selected !== value)
24
+ : [...state.selectedValues, value];
25
+ return { state: { ...state, selectedValues, error: undefined }, done: false };
26
+ }
27
+ if (action === "all" && state.multiple) {
28
+ return { state: { ...state, selectedValues: [...new Set(optionValues)], error: undefined }, done: false };
29
+ }
30
+ if (action === "none" && state.multiple) {
31
+ return { state: { ...state, selectedValues: [], error: undefined }, done: false };
32
+ }
33
+ if (action === "enter") {
34
+ if (state.multiple && state.selectedValues.length === 0) {
35
+ return {
36
+ state: { ...state, error: "Select at least one harness, or press q to cancel." },
37
+ done: false,
38
+ };
39
+ }
40
+ const value = state.multiple
41
+ ? optionValues.filter((option) => state.selectedValues.includes(option))
42
+ : optionValues[state.index];
43
+ return { state: { ...state, error: undefined }, done: true, value };
44
+ }
45
+ return { state, done: false };
46
+ }
@@ -0,0 +1,99 @@
1
+ import { join } from "node:path";
2
+ import { resolveFrom } from "./paths.mjs";
3
+
4
+ const ROUTING_EVENTS = ["SessionStart", "SubagentStart"];
5
+
6
+ function isObject(value) {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+
10
+ function shellQuote(value) {
11
+ return "'" + value.replaceAll("'", "'\\''") + "'";
12
+ }
13
+
14
+ function hookCommand(target, event) {
15
+ return `/bin/sh ${shellQuote(join(target, "scripts", "delegate-routing-context.sh"))} ${event}`;
16
+ }
17
+
18
+ export function routingPath(name, { scope, cwd, home, claudeConfigDir, codexHome }) {
19
+ if (name === "claude") {
20
+ return scope === "global"
21
+ ? join(resolveFrom(cwd, claudeConfigDir || join(home, ".claude")), "settings.json")
22
+ : join(cwd, ".claude", "settings.local.json");
23
+ }
24
+ if (name === "codex") {
25
+ return join(scope === "global" ? resolveFrom(cwd, codexHome || join(home, ".codex")) : join(cwd, ".codex"), "hooks.json");
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function validateHookConfig(config, path) {
31
+ if (!isObject(config) || (config.hooks !== undefined && !isObject(config.hooks))) {
32
+ throw new Error(`expected a JSON object with an optional hooks object: ${path}`);
33
+ }
34
+ for (const groups of Object.values(config.hooks ?? {})) {
35
+ if (!Array.isArray(groups) || groups.some((group) => !isObject(group)
36
+ || !Array.isArray(group.hooks) || group.hooks.some((hook) => !isObject(hook) || typeof hook.type !== "string"))) {
37
+ throw new Error(`invalid hook configuration; no routing changes written: ${path}`);
38
+ }
39
+ }
40
+ }
41
+
42
+ export function countRoutingEvents(config, target) {
43
+ const hooks = config.hooks ?? {};
44
+ return ROUTING_EVENTS.filter((event) => {
45
+ const expected = hookCommand(target, event);
46
+ return (hooks[event] ?? []).some((group) => group.hooks.some(
47
+ (hook) => hook.type === "command" && hook.command === expected,
48
+ ));
49
+ }).length;
50
+ }
51
+
52
+ export function createRoutingPlan({ name, path, target, before, config, mode, action, assetsPresent = true }) {
53
+ validateHookConfig(config, path);
54
+ const nextConfig = { ...config };
55
+ const hooks = { ...(config.hooks ?? {}) };
56
+ let changed = false;
57
+ let present = 0;
58
+
59
+ for (const event of ROUTING_EVENTS) {
60
+ const command = hookCommand(target, event);
61
+ const groups = hooks[event] ?? [];
62
+ const matches = groups.flatMap((group) => group.hooks)
63
+ .filter((hook) => hook.type === "command" && hook.command === command);
64
+ if (matches.length) present += 1;
65
+ if (action === "disable") {
66
+ if (!matches.length) continue;
67
+ hooks[event] = groups.flatMap((group) => {
68
+ const remaining = group.hooks.filter((hook) => hook.type !== "command" || hook.command !== command);
69
+ if (remaining.length === group.hooks.length) return [group];
70
+ return remaining.length ? [{ ...group, hooks: remaining }] : [];
71
+ });
72
+ if (!hooks[event].length) delete hooks[event];
73
+ changed = true;
74
+ } else if (action === "enable" && !matches.length) {
75
+ hooks[event] = [...groups, { hooks: [{ type: "command", command }] }];
76
+ changed = true;
77
+ }
78
+ }
79
+
80
+ if (!["enable", "disable", "status"].includes(action)) {
81
+ throw new Error(`unsupported routing action: ${action}`);
82
+ }
83
+ if (changed) nextConfig.hooks = hooks;
84
+ return {
85
+ name,
86
+ path,
87
+ target,
88
+ before,
89
+ after: changed ? `${JSON.stringify(nextConfig, null, 2)}\n` : before,
90
+ mode,
91
+ status: action === "disable"
92
+ ? "disabled"
93
+ : action === "enable" || (present === 2 && assetsPresent)
94
+ ? "configured"
95
+ : present
96
+ ? "incomplete"
97
+ : "not-configured",
98
+ };
99
+ }
@@ -0,0 +1,151 @@
1
+ import { open, mkdir, readFile, rename, unlink } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getHarness } from "./catalog.mjs";
4
+ import { assertNoSymlinkComponents, existingPath } from "./filesystem.mjs";
5
+ import { countRoutingEvents, createRoutingPlan, routingPath, validateHookConfig } from "./routing-rules.mjs";
6
+ import { targetFor } from "./selection.mjs";
7
+
8
+ export async function prepareRouting(selected, action, { environment, platform, sourceDirectory }) {
9
+ const plans = [];
10
+ for (const name of new Set(selected.harnessNames)) {
11
+ const path = routingPath(name, {
12
+ scope: selected.scope,
13
+ cwd: selected.cwd,
14
+ home: selected.home,
15
+ claudeConfigDir: environment.CLAUDE_CONFIG_DIR,
16
+ codexHome: environment.CODEX_HOME,
17
+ });
18
+ if (!path || platform === "win32") {
19
+ plans.push({ name, status: "unsupported-host" });
20
+ continue;
21
+ }
22
+
23
+ await assertNoSymlinkComponents(path);
24
+ const stat = await existingPath(path);
25
+ if (stat && !stat.isFile()) throw new Error(`routing configuration must be a regular file: ${path}`);
26
+ const before = stat ? await readFile(path, "utf8") : null;
27
+ let config;
28
+ try {
29
+ config = before === null ? {} : JSON.parse(before);
30
+ } catch {
31
+ throw new Error(`invalid JSON; routing configuration left unchanged: ${path}`);
32
+ }
33
+ validateHookConfig(config, path);
34
+ const harness = getHarness(name);
35
+ if (!harness) throw new Error(`cannot prepare routing for unknown harness: ${name}`);
36
+ const target = targetFor(harness, selected.scope, selected.cwd, selected.home, environment);
37
+ let assetsPresent = true;
38
+ if (action === "status" && countRoutingEvents(config, target) > 0) {
39
+ for (const relative of ["SKILL.md", "scripts/delegate-routing-context.sh", "references/routing-rule.json"]) {
40
+ const asset = await existingPath(join(target, relative));
41
+ if (!asset?.isFile() || asset.isSymbolicLink()) assetsPresent = false;
42
+ }
43
+ }
44
+ plans.push(createRoutingPlan({
45
+ name,
46
+ path,
47
+ target,
48
+ before,
49
+ config,
50
+ mode: stat ? stat.mode & 0o777 : 0o600,
51
+ action,
52
+ assetsPresent,
53
+ }));
54
+ }
55
+ return plans;
56
+ }
57
+
58
+ async function replaceRoutingConfig(plan, before, after) {
59
+ await assertNoSymlinkComponents(plan.path);
60
+ await mkdir(dirname(plan.path), { recursive: true });
61
+ const lockPath = plan.path + ".delegation-layer.lock";
62
+ let lock;
63
+ try {
64
+ lock = await open(lockPath, "wx", 0o600);
65
+ } catch (error) {
66
+ if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
67
+ throw new Error(`routing configuration is locked: ${lockPath}; verify no other installer is running before removing a stale lock`);
68
+ }
69
+ throw error;
70
+ }
71
+ const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
72
+ const temporary = plan.path + `.delegation-layer-stage-${suffix}`;
73
+ try {
74
+ const stat = await existingPath(plan.path);
75
+ if (stat && (!stat.isFile() || stat.isSymbolicLink())) {
76
+ throw new Error(`routing configuration changed during installation: ${plan.path}`);
77
+ }
78
+ const current = stat ? await readFile(plan.path, "utf8") : null;
79
+ if (current !== before) throw new Error(`routing configuration changed during installation; retry: ${plan.path}`);
80
+ if (before !== null) {
81
+ const backup = await open(plan.path + `.delegation-layer-backup-${suffix}`, "wx", 0o600);
82
+ try {
83
+ await backup.writeFile(before);
84
+ await backup.sync();
85
+ } finally {
86
+ await backup.close();
87
+ }
88
+ }
89
+ if (after === null) {
90
+ await unlink(plan.path);
91
+ return;
92
+ }
93
+ const staged = await open(temporary, "wx", plan.mode);
94
+ try {
95
+ await staged.writeFile(after);
96
+ await staged.sync();
97
+ } finally {
98
+ await staged.close();
99
+ }
100
+ await rename(temporary, plan.path);
101
+ } finally {
102
+ try {
103
+ try {
104
+ await unlink(temporary);
105
+ } catch (error) {
106
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
107
+ }
108
+ } finally {
109
+ try {
110
+ await lock.close();
111
+ } finally {
112
+ await unlink(lockPath);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ export async function applyRouting(plans) {
119
+ const applied = [];
120
+ try {
121
+ for (const plan of plans) {
122
+ if (!plan.path || plan.before === plan.after) continue;
123
+ await replaceRoutingConfig(plan, plan.before, plan.after);
124
+ applied.push(plan);
125
+ }
126
+ } catch (error) {
127
+ const failures = [];
128
+ for (const plan of applied.reverse()) {
129
+ try {
130
+ await replaceRoutingConfig(plan, plan.after, plan.before);
131
+ } catch (rollbackError) {
132
+ failures.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
133
+ }
134
+ }
135
+ const reason = error instanceof Error ? error.message : String(error);
136
+ throw new Error(`${reason}${failures.length ? `; rollback needs attention: ${failures.join("; ")}` : "; earlier routing changes rolled back"}`);
137
+ }
138
+ }
139
+
140
+ export function printRouting(plan, action, report) {
141
+ if (!plan.path) {
142
+ report(`${plan.name}: unsupported-host for automatic routing; skill-only guidance remains available.`);
143
+ return;
144
+ }
145
+ report(`${plan.name}: ${plan.status} (${plan.path}).`);
146
+ if (plan.status === "configured") {
147
+ report("Activation not verified: start or resume a session and check the native hook output. Installation is not proof of model compliance.");
148
+ if (plan.name === "codex") report("Codex: review and trust the hooks using /hooks. New or changed definitions are skipped until trusted.");
149
+ }
150
+ if (action === "disable") report("Only this installer's matching hooks were removed; plugin hooks are managed by the host plugin manager.");
151
+ }
@@ -0,0 +1,34 @@
1
+ import { join } from "node:path";
2
+ import { resolveFrom } from "./paths.mjs";
3
+
4
+ export function defaultHarnessNames(detections) {
5
+ const detected = detections
6
+ .filter(({ harness, reasons }) => harness.name !== "universal" && reasons.length > 0)
7
+ .map(({ harness }) => harness.name);
8
+ return detected.length > 0 ? detected : ["universal"];
9
+ }
10
+
11
+ export function formatDetected(reasons) {
12
+ return reasons.length > 0 ? ` (detected: ${reasons.join(", ")})` : "";
13
+ }
14
+
15
+ export function resolveInstallSelection({ force, harnessNames, scope, routing, cwd, home, detections }) {
16
+ return {
17
+ routing: routing ?? "skill-only",
18
+ cwd,
19
+ force,
20
+ harnessNames: harnessNames ?? defaultHarnessNames(detections),
21
+ home,
22
+ scope: scope ?? "global",
23
+ };
24
+ }
25
+
26
+ export function targetFor(harness, scope, cwd, home, environment = {}) {
27
+ if (!harness) throw new Error("cannot resolve an install target for an unknown harness");
28
+ if (scope === "global" && harness.name === "claude" && environment.CLAUDE_CONFIG_DIR) {
29
+ return join(resolveFrom(cwd, environment.CLAUDE_CONFIG_DIR), "skills", "agent-integration");
30
+ }
31
+ const root = scope === "global" ? home : cwd;
32
+ const relativePath = scope === "global" ? harness.globalPath : harness.projectPath;
33
+ return join(root, ...relativePath, "agent-integration");
34
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ assertNoSymlinkComponents,
5
+ copySkillTree,
6
+ existingPath,
7
+ skillFiles,
8
+ skillTreeHasExistingFiles,
9
+ skillTreeMatches,
10
+ } from "./filesystem.mjs";
11
+
12
+ export async function installSkill({ sourceDirectory, target, force, report }) {
13
+ await assertNoSymlinkComponents(target);
14
+ const targetStat = await existingPath(target);
15
+ if (targetStat?.isSymbolicLink()) {
16
+ throw new Error(`target directory must not be a symlink: ${target}`);
17
+ }
18
+ if (targetStat && !targetStat.isDirectory()) {
19
+ throw new Error(`target must be a directory: ${target}`);
20
+ }
21
+
22
+ const files = await skillFiles(sourceDirectory);
23
+ await mkdir(target, { recursive: true });
24
+ if (!force && (await skillTreeMatches(sourceDirectory, target, files))) {
25
+ report(`Skill already installed: ${join(target, "SKILL.md")}`);
26
+ return;
27
+ }
28
+ if (!force && (await skillTreeHasExistingFiles(target, files))) {
29
+ throw new Error(`skill files already exist; use --force to replace them: ${target}`);
30
+ }
31
+ await copySkillTree(sourceDirectory, target, files, force);
32
+ report(`Installed agent integration skill: ${join(target, "SKILL.md")}`);
33
+ }
@@ -0,0 +1,104 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+ import { createPickerState, transitionPicker } from "./picker-state.mjs";
3
+
4
+ function renderPicker({ error, index, multiple, options, selectedValues, title }, output) {
5
+ const selected = new Set(selectedValues);
6
+ const lines = [title, ""];
7
+ options.forEach((option, optionIndex) => {
8
+ const current = optionIndex === index;
9
+ const marker = multiple ? (selected.has(option.value) ? "[x]" : "[ ]") : "";
10
+ const cursor = current ? "❯" : " ";
11
+ const color = current ? "\u001b[36m" : "";
12
+ const reset = current ? "\u001b[0m" : "";
13
+ lines.push(`${color}${cursor} ${marker ? `${marker} ` : ""}${option.label}${reset}`);
14
+ if (option.description) lines.push(` ${option.description}`);
15
+ });
16
+ lines.push("");
17
+ lines.push(multiple
18
+ ? "↑↓ move Space select Enter continue a all n none q cancel"
19
+ : "↑↓ move Enter select q cancel");
20
+ if (error) lines.push(`\u001b[31m${error}\u001b[0m`);
21
+ output.write(`\u001b[2J\u001b[H${lines.join("\n")}\n`);
22
+ }
23
+
24
+ export function pickOptions({ initialValues = [], multiple = false, options, title, input, output }) {
25
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
26
+
27
+ return new Promise((resolve, reject) => {
28
+ let state = createPickerState({ initialValues, multiple });
29
+ let settled = false;
30
+
31
+ const cleanup = () => {
32
+ const failures = [];
33
+ try { input.off("keypress", onKeypress); } catch (error) { failures.push(error); }
34
+ try { input.setRawMode(false); } catch (error) { failures.push(error); }
35
+ try { input.pause(); } catch (error) { failures.push(error); }
36
+ try { output.write("\u001b[?25h\u001b[0m"); } catch (error) { failures.push(error); }
37
+ if (failures.length) {
38
+ const details = failures.map((failure) => failure instanceof Error ? failure.message : String(failure));
39
+ throw new Error(`terminal cleanup failed: ${details.join("; ")}`);
40
+ }
41
+ };
42
+
43
+ const finish = (value) => {
44
+ if (settled) return;
45
+ settled = true;
46
+ try {
47
+ cleanup();
48
+ resolve(value);
49
+ } catch (error) {
50
+ reject(error);
51
+ }
52
+ };
53
+
54
+ const fail = (error) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ try {
58
+ cleanup();
59
+ } catch (cleanupError) {
60
+ const reason = error instanceof Error ? error.message : String(error);
61
+ const cleanupReason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
62
+ reject(new Error(`${reason}; ${cleanupReason}`));
63
+ return;
64
+ }
65
+ reject(error);
66
+ };
67
+
68
+ const draw = () => renderPicker({ ...state, options, title }, output);
69
+ const onKeypress = (character, key = {}) => {
70
+ try {
71
+ let action;
72
+ if ((key.ctrl && key.name === "c") || key.name === "escape" || key.name === "q") action = "cancel";
73
+ else if (key.name === "up" || key.name === "k") action = "up";
74
+ else if (key.name === "down" || key.name === "j") action = "down";
75
+ else if (multiple && (key.name === "space" || character === " ")) action = "toggle";
76
+ else if (multiple && key.name === "a") action = "all";
77
+ else if (multiple && key.name === "n") action = "none";
78
+ else if (key.name === "return" || key.name === "enter") action = "enter";
79
+ else return;
80
+
81
+ const result = transitionPicker(state, action, options.map((option) => option.value));
82
+ state = result.state;
83
+ if (result.done) {
84
+ finish(result.value);
85
+ } else {
86
+ draw();
87
+ }
88
+ } catch (error) {
89
+ fail(error);
90
+ }
91
+ };
92
+
93
+ try {
94
+ emitKeypressEvents(input);
95
+ input.setRawMode(true);
96
+ input.resume();
97
+ input.on("keypress", onKeypress);
98
+ output.write("\u001b[?25l");
99
+ draw();
100
+ } catch (error) {
101
+ fail(error);
102
+ }
103
+ });
104
+ }
@@ -34,7 +34,7 @@ Do not inspect, authenticate, install, or repair provider CLIs to bypass a
34
34
  Delegate blocker.
35
35
 
36
36
  A reported blocker is a valid final response to a delegation request. It does
37
- not turn that request into an environment-repair task. The one lifecycle
37
+ not turn that request into an environment-repair task. The supervisor recovery
38
38
  exception is `failure.code: "supervisor_upgrade_required"` with
39
39
  `next_action: "run_supervisor_migrate"`: run Delegate's own
40
40
  `supervisor migrate` command once with the exact same state root, then retry the
@@ -47,6 +47,9 @@ not Pueue administration. Never run `pueue`, `pueued`, socket inspection,
47
47
  process searches, root deletion, or a provider CLI during recovery. Executable
48
48
  path or hash drift alone is handled by normal adoption and does not require
49
49
  this command.
50
+ Managed admission also permits the two explicit `watch_workspace` responses
51
+ in the task-record table below. They keep the exact admission command and
52
+ inputs, including `continue`; they do not authorize environment repair.
50
53
  When a response says `failure.next_action: "stop_and_report"`, that attempt is
51
54
  finished. Include its failure in your final response without opening a
52
55
  clarification dialog to choose a fallback. For `task_record: not_created`,
@@ -72,7 +75,9 @@ tasks may proceed; the failed attempt must remain reported as failed or blocked.
72
75
  3. Give every turn an absolute workspace, an explicit permission mode, and a
73
76
  finite budget. Keep the state root outside the workspace. Reuse a supplied
74
77
  `DELEGATE_ROOT` for all providers and tasks; do not invent per-provider or
75
- per-task state roots. Task IDs separate work within that root.
78
+ per-task state roots. Task IDs separate work within that root. Managed Git
79
+ workspaces are optional; read [managed workspace guidance](references/workspaces.md)
80
+ before creating or attaching one.
76
81
  4. Use `--json` and save each response separately with its requested destination,
77
82
  model, effort, workspace, and returned `task_id`. Never merge concurrent
78
83
  responses into an unlabeled output stream. Read `admission`, `liveness`,
@@ -119,8 +124,10 @@ tasks may proceed; the failed attempt must remain reported as failed or blocked.
119
124
  11. The model chooses ordinary-task fan-out by issuing independent `dispatch`
120
125
  commands concurrently. The private supervisor does not impose a task-count
121
126
  or CPU-based limit. Use this for independent investigation, debate, review,
122
- or provider comparison. Concurrent write tasks must use separate workspaces
123
- or explicit coordination to avoid file conflicts.
127
+ or provider comparison. For independent parallel writes, choose
128
+ `--isolation git-worktree` automatically unless the user explicitly chooses
129
+ shared coordination; the model chooses fan-out and task scope. A read-only
130
+ review may attach a retained workspace with `--workspace ID`.
124
131
 
125
132
  For an explicit Codex code review, use the discoverable `codex:review` profile
126
133
  with one persisted target: `--review-target uncommitted`,
@@ -131,6 +138,13 @@ provider to edit files; the review profile is for Codex's native review
131
138
  operation and keeps the reviewed Git state explicit. The brief is retained as
132
139
  task evidence; the review target supplies Codex's review scope because its
133
140
  native command does not accept a prompt with the target flag.
141
+ For a complete managed-workspace turn, review the recorded initial base against
142
+ its captured tree through an ordinary read-only task. Include both sealed refs
143
+ and an explicit `git diff BASE TREE` scope. A native `uncommitted` review covers
144
+ current staged, unstaged, and untracked changes; it excludes worker commits.
145
+ Do not pass a tree as a native branch target. Preserve an explicitly requested
146
+ native review target and state its narrower scope. See [workspace review
147
+ guidance](references/workspaces.md#recovery-and-review-scope).
134
148
 
135
149
  ## Operating procedure
136
150
 
@@ -152,7 +166,7 @@ If no destination was named, use automatic selection directly. If the user
152
166
  named a harness (for example, “Codex”), resolve its exact provider ID from:
153
167
 
154
168
  ```sh
155
- delegate providers --json
169
+ delegate --root "${DELEGATE_ROOT:-$HOME/delegation-state}" providers --json
156
170
  ```
157
171
 
158
172
  Use the matching catalog ID with `--provider`; do not silently substitute
@@ -287,6 +301,7 @@ for **each attempt**, including requests issued together:
287
301
  | Usage error, no JSON response | Correct the reported syntax using Delegate help or `providers --json`, then invoke that request again. Parsing failed before admission. |
288
302
  | `task_record: not_created`, `failure.code: supervisor_upgrade_required`, and `failure.next_action: run_supervisor_migrate` | Run `delegate --root "<the exact absolute state root used by the failed command>" supervisor migrate --json` once. Retry the exact original admission argv once only after `supervisor_control.state: migrated` and `supervisor_control.next_action: retry_dispatch`; otherwise stop and report the migration response. Preserve an explicit custom root exactly. A failed `continue` must be retried as the same `continue`; never replace it with a fresh `dispatch` or a direct provider call. |
289
303
  | `task_record: not_created` and `failure.next_action: correct_request` | Correct that request and retry it. |
304
+ | `task_record: not_created`, `failure.code: workspace_provision_pending` or `workspace_busy`, and `failure.next_action: watch_workspace` | Follow [managed workspace recovery](references/result-handling.md#managed-workspace-results) using the same root and workspace. Once ready, retry the exact admission command and inputs. A failed `continue` remains that same `continue`, never a new dispatch. |
290
305
  | `task_record: not_created` with any other next action | Report that attempt's failure and stop it. Do not diagnose provider setup or substitute an integration. |
291
306
  | `task_record: created` | Use `status` before taking any other action. Do not dispatch again. |
292
307
  | `task_record: unknown` or an ambiguous response | Stop and report; do not retry an uncertain task creation. |
@@ -352,10 +367,14 @@ recruit more agents unless that is explicitly part of the task.
352
367
  admission, publication, authentication, errors, or exit codes.
353
368
  - Read [continuation](references/continuation.md) for timeout recovery or an
354
369
  exact-session follow-up.
370
+ - Read [managed workspaces](references/workspaces.md) for optional Git
371
+ worktrees, capture, review scope, release, and forgetting.
355
372
  - Use [read-only example](examples/read-only-task.md),
356
- [write example](examples/workspace-write-task.md), and
357
- [JSON examples](examples/json-results.md) when constructing a task or parsing
358
- unfamiliar output. Do not load every reference for a routine dispatch.
373
+ [write example](examples/workspace-write-task.md),
374
+ [JSON examples](examples/json-results.md), and
375
+ [managed workspace example](examples/managed-workspace.md) when constructing
376
+ a task or parsing unfamiliar output. Do not load every reference for a
377
+ routine dispatch.
359
378
 
360
379
  ## Completion report
361
380