agy-crew 0.0.0-stage → 0.9.2
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 +22 -0
- package/README.md +315 -2
- package/README.zh-CN.md +314 -0
- package/companion/accounts.mjs +300 -0
- package/companion/agy-companion.mjs +2136 -0
- package/companion/identity.mjs +25 -0
- package/companion/install.mjs +443 -0
- package/companion/observation.mjs +147 -0
- package/companion/pool/env.mjs +14 -0
- package/companion/pool/lease.mjs +89 -0
- package/companion/pool/loans.mjs +46 -0
- package/companion/pool/quota.mjs +142 -0
- package/companion/pool/results.mjs +41 -0
- package/companion/pool/select.mjs +115 -0
- package/companion/pool/state.mjs +104 -0
- package/companion/pool/token.mjs +25 -0
- package/companion/state-lock.mjs +103 -0
- package/companion/stream-worker.mjs +361 -0
- package/docs/INSTALL_FOR_AGENTS.md +213 -0
- package/docs/REFERENCE.md +509 -0
- package/docs/REFERENCE.zh-CN.md +619 -0
- package/opencode-skills/agy-crew-accounts/SKILL.md +85 -0
- package/opencode-skills/agy-crew-ask/SKILL.md +46 -0
- package/opencode-skills/agy-crew-implementer/SKILL.md +56 -0
- package/opencode-skills/agy-crew-jobs/SKILL.md +99 -0
- package/opencode-skills/agy-crew-jobs/references/setup.md +41 -0
- package/opencode-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
- package/opencode-skills/agy-crew-lead/SKILL.md +39 -0
- package/opencode-skills/agy-crew-researcher/SKILL.md +52 -0
- package/opencode-skills/agy-crew-reviewer/SKILL.md +62 -0
- package/opencode-skills/agy-crew-reviewer/references/code-review.md +46 -0
- package/opencode-skills/agy-crew-reviewer/references/general-review.md +22 -0
- package/opencode-skills/agy-crew-staffer/SKILL.md +54 -0
- package/opencode.mjs +14 -0
- package/package.json +53 -4
- package/pi-skills/agy-crew-accounts/SKILL.md +85 -0
- package/pi-skills/agy-crew-ask/SKILL.md +46 -0
- package/pi-skills/agy-crew-implementer/SKILL.md +56 -0
- package/pi-skills/agy-crew-jobs/SKILL.md +99 -0
- package/pi-skills/agy-crew-jobs/references/setup.md +41 -0
- package/pi-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
- package/pi-skills/agy-crew-lead/SKILL.md +39 -0
- package/pi-skills/agy-crew-researcher/SKILL.md +52 -0
- package/pi-skills/agy-crew-reviewer/SKILL.md +62 -0
- package/pi-skills/agy-crew-reviewer/references/code-review.md +46 -0
- package/pi-skills/agy-crew-reviewer/references/general-review.md +22 -0
- package/pi-skills/agy-crew-staffer/SKILL.md +54 -0
- package/scripts/generate-skills.mjs +138 -0
- package/skills/accounts/SKILL.md +78 -0
- package/skills/ask/SKILL.md +40 -0
- package/skills/implementer/SKILL.md +50 -0
- package/skills/jobs/SKILL.md +93 -0
- package/skills/jobs/references/setup.md +39 -0
- package/skills/jobs/references/troubleshooting.md +33 -0
- package/skills/lead/SKILL.md +32 -0
- package/skills/researcher/SKILL.md +46 -0
- package/skills/reviewer/SKILL.md +56 -0
- package/skills/reviewer/references/code-review.md +44 -0
- package/skills/reviewer/references/general-review.md +20 -0
- package/skills/staffer/SKILL.md +48 -0
- package/templates/ask.md +5 -0
- package/templates/harness-compatibility.md +5 -0
- package/templates/implement.md +39 -0
- package/templates/research.md +37 -0
- package/templates/review.md +27 -0
- package/templates/staffer.md +19 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Product identity. Every user-visible name derives from these constants. */
|
|
5
|
+
export const PRODUCT = 'agy-crew';
|
|
6
|
+
/** Prefix of flat, user-level skill directory names: agy-crew-staffer, agy-crew-jobs, ... */
|
|
7
|
+
export const SKILL_PREFIX = `${PRODUCT}-`;
|
|
8
|
+
/** Harness plugin invocation prefixes rewritten by the skill generator: Claude Code, Codex. */
|
|
9
|
+
export const PLUGIN_INVOCATIONS = [`/${PRODUCT}:`, `$${PRODUCT}:`];
|
|
10
|
+
/** Per-repository state directory (jobs, logs, config). */
|
|
11
|
+
export const STATE_DIR_NAME = `.${PRODUCT}`;
|
|
12
|
+
/** Environment variable overriding the global (per-user) state directory. */
|
|
13
|
+
export const HOME_ENV = 'AGY_CREW_HOME';
|
|
14
|
+
/** Global per-user directory (account pool, installed skill bundles). */
|
|
15
|
+
export function globalHome() {
|
|
16
|
+
const override = process.env[HOME_ENV];
|
|
17
|
+
return override ? path.resolve(override) : path.join(os.homedir(), STATE_DIR_NAME);
|
|
18
|
+
}
|
|
19
|
+
/** The upstream project this fork must never be installed alongside. */
|
|
20
|
+
export const UPSTREAM = {
|
|
21
|
+
product: 'agy-staff',
|
|
22
|
+
pluginId: 'agy@agy-staff',
|
|
23
|
+
skillPrefix: 'agy-',
|
|
24
|
+
repo: 'https://github.com/keli-wen/agy-staff',
|
|
25
|
+
};
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `install` / `uninstall`: put agy-crew's skills into the user-level skill
|
|
3
|
+
* directories of every detected harness from one shared bundle.
|
|
4
|
+
*
|
|
5
|
+
* One rendered bundle lives under <globalHome>/bundles/<version>-<digest>/;
|
|
6
|
+
* every host gets one directory link per skill pointing into it. A destination
|
|
7
|
+
* is "managed" only when it is a link into <globalHome>/bundles/; anything else
|
|
8
|
+
* is user content and is never overwritten or removed. Installation is
|
|
9
|
+
* all-or-nothing: conflicts (including the upstream agy-staff) abort before
|
|
10
|
+
* anything is written.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { PRODUCT, SKILL_PREFIX, UPSTREAM, globalHome } from './identity.mjs';
|
|
18
|
+
import { skillFiles } from '../scripts/generate-skills.mjs';
|
|
19
|
+
|
|
20
|
+
const WIN = process.platform === 'win32';
|
|
21
|
+
const STAGING_GRACE_MS = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
export const HOSTS = {
|
|
24
|
+
claude: { skills: ['.claude', 'skills'], detect: ['.claude'] },
|
|
25
|
+
codex: { skills: ['.agents', 'skills'], detect: ['.codex'] },
|
|
26
|
+
omp: { skills: ['.omp', 'agent', 'skills'], detect: ['.omp'] },
|
|
27
|
+
opencode: { skills: ['.config', 'opencode', 'skills'], detect: ['.config', 'opencode'] },
|
|
28
|
+
dsh: { skills: ['.dsh', 'skills'], detect: ['.dsh'] },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const home = (...parts) => path.join(os.homedir(), ...parts);
|
|
32
|
+
const hostDir = key => home(...HOSTS[key].skills);
|
|
33
|
+
const hostPresent = key => fs.existsSync(home(...HOSTS[key].detect));
|
|
34
|
+
const fwd = p => p.replaceAll('\\', '/');
|
|
35
|
+
const bundlesRoot = () => path.join(globalHome(), 'bundles');
|
|
36
|
+
|
|
37
|
+
function fail(message) {
|
|
38
|
+
throw Object.assign(new Error(message), { exitCode: 1 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function norm(p) {
|
|
42
|
+
const r = path.resolve(p.replace(/^\\\\\?\\/, ''));
|
|
43
|
+
return WIN ? r.toLowerCase() : r;
|
|
44
|
+
}
|
|
45
|
+
const inside = (child, parent) => norm(child).startsWith(norm(parent) + path.sep);
|
|
46
|
+
|
|
47
|
+
function lstat(p) {
|
|
48
|
+
try { return fs.lstatSync(p); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolved link target when `p` is a link, else null. */
|
|
52
|
+
function linkTarget(p) {
|
|
53
|
+
const st = lstat(p);
|
|
54
|
+
if (!st || !st.isSymbolicLink()) return null;
|
|
55
|
+
try {
|
|
56
|
+
return path.resolve(path.dirname(p), fs.readlinkSync(p).replace(/^\\\\\?\\/, ''));
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Managed = a link whose target lies inside <globalHome>/bundles/. Returns the target or null. */
|
|
61
|
+
function managedTarget(p) {
|
|
62
|
+
const t = linkTarget(p);
|
|
63
|
+
return t && inside(t, bundlesRoot()) ? t : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeLink(p) {
|
|
67
|
+
try { fs.unlinkSync(p); } catch { fs.rmdirSync(p); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// bundle rendering
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export function packageRoot() {
|
|
75
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
76
|
+
for (const need of ['skills', path.join('companion', 'agy-companion.mjs'), 'package.json']) {
|
|
77
|
+
if (!fs.existsSync(path.join(root, need))) fail(`package root ${root} is missing ${need}`);
|
|
78
|
+
}
|
|
79
|
+
return root;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Render the bundle in memory. Returns { version, digest, name, bundleDir, files, personas }. */
|
|
83
|
+
export function renderBundle(root = packageRoot()) {
|
|
84
|
+
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
85
|
+
const rootFwd = fwd(root).replace(/\/$/, '');
|
|
86
|
+
const raw = skillFiles(root, 'user');
|
|
87
|
+
const files = new Map(); // relative path inside the bundle -> Buffer
|
|
88
|
+
const personas = new Set();
|
|
89
|
+
for (const [rel, content] of [...raw].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
90
|
+
const inner = rel.split(path.sep).join('/').replace(/^user-skills\//, '');
|
|
91
|
+
personas.add(inner.split('/')[0].slice(SKILL_PREFIX.length));
|
|
92
|
+
let out = content;
|
|
93
|
+
if (inner.endsWith('.md')) {
|
|
94
|
+
out = Buffer.from(content.toString('utf8')
|
|
95
|
+
.replaceAll('<skill-dir>/../../', `${rootFwd}/`)
|
|
96
|
+
.replaceAll('../../', `${rootFwd}/`));
|
|
97
|
+
}
|
|
98
|
+
files.set(inner, out);
|
|
99
|
+
}
|
|
100
|
+
// The digest is taken before the bundle's own location is substituted
|
|
101
|
+
// (rule 3), which would otherwise make it circular.
|
|
102
|
+
const hash = crypto.createHash('sha256');
|
|
103
|
+
for (const [rel, content] of files) hash.update(rel).update('\0').update(content).update('\0');
|
|
104
|
+
const digest = hash.digest('hex').slice(0, 12);
|
|
105
|
+
const name = `${version}-${digest}`;
|
|
106
|
+
const bundleDir = path.join(bundlesRoot(), name);
|
|
107
|
+
for (const [rel, content] of files) {
|
|
108
|
+
if (!rel.endsWith('.md')) continue;
|
|
109
|
+
const text = content.toString('utf8').replaceAll('<plugin-root>/user-skills/', `${fwd(bundleDir)}/`);
|
|
110
|
+
const left = /<skill-dir>|<plugin-root>|\.\.\/\.\.\//.exec(text);
|
|
111
|
+
if (left) fail(`rendered ${rel} still contains ${left[0]}`);
|
|
112
|
+
files.set(rel, Buffer.from(text));
|
|
113
|
+
}
|
|
114
|
+
return { version, digest, name, bundleDir, files, personas: [...personas].sort() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sameContent(dir, files) {
|
|
118
|
+
try {
|
|
119
|
+
for (const [rel, content] of files) {
|
|
120
|
+
if (!fs.readFileSync(path.join(dir, rel)).equals(content)) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
} catch { return false; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeBundle(bundle) {
|
|
127
|
+
const root = bundlesRoot();
|
|
128
|
+
fs.mkdirSync(root, { recursive: true });
|
|
129
|
+
if (fs.existsSync(bundle.bundleDir) && sameContent(bundle.bundleDir, bundle.files)) return;
|
|
130
|
+
const staging = path.join(root, `.staging-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
131
|
+
try {
|
|
132
|
+
for (const [rel, content] of bundle.files) {
|
|
133
|
+
const target = path.join(staging, rel);
|
|
134
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
135
|
+
fs.writeFileSync(target, content);
|
|
136
|
+
}
|
|
137
|
+
if (fs.existsSync(bundle.bundleDir)) fs.rmSync(bundle.bundleDir, { recursive: true, force: true }); // damaged copy
|
|
138
|
+
fs.renameSync(staging, bundle.bundleDir);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// A concurrent install may have won the race with identical content.
|
|
141
|
+
if (!(fs.existsSync(bundle.bundleDir) && sameContent(bundle.bundleDir, bundle.files))) throw error;
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// host selection and classification
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function selectHosts(opts, { defaultPresent }) {
|
|
152
|
+
if (opts.hosts && opts.all) fail('--hosts and --all are mutually exclusive');
|
|
153
|
+
if (opts.hosts) {
|
|
154
|
+
const keys = opts.hosts.split(',').map(s => s.trim()).filter(Boolean);
|
|
155
|
+
const bad = keys.filter(k => !Object.hasOwn(HOSTS, k));
|
|
156
|
+
if (bad.length || !keys.length) fail(`unknown host ${bad.join(', ') || '(empty)'}; valid hosts: ${Object.keys(HOSTS).join(', ')}`);
|
|
157
|
+
return [...new Set(keys)];
|
|
158
|
+
}
|
|
159
|
+
if (opts.all) return Object.keys(HOSTS);
|
|
160
|
+
return defaultPresent();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Managed `agy-crew-*` entries currently in a host's skills dir: [{ name, dest, target }]. */
|
|
164
|
+
function managedEntries(key) {
|
|
165
|
+
const dir = hostDir(key);
|
|
166
|
+
let names = [];
|
|
167
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
168
|
+
return names.filter(n => n.startsWith(SKILL_PREFIX) && !n.includes('.tmp-'))
|
|
169
|
+
.map(name => ({ name, dest: path.join(dir, name), target: managedTarget(path.join(dir, name)) }))
|
|
170
|
+
.filter(e => e.target);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function classify(dest, target) {
|
|
174
|
+
if (!lstat(dest)) return 'created';
|
|
175
|
+
const current = managedTarget(dest);
|
|
176
|
+
if (!current) return 'conflict';
|
|
177
|
+
return norm(current) === norm(target) ? 'unchanged' : 'updated';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function upstreamConflicts(selected, personas) {
|
|
181
|
+
const out = [];
|
|
182
|
+
const add = (p, reason, remedy) => out.push({ path: p, reason, remedy });
|
|
183
|
+
const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
|
|
184
|
+
|
|
185
|
+
const installed = home('.claude', 'plugins', 'installed_plugins.json');
|
|
186
|
+
const text = readText(installed);
|
|
187
|
+
if (text !== null) {
|
|
188
|
+
try {
|
|
189
|
+
const json = JSON.parse(text);
|
|
190
|
+
if (json && json.plugins && Object.hasOwn(json.plugins, UPSTREAM.pluginId)) {
|
|
191
|
+
add(installed, `upstream ${UPSTREAM.product} plugin ${UPSTREAM.pluginId} is installed in Claude Code`, `claude plugin uninstall ${UPSTREAM.pluginId}`);
|
|
192
|
+
}
|
|
193
|
+
} catch { /* unparsable: not ours to judge */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const codexCache = home('.codex', 'plugins', 'cache', UPSTREAM.product);
|
|
197
|
+
if (fs.existsSync(codexCache)) {
|
|
198
|
+
add(codexCache, `upstream ${UPSTREAM.product} plugin is installed in Codex`, `remove the ${UPSTREAM.product} plugin from Codex (see codex plugin --help) and restart Codex`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const name of ['opencode.json', 'opencode.jsonc']) {
|
|
202
|
+
const file = home('.config', 'opencode', name);
|
|
203
|
+
const body = readText(file);
|
|
204
|
+
if (body !== null && body.includes(`${UPSTREAM.product}@`)) {
|
|
205
|
+
add(file, `upstream ${UPSTREAM.product} plugin is configured in OpenCode`, `remove the ${UPSTREAM.product} plugin entry from ${file}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const pi = home('.pi');
|
|
210
|
+
if (fs.existsSync(pi)) {
|
|
211
|
+
const walk = (dir, depth) => {
|
|
212
|
+
let entries;
|
|
213
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
const full = path.join(dir, entry.name);
|
|
216
|
+
if (entry.isDirectory()) {
|
|
217
|
+
if (depth < 3) { const hit = walk(full, depth + 1); if (hit) return hit; }
|
|
218
|
+
} else if (entry.isFile()) {
|
|
219
|
+
try {
|
|
220
|
+
if (fs.statSync(full).size <= 1024 * 1024 && fs.readFileSync(full, 'utf8').includes(UPSTREAM.product)) return full;
|
|
221
|
+
} catch { /* unreadable */ }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
const hit = walk(pi, 1);
|
|
227
|
+
if (hit) add(hit, `upstream ${UPSTREAM.product} is referenced in the Pi configuration`, `uninstall the ${UPSTREAM.product} Pi package (pi remove / pi list)`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const key of selected) {
|
|
231
|
+
for (const persona of personas) {
|
|
232
|
+
const entry = path.join(hostDir(key), `${UPSTREAM.skillPrefix}${persona}`);
|
|
233
|
+
if (lstat(entry)) add(entry, `upstream ${UPSTREAM.product} skill (or a conflicting user skill)`, `remove ${entry} if it is the ${UPSTREAM.product} skill`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// pruning
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
function prune(currentName) {
|
|
244
|
+
const root = bundlesRoot();
|
|
245
|
+
try {
|
|
246
|
+
if (!fs.existsSync(root)) return;
|
|
247
|
+
const referenced = new Set();
|
|
248
|
+
for (const key of Object.keys(HOSTS)) {
|
|
249
|
+
for (const { target } of managedEntries(key)) {
|
|
250
|
+
referenced.add(norm(path.join(root, path.relative(root, target).split(path.sep)[0])));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
254
|
+
const full = path.join(root, entry.name);
|
|
255
|
+
try {
|
|
256
|
+
if (entry.name === currentName) continue;
|
|
257
|
+
if (entry.name.startsWith('.staging-')) {
|
|
258
|
+
if (Date.now() - fs.statSync(full).mtimeMs < STAGING_GRACE_MS) continue;
|
|
259
|
+
} else if (referenced.has(norm(full))) continue;
|
|
260
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
process.stderr.write(`${PRODUCT} warning: could not prune ${full}: ${error.message}\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
process.stderr.write(`${PRODUCT} warning: pruning bundles failed: ${error.message}\n`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// install
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function counts(skills) {
|
|
275
|
+
const tally = {};
|
|
276
|
+
for (const s of skills) tally[s.change] = (tally[s.change] || 0) + 1;
|
|
277
|
+
return ['created', 'updated', 'unchanged'].filter(k => tally[k]).map(k => `${tally[k]} ${k}`).join(', ') || '0 changed';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function makeLink(target, dest) {
|
|
281
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
282
|
+
const tmp = `${dest}.tmp-${process.pid}`;
|
|
283
|
+
if (lstat(tmp)) removeLink(tmp);
|
|
284
|
+
fs.symlinkSync(target, tmp, WIN ? 'junction' : 'dir');
|
|
285
|
+
try {
|
|
286
|
+
fs.renameSync(tmp, dest);
|
|
287
|
+
} catch {
|
|
288
|
+
try {
|
|
289
|
+
if (managedTarget(dest)) removeLink(dest);
|
|
290
|
+
fs.renameSync(tmp, dest);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
try { removeLink(tmp); } catch { /* best effort */ }
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function cmdInstall(opts) {
|
|
299
|
+
if (opts.status) return cmdStatus(opts);
|
|
300
|
+
const bundle = renderBundle();
|
|
301
|
+
const selected = selectHosts(opts, {
|
|
302
|
+
defaultPresent: () => {
|
|
303
|
+
const present = Object.keys(HOSTS).filter(hostPresent);
|
|
304
|
+
if (!present.length) fail('no supported harness detected; pass --hosts <list> or --all');
|
|
305
|
+
return present;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const conflicts = upstreamConflicts(selected, bundle.personas);
|
|
310
|
+
const plan = selected.map(key => ({
|
|
311
|
+
host: key,
|
|
312
|
+
dir: hostDir(key),
|
|
313
|
+
skills: bundle.personas.map(persona => {
|
|
314
|
+
const name = `${SKILL_PREFIX}${persona}`;
|
|
315
|
+
const dest = path.join(hostDir(key), name);
|
|
316
|
+
const change = classify(dest, path.join(bundle.bundleDir, name));
|
|
317
|
+
if (change === 'conflict') {
|
|
318
|
+
conflicts.push({ path: dest, reason: 'exists and was not created by agy-crew', remedy: `move or remove ${dest}, then re-run install` });
|
|
319
|
+
}
|
|
320
|
+
return { name, dest, change };
|
|
321
|
+
}),
|
|
322
|
+
}));
|
|
323
|
+
const publicPlan = plan.map(h => ({ host: h.host, dir: h.dir, skills: h.skills.map(({ name, change }) => ({ name, change })) }));
|
|
324
|
+
|
|
325
|
+
if (opts.json) {
|
|
326
|
+
process.stdout.write(`${JSON.stringify({ version: bundle.version, bundle: bundle.bundleDir, hosts: publicPlan, conflicts }, null, 2)}\n`);
|
|
327
|
+
if (conflicts.length) throw Object.assign(new Error('conflicts'), { exitCode: 1, alreadyPrinted: true });
|
|
328
|
+
} else if (conflicts.length) {
|
|
329
|
+
const lines = [`${PRODUCT}: install aborted, nothing was written. ${conflicts.length} conflict(s):`];
|
|
330
|
+
for (const c of conflicts) lines.push(` ${c.path}`, ` reason: ${c.reason}`, ` remedy: ${c.remedy}`);
|
|
331
|
+
process.stderr.write(`${lines.join('\n')}\n`);
|
|
332
|
+
throw Object.assign(new Error('conflicts'), { exitCode: 1, alreadyPrinted: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (opts['dry-run']) {
|
|
336
|
+
if (opts.json) return;
|
|
337
|
+
const lines = [`${PRODUCT} ${bundle.version} (dry run) → ${bundle.bundleDir}`];
|
|
338
|
+
for (const h of plan) {
|
|
339
|
+
lines.push(`${h.host} ${h.dir}`);
|
|
340
|
+
for (const s of h.skills) lines.push(` ${s.name} ${s.change}`);
|
|
341
|
+
}
|
|
342
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
writeBundle(bundle);
|
|
347
|
+
const done = []; // { dest, previous }
|
|
348
|
+
try {
|
|
349
|
+
for (const h of plan) {
|
|
350
|
+
for (const s of h.skills) {
|
|
351
|
+
if (s.change === 'unchanged') continue;
|
|
352
|
+
const previous = s.change === 'updated' ? managedTarget(s.dest) : null;
|
|
353
|
+
makeLink(path.join(bundle.bundleDir, s.name), s.dest);
|
|
354
|
+
done.push({ dest: s.dest, previous });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
for (const { dest, previous } of done.reverse()) {
|
|
359
|
+
try {
|
|
360
|
+
removeLink(dest);
|
|
361
|
+
if (previous) makeLink(previous, dest);
|
|
362
|
+
} catch { /* best effort rollback */ }
|
|
363
|
+
}
|
|
364
|
+
fail(`install failed and was rolled back: ${error.message}`);
|
|
365
|
+
}
|
|
366
|
+
prune(bundle.name);
|
|
367
|
+
|
|
368
|
+
if (!opts.json) {
|
|
369
|
+
const lines = [`${PRODUCT} ${bundle.version} → ${bundle.bundleDir}`];
|
|
370
|
+
for (const h of plan) lines.push(`${h.host.padEnd(9)} ${counts(h.skills).padEnd(22)} ${h.dir}`);
|
|
371
|
+
lines.push(`Restart the harnesses (or open a new session) to load the skills. Smoke test: /${SKILL_PREFIX}ask reply with OK`);
|
|
372
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// status
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
function cmdStatus(opts) {
|
|
381
|
+
const bundle = renderBundle();
|
|
382
|
+
const hosts = Object.keys(HOSTS).map(key => ({
|
|
383
|
+
host: key,
|
|
384
|
+
present: hostPresent(key),
|
|
385
|
+
dir: hostDir(key),
|
|
386
|
+
skills: bundle.personas.map(persona => {
|
|
387
|
+
const name = `${SKILL_PREFIX}${persona}`;
|
|
388
|
+
const dest = path.join(hostDir(key), name);
|
|
389
|
+
const change = classify(dest, path.join(bundle.bundleDir, name));
|
|
390
|
+
const state = { created: 'missing', unchanged: 'managed-current', updated: 'managed-stale', conflict: 'conflict' }[change];
|
|
391
|
+
return { name, state };
|
|
392
|
+
}),
|
|
393
|
+
}));
|
|
394
|
+
let bundles = [];
|
|
395
|
+
try { bundles = fs.readdirSync(bundlesRoot()).sort(); } catch { /* none */ }
|
|
396
|
+
const listing = bundles.map(name => ({ name, current: name === bundle.name }));
|
|
397
|
+
if (opts.json) {
|
|
398
|
+
process.stdout.write(`${JSON.stringify({ version: bundle.version, bundle: bundle.bundleDir, hosts, bundles: listing }, null, 2)}\n`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const lines = [`${PRODUCT} ${bundle.version} current bundle: ${bundle.bundleDir}`];
|
|
402
|
+
for (const h of hosts) {
|
|
403
|
+
lines.push(`${h.host.padEnd(9)} ${h.present ? 'present' : 'absent'} ${h.dir}`);
|
|
404
|
+
for (const s of h.skills) lines.push(` ${s.name.padEnd(22)} ${s.state}`);
|
|
405
|
+
}
|
|
406
|
+
lines.push(`bundles (${bundlesRoot()}):`);
|
|
407
|
+
if (!listing.length) lines.push(' (none)');
|
|
408
|
+
for (const b of listing) lines.push(` ${b.name}${b.current ? ' (current)' : ''}`);
|
|
409
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// uninstall
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
export function cmdUninstall(opts) {
|
|
417
|
+
const selected = selectHosts(opts, {
|
|
418
|
+
defaultPresent: () => Object.keys(HOSTS).filter(key => managedEntries(key).length),
|
|
419
|
+
});
|
|
420
|
+
const result = [];
|
|
421
|
+
for (const key of selected) {
|
|
422
|
+
let removed = 0;
|
|
423
|
+
for (const { dest } of managedEntries(key)) {
|
|
424
|
+
try { removeLink(dest); removed++; } catch (error) {
|
|
425
|
+
process.stderr.write(`${PRODUCT} warning: could not remove ${dest}: ${error.message}\n`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
result.push({ host: key, dir: hostDir(key), removed });
|
|
429
|
+
}
|
|
430
|
+
const remaining = Object.keys(HOSTS).some(key => managedEntries(key).length);
|
|
431
|
+
if (remaining) prune(null);
|
|
432
|
+
else {
|
|
433
|
+
try { fs.rmSync(bundlesRoot(), { recursive: true, force: true }); } catch (error) {
|
|
434
|
+
process.stderr.write(`${PRODUCT} warning: could not remove ${bundlesRoot()}: ${error.message}\n`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (opts.json) {
|
|
438
|
+
process.stdout.write(`${JSON.stringify({ hosts: result }, null, 2)}\n`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!result.length) process.stdout.write(`${PRODUCT}: nothing to uninstall\n`);
|
|
442
|
+
for (const h of result) process.stdout.write(`${h.host.padEnd(9)} ${h.removed} removed ${h.dir}\n`);
|
|
443
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Per-job projection of AGY's NDJSON protocol. No shared observer cursor.
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
|
|
4
|
+
export const bytes = (value) => Buffer.byteLength(JSON.stringify(value), 'utf8');
|
|
5
|
+
export function excerpt(value, limit, tail = false) {
|
|
6
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
|
|
7
|
+
if (bytes(text) <= limit) return { text, truncated: false };
|
|
8
|
+
const chars = Array.from(text);
|
|
9
|
+
let lo = 0, hi = chars.length;
|
|
10
|
+
while (lo < hi) {
|
|
11
|
+
const n = Math.ceil((lo + hi) / 2);
|
|
12
|
+
const part = tail ? chars.slice(-n).join('') : chars.slice(0, n).join('');
|
|
13
|
+
if (bytes(part) <= limit) lo = n; else hi = n - 1;
|
|
14
|
+
}
|
|
15
|
+
return { text: lo ? (tail ? chars.slice(-lo) : chars.slice(0, lo)).join('') : '', truncated: true };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function boundSnapshot(snapshot) {
|
|
19
|
+
const out = structuredClone(snapshot);
|
|
20
|
+
while (bytes(out) + 1 > 8192 && out.recent_activities?.length) {
|
|
21
|
+
out.recent_activities.shift();
|
|
22
|
+
out.truncated = true;
|
|
23
|
+
}
|
|
24
|
+
if (bytes(out) + 1 > 8192) { out.latest_text = null; out.truncated = true; }
|
|
25
|
+
// Path/configuration values can also be unusually large. Keep the object valid.
|
|
26
|
+
if (bytes(out) + 1 > 8192) {
|
|
27
|
+
for (const key of Object.keys(out)) {
|
|
28
|
+
if (typeof out[key] === 'string') out[key] = excerpt(out[key], 256).text;
|
|
29
|
+
}
|
|
30
|
+
for (const key of Object.keys(out.details || {})) {
|
|
31
|
+
if (typeof out.details[key] === 'string') out.details[key] = excerpt(out.details[key], 512).text;
|
|
32
|
+
}
|
|
33
|
+
out.truncated = true;
|
|
34
|
+
out.details_truncated = true;
|
|
35
|
+
}
|
|
36
|
+
// Terminal observations add nested recovery/configuration strings. Budget
|
|
37
|
+
// those too; a long path must not bypass the same 8 KiB response ceiling.
|
|
38
|
+
if (bytes(out) + 1 > 8192) {
|
|
39
|
+
const trim = (value, limit) => {
|
|
40
|
+
if (typeof value === 'string') return excerpt(value, limit).text;
|
|
41
|
+
if (Array.isArray(value)) return value.map((item) => trim(item, limit));
|
|
42
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, trim(item, limit)]));
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
out.truncated = true;
|
|
46
|
+
out.details_truncated = true;
|
|
47
|
+
for (const limit of [256, 128, 64]) {
|
|
48
|
+
const compact = trim(out, limit);
|
|
49
|
+
if (bytes(compact) + 1 <= 8192) return compact;
|
|
50
|
+
}
|
|
51
|
+
return { job_id: excerpt(out.job_id || '', 256).text, status: out.status,
|
|
52
|
+
result_file: excerpt(out.result_file || out.details?.result || '', 512).text,
|
|
53
|
+
truncated: true, details_truncated: true };
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createProjection(onConversation = () => {}) {
|
|
59
|
+
const data = { last_event_at: null, recent_activities: [], latest_text: null, warnings: [] };
|
|
60
|
+
let conversation = null;
|
|
61
|
+
const warn = (message) => { if (!data.warnings.includes(message) && data.warnings.length < 5) data.warnings.push(message); };
|
|
62
|
+
const accept = (event) => {
|
|
63
|
+
data.last_event_at = new Date().toISOString();
|
|
64
|
+
const body = event?.[event.event] || event;
|
|
65
|
+
const id = body?.conversation_id;
|
|
66
|
+
if (typeof id === 'string' && id.length <= 256 && id !== conversation) { conversation = id; onConversation(id); }
|
|
67
|
+
if (event.event === 'init' || event.event === 'result') return;
|
|
68
|
+
if (event.event !== 'step_update') { warn('Unknown event retained in raw output.'); return; }
|
|
69
|
+
const step = event.step_update;
|
|
70
|
+
if (!step || !Number.isInteger(step.step_index)) { warn('Unrecognized step retained in raw output.'); return; }
|
|
71
|
+
const key = `${conversation}:${step.step_index}`;
|
|
72
|
+
const state = step.state === 'ACTIVE' ? 'running' : step.state === 'DONE' ? 'done' : 'unknown';
|
|
73
|
+
if (step.step_type === 'tool') {
|
|
74
|
+
let item = data.recent_activities.find((a) => a.key === key);
|
|
75
|
+
if (!item) {
|
|
76
|
+
const oldest = data.recent_activities[0];
|
|
77
|
+
if (data.recent_activities.length === 5 && oldest.key.startsWith(`${conversation}:`) && step.step_index < oldest.step_index) return;
|
|
78
|
+
item = { key, step_index: step.step_index, started_at: data.last_event_at };
|
|
79
|
+
data.recent_activities.push(item);
|
|
80
|
+
if (data.recent_activities.length > 5) data.recent_activities.shift();
|
|
81
|
+
}
|
|
82
|
+
const info = step.tool_info || {};
|
|
83
|
+
const tool = excerpt(step.tool_name || info.name || item.tool || 'unknown', 100);
|
|
84
|
+
item.tool = tool.text; item.tool_truncated = tool.truncated || !!item.tool_truncated;
|
|
85
|
+
item.updated_at = data.last_event_at;
|
|
86
|
+
item.status = info.error || step.error || /ERROR|FAIL/.test(step.state || '') ? 'error' : state;
|
|
87
|
+
if (info.parameters !== undefined) {
|
|
88
|
+
const input = excerpt(info.parameters, 240);
|
|
89
|
+
item.input_preview = input.text; item.input_truncated = input.truncated;
|
|
90
|
+
}
|
|
91
|
+
if (info.output !== undefined || info.error || step.error) {
|
|
92
|
+
const output = excerpt(info.error || step.error || info.output, 240);
|
|
93
|
+
item.output_preview = output.text; item.output_truncated = output.truncated;
|
|
94
|
+
}
|
|
95
|
+
item.truncated = !!(item.input_truncated || item.output_truncated || item.tool_truncated);
|
|
96
|
+
if (item.status === 'error') warn('Tool reported an explicit error; see raw output.');
|
|
97
|
+
// Identity is bounded too; keys are internal, never serialized.
|
|
98
|
+
const publicItem = { ...item }; delete publicItem.key;
|
|
99
|
+
if (bytes(publicItem) > 1024) { item.input_preview = ''; item.output_preview = ''; item.truncated = true; }
|
|
100
|
+
} else if (step.step_type === 'agent_response') {
|
|
101
|
+
if (data.latest_text?.key.startsWith(`${conversation}:`) && step.step_index < data.latest_text.step_index) return;
|
|
102
|
+
if (typeof step.text_delta === 'string' && step.text_delta) {
|
|
103
|
+
const prev = data.latest_text?.key === key ? data.latest_text : null;
|
|
104
|
+
const text = excerpt((prev?.text || '') + step.text_delta, 1700, true);
|
|
105
|
+
data.latest_text = { key, step_index: step.step_index, text: text.text, truncated: !!(prev?.truncated || text.truncated), updated_at: data.last_event_at, incomplete: state !== 'done', status: state };
|
|
106
|
+
} else if (data.latest_text?.key === key) {
|
|
107
|
+
data.latest_text.incomplete = state !== 'done';
|
|
108
|
+
data.latest_text.status = state;
|
|
109
|
+
data.latest_text.updated_at = data.last_event_at;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const snapshot = () => {
|
|
114
|
+
const out = structuredClone(data);
|
|
115
|
+
out.recent_activities.forEach((item) => delete item.key);
|
|
116
|
+
if (out.latest_text) delete out.latest_text.key;
|
|
117
|
+
return out;
|
|
118
|
+
};
|
|
119
|
+
return { accept, snapshot, warn };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bound the pending record independently of raw file retention. Large/malformed
|
|
123
|
+
// records degrade observation; only a valid result event can complete a job.
|
|
124
|
+
export function createParser(onEvent, onWarning, maxRecord = 8 * 1024 * 1024) {
|
|
125
|
+
const decoder = new StringDecoder('utf8');
|
|
126
|
+
let pending = '', pendingBytes = 0, dropping = false;
|
|
127
|
+
const consume = (text) => {
|
|
128
|
+
for (const fragment of text.split(/(?<=\n)/)) {
|
|
129
|
+
const end = fragment.endsWith('\n');
|
|
130
|
+
if (!dropping) {
|
|
131
|
+
const fragmentBytes = Buffer.byteLength(fragment);
|
|
132
|
+
if (pendingBytes + fragmentBytes > maxRecord) {
|
|
133
|
+
pending = ''; pendingBytes = 0; dropping = true; onWarning('Oversized record omitted from projection; see raw output.');
|
|
134
|
+
} else { pending += fragment; pendingBytes += fragmentBytes; }
|
|
135
|
+
}
|
|
136
|
+
if (end) {
|
|
137
|
+
if (!dropping && pending.trim()) {
|
|
138
|
+
let event;
|
|
139
|
+
try { event = JSON.parse(pending); } catch { onWarning('Malformed record retained in raw output.'); }
|
|
140
|
+
if (event && typeof event === 'object') onEvent(event);
|
|
141
|
+
}
|
|
142
|
+
pending = ''; pendingBytes = 0; dropping = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
return { write: (chunk) => consume(decoder.write(chunk)), end: () => consume(decoder.end() + '\n') };
|
|
147
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Environment for one agy process. A pool account is a private HOME holding
|
|
2
|
+
* its own file-backed token; the SSH markers make agy store credentials in
|
|
3
|
+
* files instead of the OS keyring (which would be shared across HOMEs).
|
|
4
|
+
* `live` (home == null) is the user's own login and inherits the environment
|
|
5
|
+
* unchanged. */
|
|
6
|
+
export function accountEnv(home, base = process.env) {
|
|
7
|
+
if (!home) return base;
|
|
8
|
+
const env = { ...base, HOME: home };
|
|
9
|
+
if (process.platform === 'win32') env.USERPROFILE = home;
|
|
10
|
+
env.SSH_CONNECTION = '127.0.0.1 1 127.0.0.1 1';
|
|
11
|
+
env.SSH_CLIENT = '127.0.0.1 1 1';
|
|
12
|
+
env.SSH_TTY = 'agy-crew-account';
|
|
13
|
+
return env;
|
|
14
|
+
}
|