avenic 1.0.0
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/LICENSE +21 -0
- package/README.md +234 -0
- package/package.json +30 -0
- package/scripts/skills.mjs +13 -0
- package/scripts/watchdog.mjs +50 -0
- package/src/cli/dispatcher.mjs +439 -0
- package/src/cli/self-update.mjs +27 -0
- package/src/cli/skills-cli.mjs +1060 -0
- package/src/cli/watchdog.mjs +30 -0
- package/vendor/core-src/index.mjs +143 -0
- package/vendor/core-src/runtime/adapters/claude.mjs +81 -0
- package/vendor/core-src/runtime/adapters/codex.mjs +142 -0
- package/vendor/core-src/runtime/adapters/index.mjs +9 -0
- package/vendor/core-src/runtime/adapters/opencode.mjs +76 -0
- package/vendor/core-src/runtime/agents.mjs +39 -0
- package/vendor/core-src/runtime/config.mjs +227 -0
- package/vendor/core-src/runtime/gitignore.mjs +69 -0
- package/vendor/core-src/runtime/process.mjs +41 -0
- package/vendor/core-src/runtime/project-root.mjs +42 -0
- package/vendor/core-src/runtime/sessions.mjs +312 -0
- package/vendor/core-src/skills/catalog.mjs +130 -0
- package/vendor/core-src/skills/direct.mjs +209 -0
- package/vendor/core-src/skills/git.mjs +93 -0
- package/vendor/core-src/skills/ids.mjs +40 -0
- package/vendor/core-src/skills/install.mjs +357 -0
- package/vendor/core-src/skills/packs.mjs +256 -0
- package/vendor/core-src/skills/paths.mjs +92 -0
- package/vendor/core-src/skills/sources.mjs +246 -0
- package/vendor/core-src/skills/ui.mjs +21 -0
- package/vendor/core-src/skills/vendor.mjs +57 -0
- package/vendor/core-src/util/fail.mjs +3 -0
- package/vendor/core-src/util/fs.mjs +14 -0
- package/vendor/core-src/util/json.mjs +14 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getAgent } from "./agents.mjs";
|
|
5
|
+
import { ensureRuntimeGitignore, removeRuntimeGitignore } from "./gitignore.mjs";
|
|
6
|
+
|
|
7
|
+
const AUTH_MODES = new Set(["global", "project"]);
|
|
8
|
+
const SESSIONS_MODES = new Set(["global", "project"]);
|
|
9
|
+
|
|
10
|
+
async function readJsonIfExists(file, fallback) {
|
|
11
|
+
if (!existsSync(file)) {
|
|
12
|
+
return fallback;
|
|
13
|
+
}
|
|
14
|
+
return JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function writeJsonIfChanged(file, value) {
|
|
18
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
19
|
+
if (existsSync(file) && (await readFile(file, "utf8")) === content) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
23
|
+
await writeFile(file, content, "utf8");
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateAuthMode(authMode) {
|
|
28
|
+
if (!AUTH_MODES.has(authMode)) {
|
|
29
|
+
throw new Error(`Authentication must be global or project: ${authMode}`);
|
|
30
|
+
}
|
|
31
|
+
return authMode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateSessionsMode(sessionsMode) {
|
|
35
|
+
if (!SESSIONS_MODES.has(sessionsMode)) {
|
|
36
|
+
throw new Error(`Sessions must be global or project: ${sessionsMode}`);
|
|
37
|
+
}
|
|
38
|
+
return sessionsMode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function runtimePaths(projectRoot) {
|
|
42
|
+
const agentsRoot = path.join(projectRoot, ".agents");
|
|
43
|
+
const localRoot = path.join(agentsRoot, "local");
|
|
44
|
+
return {
|
|
45
|
+
localRoot,
|
|
46
|
+
runtimeFile: path.join(agentsRoot, "runtime.json"),
|
|
47
|
+
localRuntimeFile: path.join(localRoot, "runtime.local.json"),
|
|
48
|
+
sessionsRoot: path.join(agentsRoot, "sessions"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadRuntime(projectRoot) {
|
|
53
|
+
const paths = runtimePaths(projectRoot);
|
|
54
|
+
const runtime = await readJsonIfExists(paths.runtimeFile, { schemaVersion: 1, agents: {} });
|
|
55
|
+
const local = await readJsonIfExists(paths.localRuntimeFile, { schemaVersion: 1, agents: {} });
|
|
56
|
+
return { paths, runtime, local };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function initializeAgent(projectRoot, agentId, authMode, sessionsMode) {
|
|
60
|
+
getAgent(agentId);
|
|
61
|
+
if (authMode) {
|
|
62
|
+
validateAuthMode(authMode);
|
|
63
|
+
}
|
|
64
|
+
if (sessionsMode) {
|
|
65
|
+
validateSessionsMode(sessionsMode);
|
|
66
|
+
}
|
|
67
|
+
const state = await loadRuntime(projectRoot);
|
|
68
|
+
state.runtime.schemaVersion ??= 1;
|
|
69
|
+
state.runtime.agents ??= {};
|
|
70
|
+
const previous = state.runtime.agents[agentId] ?? {};
|
|
71
|
+
const effectiveAuthMode = authMode ?? previous.auth ?? "global";
|
|
72
|
+
const effectiveSessionsMode = sessionsMode ?? previous.sessions ?? "project";
|
|
73
|
+
state.runtime.agents[agentId] = {
|
|
74
|
+
...previous,
|
|
75
|
+
enabled: true,
|
|
76
|
+
auth: effectiveAuthMode,
|
|
77
|
+
sessions: effectiveSessionsMode,
|
|
78
|
+
};
|
|
79
|
+
const sessionDirectory = path.join(state.paths.sessionsRoot, agentId);
|
|
80
|
+
const localDirectory = path.join(state.paths.localRoot, agentId);
|
|
81
|
+
const missingStructure = [sessionDirectory];
|
|
82
|
+
if (effectiveAuthMode === "project") {
|
|
83
|
+
missingStructure.push(localDirectory);
|
|
84
|
+
}
|
|
85
|
+
const structureRepaired = missingStructure.some((directory) => !existsSync(directory));
|
|
86
|
+
for (const directory of missingStructure) {
|
|
87
|
+
await mkdir(directory, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
const configChanged = await writeJsonIfChanged(state.paths.runtimeFile, state.runtime);
|
|
90
|
+
const gitignoreChanged = await ensureRuntimeGitignore(projectRoot);
|
|
91
|
+
return {
|
|
92
|
+
...state,
|
|
93
|
+
authMode: effectiveAuthMode,
|
|
94
|
+
sessionsMode: effectiveSessionsMode,
|
|
95
|
+
configChanged,
|
|
96
|
+
gitignoreChanged,
|
|
97
|
+
structureRepaired,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Environment overrides that scope an agent's credentials and configuration to
|
|
102
|
+
// the project (stored under .agents/local/, which is always gitignored).
|
|
103
|
+
export function projectAuthEnvironment(agentId, projectRoot) {
|
|
104
|
+
getAgent(agentId);
|
|
105
|
+
const localRoot = runtimePaths(projectRoot).localRoot;
|
|
106
|
+
switch (agentId) {
|
|
107
|
+
case "claude":
|
|
108
|
+
return { CLAUDE_CONFIG_DIR: path.join(localRoot, "claude") };
|
|
109
|
+
case "codex":
|
|
110
|
+
return { CODEX_HOME: path.join(localRoot, "codex") };
|
|
111
|
+
case "opencode":
|
|
112
|
+
return { XDG_CONFIG_HOME: path.join(localRoot, "opencode") };
|
|
113
|
+
default:
|
|
114
|
+
throw new Error(`Unknown Agent: ${agentId}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function deinitializeAgent(projectRoot, agentId, options = {}) {
|
|
119
|
+
const agent = getAgent(agentId);
|
|
120
|
+
const state = await loadRuntime(projectRoot);
|
|
121
|
+
if (!state.runtime.agents?.[agentId]) {
|
|
122
|
+
const sessionDirectory = path.join(state.paths.sessionsRoot, agentId);
|
|
123
|
+
const localDirectory = path.join(state.paths.localRoot, agentId);
|
|
124
|
+
const purged = Boolean(options.purge && (existsSync(sessionDirectory) || existsSync(localDirectory)));
|
|
125
|
+
if (options.purge) {
|
|
126
|
+
await rm(sessionDirectory, { recursive: true, force: true });
|
|
127
|
+
await rm(localDirectory, { recursive: true, force: true });
|
|
128
|
+
if (Object.keys(state.runtime.agents ?? {}).length === 0) {
|
|
129
|
+
await removeRuntimeGitignore(projectRoot, { sessions: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
agent,
|
|
134
|
+
changed: purged,
|
|
135
|
+
purged,
|
|
136
|
+
remaining: Object.keys(state.runtime.agents ?? {}).length,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
delete state.runtime.agents[agentId];
|
|
141
|
+
if (Object.keys(state.runtime.agents).length === 0) {
|
|
142
|
+
await rm(state.paths.runtimeFile, { force: true });
|
|
143
|
+
} else {
|
|
144
|
+
await writeJsonIfChanged(state.paths.runtimeFile, state.runtime);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (state.local.agents?.[agentId]) {
|
|
148
|
+
delete state.local.agents[agentId];
|
|
149
|
+
if (Object.keys(state.local.agents).length === 0) {
|
|
150
|
+
await rm(state.paths.localRuntimeFile, { force: true });
|
|
151
|
+
} else {
|
|
152
|
+
await writeJsonIfChanged(state.paths.localRuntimeFile, state.local);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (options.purge) {
|
|
156
|
+
await rm(path.join(state.paths.localRoot, agentId), { recursive: true, force: true });
|
|
157
|
+
await rm(path.join(state.paths.sessionsRoot, agentId), { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
const remaining = Object.keys(state.runtime.agents).length;
|
|
160
|
+
if (remaining === 0) {
|
|
161
|
+
if (options.purge) {
|
|
162
|
+
await rm(state.paths.localRoot, { recursive: true, force: true });
|
|
163
|
+
await rm(path.join(projectRoot, ".agents", "tmp"), { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
await removeRuntimeGitignore(projectRoot, { sessions: Boolean(options.purge) });
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
agent,
|
|
169
|
+
changed: true,
|
|
170
|
+
purged: Boolean(options.purge),
|
|
171
|
+
remaining,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function setLocalAuth(projectRoot, agentId, authMode) {
|
|
176
|
+
const agent = getAgent(agentId);
|
|
177
|
+
validateAuthMode(authMode);
|
|
178
|
+
const state = await loadRuntime(projectRoot);
|
|
179
|
+
if (!state.runtime.agents?.[agentId]?.enabled) {
|
|
180
|
+
throw new Error(`${agent.displayName} is not initialized`);
|
|
181
|
+
}
|
|
182
|
+
state.local.schemaVersion ??= 1;
|
|
183
|
+
state.local.agents ??= {};
|
|
184
|
+
state.local.agents[agentId] = {
|
|
185
|
+
...(state.local.agents[agentId] ?? {}),
|
|
186
|
+
auth: authMode,
|
|
187
|
+
};
|
|
188
|
+
await mkdir(state.paths.localRoot, { recursive: true });
|
|
189
|
+
if (authMode === "project") {
|
|
190
|
+
await mkdir(path.join(state.paths.localRoot, agentId), { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
await writeJsonIfChanged(state.paths.localRuntimeFile, state.local);
|
|
193
|
+
await ensureRuntimeGitignore(projectRoot);
|
|
194
|
+
return effectiveAgentConfig(state, agentId);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function clearLocalAuth(projectRoot, agentId) {
|
|
198
|
+
const agent = getAgent(agentId);
|
|
199
|
+
const state = await loadRuntime(projectRoot);
|
|
200
|
+
if (!state.runtime.agents?.[agentId]?.enabled) {
|
|
201
|
+
throw new Error(`${agent.displayName} is not initialized`);
|
|
202
|
+
}
|
|
203
|
+
if (!state.local.agents?.[agentId]) {
|
|
204
|
+
return effectiveAgentConfig(state, agentId);
|
|
205
|
+
}
|
|
206
|
+
delete state.local.agents[agentId];
|
|
207
|
+
if (Object.keys(state.local.agents).length === 0) {
|
|
208
|
+
await rm(state.paths.localRuntimeFile, { force: true });
|
|
209
|
+
} else {
|
|
210
|
+
await writeJsonIfChanged(state.paths.localRuntimeFile, state.local);
|
|
211
|
+
}
|
|
212
|
+
return effectiveAgentConfig(state, agentId);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function effectiveAgentConfig(state, agentId) {
|
|
216
|
+
const configured = state.runtime.agents?.[agentId];
|
|
217
|
+
const override = state.local.agents?.[agentId];
|
|
218
|
+
if (!configured) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
...configured,
|
|
223
|
+
...override,
|
|
224
|
+
configuredAuth: configured.auth ?? "global",
|
|
225
|
+
localAuth: override?.auth ?? null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const REQUIRED_RULES = [
|
|
6
|
+
".claude/skills/",
|
|
7
|
+
".agents/skills/",
|
|
8
|
+
".agents/local/",
|
|
9
|
+
".agents/tmp/",
|
|
10
|
+
".agents/direct/",
|
|
11
|
+
".agents/licenses/",
|
|
12
|
+
];
|
|
13
|
+
const SESSIONS_RULE = ".agents/sessions/";
|
|
14
|
+
|
|
15
|
+
async function readGitignore(projectRoot) {
|
|
16
|
+
const file = path.join(projectRoot, ".gitignore");
|
|
17
|
+
return {
|
|
18
|
+
content: existsSync(file) ? await readFile(file, "utf8") : "",
|
|
19
|
+
file,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function addRules(projectRoot, rules) {
|
|
24
|
+
const { content, file } = await readGitignore(projectRoot);
|
|
25
|
+
const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()));
|
|
26
|
+
const missing = rules.filter((rule) => !lines.has(rule));
|
|
27
|
+
if (missing.length === 0) return false;
|
|
28
|
+
const prefix = content.length === 0 ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
29
|
+
await writeFile(file, `${content}${prefix}# Agent Runtime\n${missing.join("\n")}\n`, "utf8");
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function ensureRuntimeGitignore(projectRoot) {
|
|
34
|
+
return addRules(projectRoot, REQUIRED_RULES);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function sessionsGitIgnored(projectRoot) {
|
|
38
|
+
const { content } = await readGitignore(projectRoot);
|
|
39
|
+
return content.split(/\r?\n/).some((line) => line.trim() === SESSIONS_RULE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function setSessionsGitIgnored(projectRoot, ignored) {
|
|
43
|
+
if (ignored) return addRules(projectRoot, [SESSIONS_RULE]);
|
|
44
|
+
const { content, file } = await readGitignore(projectRoot);
|
|
45
|
+
const lines = content.split(/\r?\n/);
|
|
46
|
+
const filtered = lines.filter((line) => line.trim() !== SESSIONS_RULE);
|
|
47
|
+
if (filtered.length === lines.length) return false;
|
|
48
|
+
await writeFile(file, filtered.join("\n"), "utf8");
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removeRuntimeGitignore(projectRoot, options = {}) {
|
|
53
|
+
const { content, file } = await readGitignore(projectRoot);
|
|
54
|
+
if (!content) return false;
|
|
55
|
+
const removable = new Set([".agents/local/", ".agents/tmp/"]);
|
|
56
|
+
if (options.sessions) removable.add(SESSIONS_RULE);
|
|
57
|
+
let lines = content.split(/\r?\n/).filter((line) => !removable.has(line.trim()));
|
|
58
|
+
const managedRules = new Set([...REQUIRED_RULES, SESSIONS_RULE]);
|
|
59
|
+
if (!lines.some((line) => managedRules.has(line.trim()))) {
|
|
60
|
+
lines = lines.filter((line) => line.trim() !== "# Agent Runtime");
|
|
61
|
+
}
|
|
62
|
+
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
63
|
+
const updated = lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
64
|
+
if (updated === content) return false;
|
|
65
|
+
await writeFile(file, updated, "utf8");
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { REQUIRED_RULES, SESSIONS_RULE };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
function resolveOnPath(executable, environment) {
|
|
7
|
+
if (path.isAbsolute(executable) || executable.includes(path.sep)) {
|
|
8
|
+
return existsSync(executable) ? executable : null;
|
|
9
|
+
}
|
|
10
|
+
const extensions = process.platform === "win32" ? [".exe", ".com", ".ps1", ".cmd", ".bat", ""] : [""];
|
|
11
|
+
for (const directory of (environment.PATH ?? "").split(path.delimiter)) {
|
|
12
|
+
if (!directory) continue;
|
|
13
|
+
for (const extension of extensions) {
|
|
14
|
+
const candidate = path.join(directory.replace(/^"|"$/g, ""), `${executable}${extension}`);
|
|
15
|
+
if (existsSync(candidate)) return candidate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function invocation(executable, argumentsList, environment) {
|
|
22
|
+
const resolved = resolveOnPath(executable, environment) ?? executable;
|
|
23
|
+
if (process.platform === "win32" && resolved.toLowerCase().endsWith(".ps1")) {
|
|
24
|
+
const powershell = path.join(environment.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
25
|
+
return {
|
|
26
|
+
command: powershell,
|
|
27
|
+
argumentsList: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", resolved, ...argumentsList],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { command: resolved, argumentsList };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function spawnExecutableSync(executable, argumentsList, options = {}) {
|
|
34
|
+
const environment = options.env ?? process.env;
|
|
35
|
+
const { spawn, ...spawnOptions } = options;
|
|
36
|
+
if (spawn) {
|
|
37
|
+
return spawn(executable, argumentsList, { ...spawnOptions, env: environment });
|
|
38
|
+
}
|
|
39
|
+
const resolved = invocation(executable, argumentsList, environment);
|
|
40
|
+
return spawnSync(resolved.command, resolved.argumentsList, { ...spawnOptions, env: environment });
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { LEGACY_PROJECT_CONFIG_FILE, PROJECT_CONFIG_FILE } from "../skills/paths.mjs";
|
|
5
|
+
|
|
6
|
+
function findMarker(startDirectory, relativeMarker) {
|
|
7
|
+
let current = path.resolve(startDirectory);
|
|
8
|
+
while (true) {
|
|
9
|
+
if (existsSync(path.join(current, relativeMarker))) {
|
|
10
|
+
return current;
|
|
11
|
+
}
|
|
12
|
+
const parent = path.dirname(current);
|
|
13
|
+
if (parent === current) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
current = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findGitRoot(startDirectory) {
|
|
21
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
22
|
+
cwd: startDirectory,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
});
|
|
26
|
+
if (result.status !== 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const root = result.stdout.trim();
|
|
30
|
+
return root ? path.resolve(root) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function locateProjectRoot(startDirectory = process.cwd()) {
|
|
34
|
+
const start = path.resolve(startDirectory);
|
|
35
|
+
return (
|
|
36
|
+
findGitRoot(start) ??
|
|
37
|
+
findMarker(start, path.join(".agents", "runtime.json")) ??
|
|
38
|
+
findMarker(start, PROJECT_CONFIG_FILE) ??
|
|
39
|
+
findMarker(start, LEGACY_PROJECT_CONFIG_FILE) ??
|
|
40
|
+
start
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
15
|
+
|
|
16
|
+
export const PROJECT_ROOT_TOKEN = "${PROJECT_ROOT}";
|
|
17
|
+
|
|
18
|
+
export function samePath(left, right) {
|
|
19
|
+
if (!left || !right) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const normalize = (value) => {
|
|
23
|
+
const resolved = path.resolve(value);
|
|
24
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
25
|
+
};
|
|
26
|
+
return normalize(left) === normalize(right);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function transformJsonLines(content, transform) {
|
|
30
|
+
const trailingNewline = content.endsWith("\n");
|
|
31
|
+
const lines = content.split(/\r?\n/);
|
|
32
|
+
if (trailingNewline) {
|
|
33
|
+
lines.pop();
|
|
34
|
+
}
|
|
35
|
+
const transformed = lines.map((line) => {
|
|
36
|
+
if (!line.trim()) {
|
|
37
|
+
return line;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(transform(JSON.parse(line)));
|
|
41
|
+
} catch {
|
|
42
|
+
return line;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return `${transformed.join("\n")}${trailingNewline ? "\n" : ""}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listFiles(root) {
|
|
49
|
+
if (!existsSync(root)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const files = [];
|
|
53
|
+
async function walk(directory) {
|
|
54
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
55
|
+
const absolute = path.join(directory, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
await walk(absolute);
|
|
58
|
+
} else if (entry.isFile()) {
|
|
59
|
+
files.push(path.relative(root, absolute));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await walk(root);
|
|
64
|
+
return files.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function replaceDirectory(destination, build) {
|
|
68
|
+
const parent = path.dirname(destination);
|
|
69
|
+
const suffix = `${process.pid}-${Date.now()}`;
|
|
70
|
+
const temporary = `${destination}.tmp-${suffix}`;
|
|
71
|
+
const backup = `${destination}.bak-${suffix}`;
|
|
72
|
+
await mkdir(parent, { recursive: true });
|
|
73
|
+
await rm(temporary, { recursive: true, force: true });
|
|
74
|
+
await mkdir(temporary, { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
await build(temporary);
|
|
77
|
+
if (existsSync(destination)) {
|
|
78
|
+
await renameWithRetry(destination, backup);
|
|
79
|
+
}
|
|
80
|
+
await renameWithRetry(temporary, destination);
|
|
81
|
+
await rm(backup, { recursive: true, force: true });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
await rm(temporary, { recursive: true, force: true });
|
|
84
|
+
if (existsSync(backup) && !existsSync(destination)) {
|
|
85
|
+
await renameWithRetry(backup, destination);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function renameWithRetry(source, destination) {
|
|
92
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
await rename(source, destination);
|
|
95
|
+
return;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (attempt >= 4 || !["EACCES", "EBUSY", "EPERM"].includes(error.code)) throw error;
|
|
98
|
+
await delay(40 * (attempt + 1));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function snapshotFiles(sourceRoot, relativeFiles, destination, transform) {
|
|
104
|
+
await replaceDirectory(destination, async (temporary) => {
|
|
105
|
+
for (const relative of relativeFiles) {
|
|
106
|
+
const source = path.join(sourceRoot, relative);
|
|
107
|
+
const target = path.join(temporary, relative);
|
|
108
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
109
|
+
const content = await readFile(source);
|
|
110
|
+
await writeFile(target, transform ? await transform(content, relative) : content);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isPrefix(prefix, content) {
|
|
116
|
+
return prefix.length <= content.length && content.subarray(0, prefix.length).equals(prefix);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function mergeFiles(sourceRoot, relativeFiles, destinationRoot, transform, options = {}) {
|
|
120
|
+
let added = 0;
|
|
121
|
+
let conflicts = 0;
|
|
122
|
+
let updated = 0;
|
|
123
|
+
let unchanged = 0;
|
|
124
|
+
for (const relative of relativeFiles) {
|
|
125
|
+
const source = path.join(sourceRoot, relative);
|
|
126
|
+
const destination = path.join(destinationRoot, relative);
|
|
127
|
+
const sourceContent = Buffer.from(transform ? await transform(await readFile(source), relative) : await readFile(source));
|
|
128
|
+
if (!existsSync(destination)) {
|
|
129
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
130
|
+
await writeFile(destination, sourceContent);
|
|
131
|
+
added += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const destinationContent = await readFile(destination);
|
|
135
|
+
if (sourceContent.equals(destinationContent) || isPrefix(sourceContent, destinationContent)) {
|
|
136
|
+
unchanged += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (relative.endsWith(".jsonl") && isPrefix(destinationContent, sourceContent)) {
|
|
140
|
+
await writeFile(destination, sourceContent);
|
|
141
|
+
updated += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (options.onConflict === "keep-destination") {
|
|
145
|
+
conflicts += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (options.onConflict === "keep-source") {
|
|
149
|
+
// The portable (project) copy wins; the destination gets the source
|
|
150
|
+
// content and the caller reports the conflict.
|
|
151
|
+
await writeFile(destination, sourceContent);
|
|
152
|
+
conflicts += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`Session conflict: ${relative}. Keep one version, then retry.`);
|
|
156
|
+
}
|
|
157
|
+
return { added, conflicts, updated, unchanged };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function readFirstJsonLine(file) {
|
|
161
|
+
const content = await readFile(file, "utf8");
|
|
162
|
+
const line = content.split(/\r?\n/, 1)[0];
|
|
163
|
+
return JSON.parse(line.replace(/^\uFEFF/, ""));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function hashContent(content) {
|
|
167
|
+
return createHash("sha256").update(content).digest("hex");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function copyPath(source, destination) {
|
|
171
|
+
const stats = await stat(source);
|
|
172
|
+
if (stats.isDirectory()) {
|
|
173
|
+
await mkdir(destination, { recursive: true });
|
|
174
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
175
|
+
await copyPath(path.join(source, entry.name), path.join(destination, entry.name));
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
179
|
+
await writeFile(destination, await readFile(source));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Copy a file or directory into destination, which must not exist; when the
|
|
184
|
+
// source is absent the destination is removed instead, so a path that did not
|
|
185
|
+
// exist at snapshot time disappears again on revert.
|
|
186
|
+
export async function snapshotInto(source, destination) {
|
|
187
|
+
if (!existsSync(source)) {
|
|
188
|
+
await rm(destination, { recursive: true, force: true });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await rm(destination, { recursive: true, force: true });
|
|
192
|
+
await copyPath(source, destination);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Restore the pre-launch state saved by snapshotInto: the source path returns
|
|
196
|
+
// to its snapshot content, or disappears entirely when the snapshot is absent.
|
|
197
|
+
export async function revertFrom(snapshot, source) {
|
|
198
|
+
await rm(source, { recursive: true, force: true });
|
|
199
|
+
if (existsSync(snapshot)) {
|
|
200
|
+
await copyPath(snapshot, source);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function processAlive(pid) {
|
|
205
|
+
try {
|
|
206
|
+
process.kill(pid, 0);
|
|
207
|
+
return true;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return error.code === "EPERM"; // exists but owned by another user
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Shared state for concurrent avenic launches of one agent in one project.
|
|
214
|
+
export function sessionLeasePath(agentId, projectRoot) {
|
|
215
|
+
const key = createHash("sha256").update(`${path.resolve(projectRoot)}\n${agentId}`).digest("hex").slice(0, 16);
|
|
216
|
+
return path.join(os.tmpdir(), `avenic-launch-${key}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Serialize the short bookkeeping sections of acquire/release. The lock file
|
|
220
|
+
// is created exclusively, holds the owner pid, and is stolen when that
|
|
221
|
+
// process is gone (crashed or killed).
|
|
222
|
+
async function withLaunchLock(stateDir, run) {
|
|
223
|
+
await mkdir(stateDir, { recursive: true });
|
|
224
|
+
const lockFile = path.join(stateDir, ".lock");
|
|
225
|
+
const deadline = Date.now() + 60000;
|
|
226
|
+
for (;;) {
|
|
227
|
+
try {
|
|
228
|
+
await writeFile(lockFile, `${process.pid}\n`, { encoding: "utf8", flag: "wx" });
|
|
229
|
+
break;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error.code !== "EEXIST") {
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
let owner = null;
|
|
235
|
+
try {
|
|
236
|
+
owner = Number.parseInt((await readFile(lockFile, "utf8")).trim(), 10);
|
|
237
|
+
} catch {}
|
|
238
|
+
if (!(Number.isInteger(owner) && processAlive(owner))) {
|
|
239
|
+
await rm(lockFile, { force: true });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() > deadline) {
|
|
243
|
+
throw new Error("Timed out waiting for another avenic launch in this project");
|
|
244
|
+
}
|
|
245
|
+
await delay(100);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
return await run();
|
|
250
|
+
} finally {
|
|
251
|
+
await rm(lockFile, { force: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function aliveLeasePids(stateDir) {
|
|
256
|
+
let names = [];
|
|
257
|
+
try {
|
|
258
|
+
names = await readdir(path.join(stateDir, "pids"));
|
|
259
|
+
} catch {}
|
|
260
|
+
return names
|
|
261
|
+
.map((name) => Number.parseInt(name.split("-", 1)[0], 10))
|
|
262
|
+
.filter((pid) => Number.isInteger(pid) && processAlive(pid));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let leaseMemberSequence = 0;
|
|
266
|
+
|
|
267
|
+
// Join a launch group for one project+agent. Any number of launches can be
|
|
268
|
+
// active at once; the first one snapshots the agent's native storage (or, when
|
|
269
|
+
// a previous group died without finishing, salvages it via onFirst(true)
|
|
270
|
+
// first) and the last one to exit reverts it via onLast, so sessions created
|
|
271
|
+
// by avenic launches live only in the project. The returned function
|
|
272
|
+
// leaves the group.
|
|
273
|
+
// Leave a launch group as the given member. Runs onLast when the group has
|
|
274
|
+
// no live launches left, then removes the group state. Used by the launch
|
|
275
|
+
// flow and by the watchdog that finishes an interrupted launch.
|
|
276
|
+
export async function releaseSessionLease(agentId, projectRoot, member, callbacks = {}) {
|
|
277
|
+
const stateDir = sessionLeasePath(agentId, projectRoot);
|
|
278
|
+
await withLaunchLock(stateDir, async () => {
|
|
279
|
+
await rm(path.join(stateDir, "pids", member), { force: true });
|
|
280
|
+
if ((await aliveLeasePids(stateDir)).length > 0) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
await callbacks.onLast?.();
|
|
285
|
+
} finally {
|
|
286
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function acquireSessionLease(agentId, projectRoot, callbacks = {}) {
|
|
292
|
+
const stateDir = sessionLeasePath(agentId, projectRoot);
|
|
293
|
+
const snapshotMarker = path.join(stateDir, "snapshot.ok");
|
|
294
|
+
// One member file per launch: two launches of the same process (tests) must
|
|
295
|
+
// count separately, while the pid records whether the owner is still alive.
|
|
296
|
+
const member = `${process.pid}-${Date.now()}-${leaseMemberSequence++}`;
|
|
297
|
+
await withLaunchLock(stateDir, async () => {
|
|
298
|
+
if ((await aliveLeasePids(stateDir)).length === 0) {
|
|
299
|
+
// First launch of the group, or every previous launch died: the marker
|
|
300
|
+
// records that a completed snapshot exists to recover from.
|
|
301
|
+
await callbacks.onFirst?.(existsSync(snapshotMarker));
|
|
302
|
+
await writeFile(snapshotMarker, "", { encoding: "utf8" });
|
|
303
|
+
}
|
|
304
|
+
await mkdir(path.join(stateDir, "pids"), { recursive: true });
|
|
305
|
+
await writeFile(path.join(stateDir, "pids", member), "", { encoding: "utf8" });
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
member,
|
|
309
|
+
stateDir,
|
|
310
|
+
release: () => releaseSessionLease(agentId, projectRoot, member, callbacks),
|
|
311
|
+
};
|
|
312
|
+
}
|