delegation-layer 0.1.27 → 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.
- package/README.md +58 -14
- package/docs/assets/delegation-layer-hero.jpg +0 -0
- package/package.json +3 -2
- package/plugins/delegation-layer/.claude-plugin/plugin.json +1 -1
- package/plugins/delegation-layer/.codex-plugin/plugin.json +1 -1
- package/plugins/delegation-layer/skills/agent-integration/SKILL.md +27 -8
- package/plugins/delegation-layer/skills/agent-integration/examples/json-results.md +6 -7
- package/plugins/delegation-layer/skills/agent-integration/examples/managed-workspace.md +68 -0
- package/plugins/delegation-layer/skills/agent-integration/references/collaboration.md +22 -6
- package/plugins/delegation-layer/skills/agent-integration/references/command-reference.md +56 -18
- package/plugins/delegation-layer/skills/agent-integration/references/continuation.md +36 -0
- package/plugins/delegation-layer/skills/agent-integration/references/result-handling.md +46 -2
- package/plugins/delegation-layer/skills/agent-integration/references/workspaces.md +197 -0
- package/scripts/install-agent-integration.mjs +15 -829
- package/scripts/install-cli.mjs +26 -66
- package/scripts/installer/application.mjs +250 -0
- package/scripts/installer/arguments.mjs +123 -0
- package/scripts/installer/catalog.mjs +95 -0
- package/scripts/installer/detection.mjs +41 -0
- package/scripts/installer/filesystem.mjs +97 -0
- package/scripts/installer/paths.mjs +14 -0
- package/scripts/installer/picker-state.mjs +46 -0
- package/scripts/installer/routing-rules.mjs +99 -0
- package/scripts/installer/routing-store.mjs +151 -0
- package/scripts/installer/selection.mjs +34 -0
- package/scripts/installer/skill-store.mjs +33 -0
- package/scripts/installer/terminal.mjs +104 -0
- package/skills/agent-integration/SKILL.md +27 -8
- package/skills/agent-integration/examples/json-results.md +6 -7
- package/skills/agent-integration/examples/managed-workspace.md +68 -0
- package/skills/agent-integration/references/collaboration.md +22 -6
- package/skills/agent-integration/references/command-reference.md +56 -18
- package/skills/agent-integration/references/continuation.md +36 -0
- package/skills/agent-integration/references/result-handling.md +46 -2
- 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
|
|
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.
|
|
123
|
-
|
|
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),
|
|
357
|
-
[JSON examples](examples/json-results.md)
|
|
358
|
-
|
|
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
|
|