@phnx-labs/agents-cli 1.22.71 → 1.22.73
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/CHANGELOG.md +45 -0
- package/README.md +2 -0
- package/dist/cli/command-registry.d.ts +1 -1
- package/dist/cli/command-registry.js +2 -1
- package/dist/commands/packages-materialize.d.ts +17 -0
- package/dist/commands/packages-materialize.js +93 -0
- package/dist/commands/packages.d.ts +6 -4
- package/dist/commands/packages.js +8 -4
- package/dist/commands/repo.js +8 -8
- package/dist/commands/sessions-picker.js +38 -7
- package/dist/commands/sessions.js +6 -5
- package/dist/lib/actor.d.ts +36 -4
- package/dist/lib/actor.js +73 -9
- package/dist/lib/agent-spec/index.d.ts +4 -0
- package/dist/lib/agent-spec/index.js +5 -0
- package/dist/lib/agent-spec/materialize.d.ts +13 -0
- package/dist/lib/agent-spec/materialize.js +414 -0
- package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
- package/dist/lib/agent-spec/package-resolve.js +274 -0
- package/dist/lib/agent-spec/package-schema.d.ts +5 -0
- package/dist/lib/agent-spec/package-schema.js +147 -0
- package/dist/lib/agent-spec/package-types.d.ts +121 -0
- package/dist/lib/agent-spec/package-types.js +11 -0
- package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
- package/dist/lib/daemon/usage-sync-service.js +27 -5
- package/dist/lib/exec.js +3 -0
- package/dist/lib/fleet-shared-state.d.ts +30 -0
- package/dist/lib/fleet-shared-state.js +5 -0
- package/dist/lib/hooks/install.d.ts +31 -1
- package/dist/lib/hooks/install.js +44 -2
- package/dist/lib/mcp.d.ts +14 -2
- package/dist/lib/mcp.js +12 -2
- package/dist/lib/packages/output-home.d.ts +39 -0
- package/dist/lib/packages/output-home.js +203 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +26 -0
- package/dist/lib/project-resources.js +14 -5
- package/dist/lib/session/active.d.ts +12 -0
- package/dist/lib/session/active.js +5 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +60 -1
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/mirror.d.ts +67 -0
- package/dist/lib/session/mirror.js +158 -0
- package/dist/lib/session/types.d.ts +18 -0
- package/dist/lib/spinner.d.ts +39 -0
- package/dist/lib/spinner.js +41 -0
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical agent-package resolver (PHNX-3838).
|
|
3
|
+
*
|
|
4
|
+
* `resolveAgentPackage` is the ONE place that decides what a package's logical
|
|
5
|
+
* resources are: it validates the manifest, resolves every declared resource
|
|
6
|
+
* path against the package directory (failing closed on anything missing,
|
|
7
|
+
* malformed, or escaping the package root), hashes each resource
|
|
8
|
+
* deterministically, and applies the package's conflict-resolution rule —
|
|
9
|
+
* within one scope (portable, or one harness's overlay) a duplicate
|
|
10
|
+
* `(kind, name)` is a hard validation error; across scopes, an overlay
|
|
11
|
+
* resource deterministically replaces the portable resource of the same
|
|
12
|
+
* `(kind, name)` when a harness materializes it (`effectiveResources`).
|
|
13
|
+
*
|
|
14
|
+
* This module never writes to disk and never knows about any specific
|
|
15
|
+
* harness's native format — `materialize.ts` is the only consumer of
|
|
16
|
+
* `effectiveResources`, and it owns projection, not resolution.
|
|
17
|
+
*/
|
|
18
|
+
import * as crypto from 'crypto';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import * as yaml from 'yaml';
|
|
22
|
+
import { assertWithin, isSafeSegmentName } from '../paths.js';
|
|
23
|
+
import { loadAgentPackageManifest } from './package-schema.js';
|
|
24
|
+
import { AgentPackageError } from './package-types.js';
|
|
25
|
+
/**
|
|
26
|
+
* Reject a package source that is a symlink, or that resolves (through a
|
|
27
|
+
* symlinked ancestor) outside the package's real root. `assertWithin` above is a
|
|
28
|
+
* TEXTUAL check — `path.resolve` normalizes `..` but never follows links — so a
|
|
29
|
+
* `skills/evil -> /home/user/.ssh` symlink, or an `instructions.md ->
|
|
30
|
+
* /etc/passwd` symlink, passes it while the actual bytes read/copied come from
|
|
31
|
+
* OUTSIDE the package. This is the containment check that makes the copy safe.
|
|
32
|
+
* A missing source is left for the `requireFile`/`requireDir` caller to report.
|
|
33
|
+
*/
|
|
34
|
+
function assertRealSourceWithin(abs, packageReal, label) {
|
|
35
|
+
let lst;
|
|
36
|
+
try {
|
|
37
|
+
lst = fs.lstatSync(abs);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return; // does not exist — requireFile/requireDir reports the specific error
|
|
41
|
+
}
|
|
42
|
+
if (lst.isSymbolicLink()) {
|
|
43
|
+
throw new AgentPackageError(`${label}: '${abs}' is a symlink; symlinked package sources are not allowed`, 'path-escape');
|
|
44
|
+
}
|
|
45
|
+
let real;
|
|
46
|
+
try {
|
|
47
|
+
real = fs.realpathSync(abs);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (real !== packageReal && !real.startsWith(packageReal + path.sep)) {
|
|
53
|
+
throw new AgentPackageError(`${label}: '${abs}' resolves outside the package directory`, 'path-escape');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function resolveWithin(containRoot, relPath, label, packageReal) {
|
|
57
|
+
const abs = path.resolve(containRoot, relPath);
|
|
58
|
+
try {
|
|
59
|
+
assertWithin(containRoot, abs);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new AgentPackageError(`${label}: '${relPath}' escapes the package directory`, 'path-escape');
|
|
63
|
+
}
|
|
64
|
+
assertRealSourceWithin(abs, packageReal, label);
|
|
65
|
+
return abs;
|
|
66
|
+
}
|
|
67
|
+
function sha256OfBytes(data) {
|
|
68
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
/** Deterministic combined hash of every file under `dir`, sorted by relative path. */
|
|
71
|
+
function sha256OfDir(dir) {
|
|
72
|
+
const files = [];
|
|
73
|
+
const walk = (base) => {
|
|
74
|
+
for (const entry of fs.readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
75
|
+
if (entry.isSymbolicLink())
|
|
76
|
+
continue;
|
|
77
|
+
const full = path.join(base, entry.name);
|
|
78
|
+
if (entry.isDirectory())
|
|
79
|
+
walk(full);
|
|
80
|
+
else if (entry.isFile())
|
|
81
|
+
files.push(full);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
walk(dir);
|
|
85
|
+
const hash = crypto.createHash('sha256');
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const rel = path.relative(dir, file).split(path.sep).join('/');
|
|
88
|
+
hash.update(rel).update('\0').update(fs.readFileSync(file)).update('\n');
|
|
89
|
+
}
|
|
90
|
+
return hash.digest('hex');
|
|
91
|
+
}
|
|
92
|
+
function requireFile(absPath, label) {
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = fs.statSync(absPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new AgentPackageError(`${label}: no such file '${absPath}'`, 'invalid-resource');
|
|
99
|
+
}
|
|
100
|
+
if (!stat.isFile()) {
|
|
101
|
+
throw new AgentPackageError(`${label}: '${absPath}' is not a file`, 'invalid-resource');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function requireDir(absPath, label) {
|
|
105
|
+
let stat;
|
|
106
|
+
try {
|
|
107
|
+
stat = fs.statSync(absPath);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new AgentPackageError(`${label}: no such directory '${absPath}'`, 'invalid-resource');
|
|
111
|
+
}
|
|
112
|
+
if (!stat.isDirectory()) {
|
|
113
|
+
throw new AgentPackageError(`${label}: '${absPath}' is not a directory`, 'invalid-resource');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function parseYamlFile(absPath, label) {
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = fs.readFileSync(absPath, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
throw new AgentPackageError(`${label}: cannot read '${absPath}' — ${err.message}`, 'invalid-resource');
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return yaml.parse(raw);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw new AgentPackageError(`${label}: malformed YAML in '${absPath}' — ${err.message}`, 'invalid-resource');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function resolveInstructions(packageDir, relPath, provenance, label, packageReal) {
|
|
132
|
+
const abs = resolveWithin(packageDir, relPath, label, packageReal);
|
|
133
|
+
requireFile(abs, label);
|
|
134
|
+
return { kind: 'instructions', name: 'instructions', sourcePath: abs, sha256: sha256OfBytes(fs.readFileSync(abs)), provenance };
|
|
135
|
+
}
|
|
136
|
+
function resolveDirResource(packageDir, relPath, kind, markerFile, provenance, label, packageReal) {
|
|
137
|
+
const abs = resolveWithin(packageDir, relPath, label, packageReal);
|
|
138
|
+
requireDir(abs, label);
|
|
139
|
+
requireFile(path.join(abs, markerFile), `${label} (missing ${markerFile})`);
|
|
140
|
+
return { kind, name: path.basename(abs), sourcePath: abs, sha256: sha256OfDir(abs), provenance };
|
|
141
|
+
}
|
|
142
|
+
function resolveMcpResource(packageDir, relPath, provenance, label, packageReal) {
|
|
143
|
+
const abs = resolveWithin(packageDir, relPath, label, packageReal);
|
|
144
|
+
requireFile(abs, label);
|
|
145
|
+
const server = parseYamlFile(abs, label);
|
|
146
|
+
if (!server || typeof server.name !== 'string' || server.name.length === 0) {
|
|
147
|
+
throw new AgentPackageError(`${label}: '${relPath}' must declare a 'name'`, 'invalid-resource');
|
|
148
|
+
}
|
|
149
|
+
if (server.transport !== 'stdio' && server.transport !== 'http' && server.transport !== 'sse') {
|
|
150
|
+
throw new AgentPackageError(`${label}: '${relPath}' has an invalid transport (${JSON.stringify(server.transport)})`, 'invalid-resource');
|
|
151
|
+
}
|
|
152
|
+
if (server.transport === 'stdio' && !server.command) {
|
|
153
|
+
throw new AgentPackageError(`${label}: '${relPath}' declares transport 'stdio' but no 'command'`, 'invalid-resource');
|
|
154
|
+
}
|
|
155
|
+
if ((server.transport === 'http' || server.transport === 'sse') && !server.url) {
|
|
156
|
+
throw new AgentPackageError(`${label}: '${relPath}' declares transport '${server.transport}' but no 'url'`, 'invalid-resource');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
kind: 'mcp',
|
|
160
|
+
name: server.name,
|
|
161
|
+
sourcePath: abs,
|
|
162
|
+
sha256: sha256OfBytes(fs.readFileSync(abs)),
|
|
163
|
+
provenance,
|
|
164
|
+
mcp: server,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function resolveHookResource(packageDir, relPath, provenance, label, packageReal) {
|
|
168
|
+
const abs = resolveWithin(packageDir, relPath, label, packageReal);
|
|
169
|
+
requireFile(abs, label);
|
|
170
|
+
const hook = parseYamlFile(abs, label);
|
|
171
|
+
if (!hook || typeof hook.name !== 'string' || hook.name.length === 0) {
|
|
172
|
+
throw new AgentPackageError(`${label}: '${relPath}' must declare a 'name'`, 'invalid-resource');
|
|
173
|
+
}
|
|
174
|
+
// The hook name becomes a filename in the materialized home, so a traversing
|
|
175
|
+
// name ('../../foo') would write a hook script outside the output home. Reject
|
|
176
|
+
// anything but a single safe path segment at the source.
|
|
177
|
+
if (!isSafeSegmentName(hook.name)) {
|
|
178
|
+
throw new AgentPackageError(`${label}: hook name '${hook.name}' is not a safe single path segment`, 'invalid-resource');
|
|
179
|
+
}
|
|
180
|
+
if (typeof hook.script !== 'string' || hook.script.length === 0) {
|
|
181
|
+
throw new AgentPackageError(`${label}: '${relPath}' must declare a 'script'`, 'invalid-resource');
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(hook.events) || hook.events.length === 0 || hook.events.some((e) => typeof e !== 'string')) {
|
|
184
|
+
throw new AgentPackageError(`${label}: '${relPath}' must declare a non-empty 'events' list of strings`, 'invalid-resource');
|
|
185
|
+
}
|
|
186
|
+
const scriptPath = resolveWithin(path.dirname(abs), hook.script, label, packageReal);
|
|
187
|
+
requireFile(scriptPath, `${label} (hook script)`);
|
|
188
|
+
return {
|
|
189
|
+
kind: 'hooks',
|
|
190
|
+
name: hook.name,
|
|
191
|
+
sourcePath: abs,
|
|
192
|
+
sha256: sha256OfBytes(Buffer.concat([fs.readFileSync(abs), Buffer.from('\0'), fs.readFileSync(scriptPath)])),
|
|
193
|
+
provenance,
|
|
194
|
+
hook: { def: hook, scriptPath },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function assertNoDuplicates(resources, scopeLabel) {
|
|
198
|
+
const seen = new Map();
|
|
199
|
+
for (const r of resources) {
|
|
200
|
+
const key = `${r.kind}:${r.name}`;
|
|
201
|
+
const prior = seen.get(key);
|
|
202
|
+
if (prior) {
|
|
203
|
+
throw new AgentPackageError(`${scopeLabel}: duplicate ${r.kind} '${r.name}' declared by both '${prior.sourcePath}' and '${r.sourcePath}'`, 'duplicate-resource');
|
|
204
|
+
}
|
|
205
|
+
seen.set(key, r);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function resolveScope(packageDir, paths, provenance, scopeLabel, packageReal) {
|
|
209
|
+
const resources = [];
|
|
210
|
+
if (paths.instructions)
|
|
211
|
+
resources.push(resolveInstructions(packageDir, paths.instructions, provenance, scopeLabel, packageReal));
|
|
212
|
+
for (const p of paths.skills)
|
|
213
|
+
resources.push(resolveDirResource(packageDir, p, 'skills', 'SKILL.md', provenance, scopeLabel, packageReal));
|
|
214
|
+
for (const p of paths.subagents)
|
|
215
|
+
resources.push(resolveDirResource(packageDir, p, 'subagents', 'AGENT.md', provenance, scopeLabel, packageReal));
|
|
216
|
+
for (const p of paths.mcp)
|
|
217
|
+
resources.push(resolveMcpResource(packageDir, p, provenance, scopeLabel, packageReal));
|
|
218
|
+
for (const p of paths.hooks)
|
|
219
|
+
resources.push(resolveHookResource(packageDir, p, provenance, scopeLabel, packageReal));
|
|
220
|
+
// Sort deterministically — resolution order in agent.yaml must not affect output.
|
|
221
|
+
resources.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind.localeCompare(b.kind)));
|
|
222
|
+
assertNoDuplicates(resources, scopeLabel);
|
|
223
|
+
return resources;
|
|
224
|
+
}
|
|
225
|
+
/** Deterministic package identity digest — independent of which harness later materializes it. */
|
|
226
|
+
function computeDigest(portable, overlays) {
|
|
227
|
+
const hash = crypto.createHash('sha256');
|
|
228
|
+
const record = (label, resources) => {
|
|
229
|
+
for (const r of resources)
|
|
230
|
+
hash.update(`${label}\0${r.kind}\0${r.name}\0${r.sha256}\n`);
|
|
231
|
+
};
|
|
232
|
+
record('portable', portable);
|
|
233
|
+
for (const agent of Object.keys(overlays).sort())
|
|
234
|
+
record(`overlay:${agent}`, overlays[agent] ?? []);
|
|
235
|
+
return hash.digest('hex');
|
|
236
|
+
}
|
|
237
|
+
/** Parse, validate, and resolve every declared resource of a package directory into one canonical result. */
|
|
238
|
+
export function resolveAgentPackage(packageDir) {
|
|
239
|
+
const manifest = loadAgentPackageManifest(packageDir);
|
|
240
|
+
const ex = manifest.execution;
|
|
241
|
+
// The package's REAL root — every resource source must realpath-resolve under
|
|
242
|
+
// this, so a symlinked ancestor can't smuggle in bytes from outside. The
|
|
243
|
+
// manifest already loaded from packageDir, so it exists and realpath succeeds.
|
|
244
|
+
const packageReal = fs.realpathSync(path.resolve(packageDir));
|
|
245
|
+
const portable = resolveScope(packageDir, { instructions: ex.instructions, skills: ex.skills, subagents: ex.subagents, mcp: ex.mcp, hooks: ex.hooks }, 'portable', 'execution', packageReal);
|
|
246
|
+
const overlays = {};
|
|
247
|
+
for (const [agent, overlay] of Object.entries(ex.harnessOverlays)) {
|
|
248
|
+
overlays[agent] = resolveScope(packageDir, { instructions: overlay.instructions, skills: overlay.skills ?? [], subagents: overlay.subagents ?? [], mcp: overlay.mcp ?? [], hooks: overlay.hooks ?? [] }, 'overlay', `execution.harness_overlays.${agent}`, packageReal);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
manifest,
|
|
252
|
+
packageDir: path.resolve(packageDir),
|
|
253
|
+
digest: computeDigest(portable, overlays),
|
|
254
|
+
portable,
|
|
255
|
+
overlays,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The resource set a specific harness actually materializes: portable
|
|
260
|
+
* resources, with any harness-overlay resource of the same `(kind, name)`
|
|
261
|
+
* deterministically replacing its portable counterpart, plus overlay-only
|
|
262
|
+
* additions. This is the ONE merge rule every harness adapter shares —
|
|
263
|
+
* `materialize.ts` calls this instead of re-deriving precedence per harness.
|
|
264
|
+
*/
|
|
265
|
+
export function effectiveResources(resolved, harness) {
|
|
266
|
+
const overlay = resolved.overlays[harness] ?? [];
|
|
267
|
+
const overlayKeys = new Set(overlay.map((r) => `${r.kind}:${r.name}`));
|
|
268
|
+
const merged = [
|
|
269
|
+
...resolved.portable.filter((r) => !overlayKeys.has(`${r.kind}:${r.name}`)),
|
|
270
|
+
...overlay,
|
|
271
|
+
];
|
|
272
|
+
merged.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind.localeCompare(b.kind)));
|
|
273
|
+
return merged;
|
|
274
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentPackageManifest } from './package-types.js';
|
|
2
|
+
/** Parse and shape-validate the `execution` block of a schema-v3 agent.yaml already read into memory. */
|
|
3
|
+
export declare function parseAgentPackageManifest(raw: unknown, sourceLabel: string): AgentPackageManifest;
|
|
4
|
+
/** Read + parse `<packageDir>/agent.yaml`. Fails closed on missing file or malformed YAML. */
|
|
5
|
+
export declare function loadAgentPackageManifest(packageDir: string): AgentPackageManifest;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-v3 `execution` block parsing for a portable agent package (agent.yaml).
|
|
3
|
+
*
|
|
4
|
+
* Pure and fs-free beyond reading the manifest file itself — never resolves or
|
|
5
|
+
* hashes referenced resources (that's `package-resolve.ts`). Fails closed on
|
|
6
|
+
* anything malformed: this is the "malformed shared config fails closed"
|
|
7
|
+
* boundary from the PHNX-3838 brief, so a bad manifest throws immediately
|
|
8
|
+
* rather than materializing a partial package.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as yaml from 'yaml';
|
|
13
|
+
import { ALL_AGENT_IDS } from './agents.js';
|
|
14
|
+
import { AgentPackageError } from './package-types.js';
|
|
15
|
+
const AGENT_ID_SET = new Set(ALL_AGENT_IDS);
|
|
16
|
+
function isAgentId(value) {
|
|
17
|
+
return typeof value === 'string' && AGENT_ID_SET.has(value);
|
|
18
|
+
}
|
|
19
|
+
function fail(message, details) {
|
|
20
|
+
throw new AgentPackageError(message, 'invalid-manifest', details);
|
|
21
|
+
}
|
|
22
|
+
function stringArray(value, field) {
|
|
23
|
+
if (value === undefined || value === null)
|
|
24
|
+
return [];
|
|
25
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
26
|
+
fail(`agent.yaml: '${field}' must be a list of strings`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function parseHarnessOverlay(raw, agent) {
|
|
31
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
32
|
+
fail(`agent.yaml: execution.harness_overlays.${agent} must be a mapping`);
|
|
33
|
+
}
|
|
34
|
+
const r = raw;
|
|
35
|
+
if (r.instructions !== undefined && typeof r.instructions !== 'string') {
|
|
36
|
+
fail(`agent.yaml: execution.harness_overlays.${agent}.instructions must be a string`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
instructions: r.instructions,
|
|
40
|
+
skills: stringArray(r.skills, `execution.harness_overlays.${agent}.skills`),
|
|
41
|
+
subagents: stringArray(r.subagents, `execution.harness_overlays.${agent}.subagents`),
|
|
42
|
+
mcp: stringArray(r.mcp, `execution.harness_overlays.${agent}.mcp`),
|
|
43
|
+
hooks: stringArray(r.hooks, `execution.harness_overlays.${agent}.hooks`),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Parse and shape-validate the `execution` block of a schema-v3 agent.yaml already read into memory. */
|
|
47
|
+
export function parseAgentPackageManifest(raw, sourceLabel) {
|
|
48
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
49
|
+
fail(`${sourceLabel}: expected a YAML mapping at the document root`);
|
|
50
|
+
}
|
|
51
|
+
const doc = raw;
|
|
52
|
+
if (doc.schema_version !== 3) {
|
|
53
|
+
fail(`${sourceLabel}: schema_version must be 3 (got ${JSON.stringify(doc.schema_version)}) — this resolver only understands schema v3 execution blocks`);
|
|
54
|
+
}
|
|
55
|
+
if ('http_tools' in doc) {
|
|
56
|
+
fail(`${sourceLabel}: 'http_tools' is forbidden at schema v3 — use execution.mcp instead`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof doc.name !== 'string' || doc.name.length === 0) {
|
|
59
|
+
fail(`${sourceLabel}: 'name' is required and must be a non-empty string`);
|
|
60
|
+
}
|
|
61
|
+
if (typeof doc.slug !== 'string' || doc.slug.length === 0) {
|
|
62
|
+
fail(`${sourceLabel}: 'slug' is required and must be a non-empty string`);
|
|
63
|
+
}
|
|
64
|
+
if (doc.description !== undefined && typeof doc.description !== 'string') {
|
|
65
|
+
fail(`${sourceLabel}: 'description' must be a string`);
|
|
66
|
+
}
|
|
67
|
+
const execution = doc.execution;
|
|
68
|
+
if (execution === null || typeof execution !== 'object' || Array.isArray(execution)) {
|
|
69
|
+
fail(`${sourceLabel}: 'execution' block is required`);
|
|
70
|
+
}
|
|
71
|
+
const ex = execution;
|
|
72
|
+
const mode = ex.mode;
|
|
73
|
+
if (mode !== 'cloud' && mode !== 'local') {
|
|
74
|
+
fail(`${sourceLabel}: execution.mode must be 'cloud' or 'local' (got ${JSON.stringify(mode)})`);
|
|
75
|
+
}
|
|
76
|
+
const harnesses = ex.harnesses;
|
|
77
|
+
if (harnesses === null || typeof harnesses !== 'object' || Array.isArray(harnesses)) {
|
|
78
|
+
fail(`${sourceLabel}: execution.harnesses is required`);
|
|
79
|
+
}
|
|
80
|
+
const h = harnesses;
|
|
81
|
+
if (!isAgentId(h.default)) {
|
|
82
|
+
fail(`${sourceLabel}: execution.harnesses.default must name a known agent id (got ${JSON.stringify(h.default)})`);
|
|
83
|
+
}
|
|
84
|
+
const supportedRaw = stringArray(h.supported, 'execution.harnesses.supported');
|
|
85
|
+
if (supportedRaw.length === 0) {
|
|
86
|
+
fail(`${sourceLabel}: execution.harnesses.supported must list at least one agent id`);
|
|
87
|
+
}
|
|
88
|
+
const badIds = supportedRaw.filter((id) => !isAgentId(id));
|
|
89
|
+
if (badIds.length > 0) {
|
|
90
|
+
fail(`${sourceLabel}: execution.harnesses.supported names unknown agent id(s): ${badIds.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
const supported = supportedRaw;
|
|
93
|
+
if (!supported.includes(h.default)) {
|
|
94
|
+
fail(`${sourceLabel}: execution.harnesses.default (${String(h.default)}) must also appear in execution.harnesses.supported`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof ex.instructions !== 'string' || ex.instructions.length === 0) {
|
|
97
|
+
fail(`${sourceLabel}: execution.instructions is required and must be a non-empty relative path`);
|
|
98
|
+
}
|
|
99
|
+
const harnessOverlaysRaw = ex.harness_overlays;
|
|
100
|
+
const harnessOverlays = {};
|
|
101
|
+
if (harnessOverlaysRaw !== undefined) {
|
|
102
|
+
if (harnessOverlaysRaw === null || typeof harnessOverlaysRaw !== 'object' || Array.isArray(harnessOverlaysRaw)) {
|
|
103
|
+
fail(`${sourceLabel}: execution.harness_overlays must be a mapping`);
|
|
104
|
+
}
|
|
105
|
+
for (const [agent, overlay] of Object.entries(harnessOverlaysRaw)) {
|
|
106
|
+
if (!isAgentId(agent)) {
|
|
107
|
+
fail(`${sourceLabel}: execution.harness_overlays names unknown agent id: ${agent}`);
|
|
108
|
+
}
|
|
109
|
+
harnessOverlays[agent] = parseHarnessOverlay(overlay, agent);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: 3,
|
|
114
|
+
name: doc.name,
|
|
115
|
+
slug: doc.slug,
|
|
116
|
+
description: doc.description,
|
|
117
|
+
execution: {
|
|
118
|
+
mode,
|
|
119
|
+
harnesses: { default: h.default, supported },
|
|
120
|
+
instructions: ex.instructions,
|
|
121
|
+
skills: stringArray(ex.skills, 'execution.skills'),
|
|
122
|
+
subagents: stringArray(ex.subagents, 'execution.subagents'),
|
|
123
|
+
mcp: stringArray(ex.mcp, 'execution.mcp'),
|
|
124
|
+
hooks: stringArray(ex.hooks, 'execution.hooks'),
|
|
125
|
+
harnessOverlays,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Read + parse `<packageDir>/agent.yaml`. Fails closed on missing file or malformed YAML. */
|
|
130
|
+
export function loadAgentPackageManifest(packageDir) {
|
|
131
|
+
const manifestPath = path.join(packageDir, 'agent.yaml');
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new AgentPackageError(`agent.yaml not found in ${packageDir}`, 'invalid-manifest');
|
|
138
|
+
}
|
|
139
|
+
let doc;
|
|
140
|
+
try {
|
|
141
|
+
doc = yaml.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
throw new AgentPackageError(`${manifestPath}: malformed YAML — ${err.message}`, 'invalid-manifest');
|
|
145
|
+
}
|
|
146
|
+
return parseAgentPackageManifest(doc, manifestPath);
|
|
147
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the portable agent-package resolver + native-home materializer
|
|
3
|
+
* (PHNX-3838). A package is a filesystem `agent.yaml` (schema v3 `execution`
|
|
4
|
+
* block) describing behavior — instructions, skills, subagents, mcp, hooks —
|
|
5
|
+
* that `resolveAgentPackage` reduces to ONE canonical resource set, which
|
|
6
|
+
* `materializeAgentPackage` then projects into a native Claude Code, Codex, or
|
|
7
|
+
* OpenCode home. See `.agents/plans/phnx-3827-portable-agent-cloud/plan.md`
|
|
8
|
+
* (PR muqsitnawaz/agents#2055) for the source design.
|
|
9
|
+
*/
|
|
10
|
+
import type { AgentId } from '../types.js';
|
|
11
|
+
export type PackageResourceKind = 'instructions' | 'skills' | 'subagents' | 'mcp' | 'hooks';
|
|
12
|
+
/** Where a resolved resource came from — the provenance a conflict resolution decision needs. */
|
|
13
|
+
export type ResourceProvenance = 'portable' | 'overlay';
|
|
14
|
+
/** Thrown on any bad package or unsatisfiable materialization request. Never `process.exit`. */
|
|
15
|
+
export declare class AgentPackageError extends Error {
|
|
16
|
+
readonly code: 'invalid-manifest' | 'invalid-resource' | 'duplicate-resource' | 'path-escape' | 'unsupported-harness' | 'unsupported-capability';
|
|
17
|
+
readonly details?: string[] | undefined;
|
|
18
|
+
constructor(message: string, code: 'invalid-manifest' | 'invalid-resource' | 'duplicate-resource' | 'path-escape' | 'unsupported-harness' | 'unsupported-capability', details?: string[] | undefined);
|
|
19
|
+
}
|
|
20
|
+
/** One MCP server declared by a package's `mcp/*.yaml` resource file. */
|
|
21
|
+
export interface PackageMcpServer {
|
|
22
|
+
name: string;
|
|
23
|
+
transport: 'stdio' | 'http' | 'sse';
|
|
24
|
+
command?: string;
|
|
25
|
+
args?: string[];
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
url?: string;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/** One lifecycle hook declared by a package's `hooks/*.yaml` resource file. */
|
|
31
|
+
export interface PackageHook {
|
|
32
|
+
name: string;
|
|
33
|
+
/** Path to the hook's script, relative to the hook manifest's own directory. */
|
|
34
|
+
script: string;
|
|
35
|
+
events: string[];
|
|
36
|
+
matcher?: string;
|
|
37
|
+
timeout?: number;
|
|
38
|
+
}
|
|
39
|
+
/** A harness-scoped overlay: the same resource-directory shape as the package root. */
|
|
40
|
+
export interface PackageHarnessOverlay {
|
|
41
|
+
instructions?: string;
|
|
42
|
+
skills?: string[];
|
|
43
|
+
subagents?: string[];
|
|
44
|
+
mcp?: string[];
|
|
45
|
+
hooks?: string[];
|
|
46
|
+
}
|
|
47
|
+
/** Parsed + shape-validated `execution` block of a schema-v3 `agent.yaml`. */
|
|
48
|
+
export interface AgentPackageManifest {
|
|
49
|
+
schemaVersion: 3;
|
|
50
|
+
name: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
execution: {
|
|
54
|
+
mode: 'cloud' | 'local';
|
|
55
|
+
harnesses: {
|
|
56
|
+
default: AgentId;
|
|
57
|
+
supported: AgentId[];
|
|
58
|
+
};
|
|
59
|
+
instructions: string;
|
|
60
|
+
skills: string[];
|
|
61
|
+
subagents: string[];
|
|
62
|
+
mcp: string[];
|
|
63
|
+
hooks: string[];
|
|
64
|
+
harnessOverlays: Partial<Record<AgentId, PackageHarnessOverlay>>;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** One resource in the canonical, resolved package — before harness projection. */
|
|
68
|
+
export interface ResolvedResource {
|
|
69
|
+
kind: PackageResourceKind;
|
|
70
|
+
/** Stable resource name within its kind (skill/subagent/mcp-server/hook name, or 'instructions'). */
|
|
71
|
+
name: string;
|
|
72
|
+
/** Absolute path to the resource's source — a file for instructions/mcp/hooks, a dir for skills/subagents. */
|
|
73
|
+
sourcePath: string;
|
|
74
|
+
/** Deterministic content hash — a single file's sha256, or a directory's combined sha256. */
|
|
75
|
+
sha256: string;
|
|
76
|
+
provenance: ResourceProvenance;
|
|
77
|
+
/** Parsed definition, kind === 'mcp' only. */
|
|
78
|
+
mcp?: PackageMcpServer;
|
|
79
|
+
/** Parsed definition + resolved script path, kind === 'hooks' only. */
|
|
80
|
+
hook?: {
|
|
81
|
+
def: PackageHook;
|
|
82
|
+
scriptPath: string;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** The one canonical resolution of a package: portable resources plus each harness's overlay. */
|
|
86
|
+
export interface ResolvedAgentPackage {
|
|
87
|
+
manifest: AgentPackageManifest;
|
|
88
|
+
packageDir: string;
|
|
89
|
+
/** Deterministic digest over every declared resource (portable + all overlays), independent of harness. */
|
|
90
|
+
digest: string;
|
|
91
|
+
portable: ResolvedResource[];
|
|
92
|
+
overlays: Partial<Record<AgentId, ResolvedResource[]>>;
|
|
93
|
+
}
|
|
94
|
+
export interface MaterializationReceiptEntry {
|
|
95
|
+
kind: PackageResourceKind;
|
|
96
|
+
name: string;
|
|
97
|
+
/** Path the materializer wrote, relative to the output home. */
|
|
98
|
+
target: string;
|
|
99
|
+
sha256: string;
|
|
100
|
+
provenance: ResourceProvenance;
|
|
101
|
+
}
|
|
102
|
+
export interface MaterializationReceipt {
|
|
103
|
+
schemaVersion: 1;
|
|
104
|
+
agent: {
|
|
105
|
+
ref: string;
|
|
106
|
+
digest: string;
|
|
107
|
+
};
|
|
108
|
+
harness: {
|
|
109
|
+
id: AgentId;
|
|
110
|
+
version: string;
|
|
111
|
+
};
|
|
112
|
+
resources: MaterializationReceiptEntry[];
|
|
113
|
+
warnings: string[];
|
|
114
|
+
}
|
|
115
|
+
export interface MaterializeOptions {
|
|
116
|
+
harness: AgentId;
|
|
117
|
+
/** Harness version — gates per-kind capability support (e.g. codex hooks need >= 0.116.0). */
|
|
118
|
+
harnessVersion: string;
|
|
119
|
+
/** Absolute path to the fresh, isolated home to materialize into. Created if missing. */
|
|
120
|
+
outputHome: string;
|
|
121
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Thrown on any bad package or unsatisfiable materialization request. Never `process.exit`. */
|
|
2
|
+
export class AgentPackageError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(message, code, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
this.name = 'AgentPackageError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet
|
|
2
|
+
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
|
+
* PHNX-3792 session mirror).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* This is the one tick that owns the bounded Git exchange over the fleet-synced
|
|
6
|
+
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
|
+
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
|
+
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
+
* usage snapshot, and EVERY box's lightweight session digests (PHNX-3792);
|
|
10
|
+
* (2) runs one serialized, timeout-bounded commit/rebase/push; (3) consumes the
|
|
11
|
+
* peer fields the exchange delivered — a worker merges usage newest-wins, and
|
|
12
|
+
* every non-worker box folds peers' session digests into its local index so the
|
|
13
|
+
* picker renders remote-host previews inline. No tick opens a device-to-device
|
|
14
|
+
* SSH mesh.
|
|
8
15
|
*/
|
|
9
16
|
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
10
17
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fleet
|
|
2
|
+
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
|
+
* PHNX-3792 session mirror).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* This is the one tick that owns the bounded Git exchange over the fleet-synced
|
|
6
|
+
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
|
+
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
|
+
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
+
* usage snapshot, and EVERY box's lightweight session digests (PHNX-3792);
|
|
10
|
+
* (2) runs one serialized, timeout-bounded commit/rebase/push; (3) consumes the
|
|
11
|
+
* peer fields the exchange delivered — a worker merges usage newest-wins, and
|
|
12
|
+
* every non-worker box folds peers' session digests into its local index so the
|
|
13
|
+
* picker renders remote-host previews inline. No tick opens a device-to-device
|
|
14
|
+
* SSH mesh.
|
|
8
15
|
*/
|
|
9
16
|
import { BasePeriodicService } from './service.js';
|
|
10
17
|
const USAGE_SYNC_TICK_MS = 15 * 60_000;
|
|
@@ -23,11 +30,18 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
23
30
|
}
|
|
24
31
|
async onTick(ctx) {
|
|
25
32
|
const { consumeUsageSnapshotsFromSharedStore, publishUsageSnapshotToSharedStore } = await import('../accounting/usage-sync.js');
|
|
33
|
+
const { consumeSessionMirrorFromSharedStore, publishSessionMirrorToSharedStore } = await import('../session/mirror.js');
|
|
34
|
+
// Publish every owned field BEFORE the single git exchange so they ride one commit.
|
|
26
35
|
const published = await publishUsageSnapshotToSharedStore();
|
|
27
36
|
if (published.changed)
|
|
28
37
|
ctx.log('INFO', `usage-sync: published usage snapshot to ${published.path}`);
|
|
29
38
|
if (published.error)
|
|
30
39
|
ctx.log('WARN', `usage-sync: publish: ${published.error}`);
|
|
40
|
+
const mirrored = await publishSessionMirrorToSharedStore();
|
|
41
|
+
if (mirrored.changed)
|
|
42
|
+
ctx.log('INFO', `session-mirror: published ${mirrored.count} session digest(s)`);
|
|
43
|
+
if (mirrored.error)
|
|
44
|
+
ctx.log('WARN', `session-mirror: publish: ${mirrored.error}`);
|
|
31
45
|
const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
|
|
32
46
|
const transport = await syncFleetSharedStateRepo();
|
|
33
47
|
if (transport.skipped)
|
|
@@ -42,5 +56,13 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
42
56
|
}
|
|
43
57
|
for (const err of consumed.errors)
|
|
44
58
|
ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
|
|
59
|
+
const foldedIn = consumeSessionMirrorFromSharedStore();
|
|
60
|
+
if (foldedIn.merged > 0) {
|
|
61
|
+
ctx.log('INFO', `session-mirror: folded ${foldedIn.merged} session(s) from ${foldedIn.sources.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
if (foldedIn.pruned > 0)
|
|
64
|
+
ctx.log('INFO', `session-mirror: pruned ${foldedIn.pruned} stale mirror row(s)`);
|
|
65
|
+
for (const err of foldedIn.errors)
|
|
66
|
+
ctx.log('WARN', `session-mirror: ${err.device}: ${err.message}`);
|
|
45
67
|
}
|
|
46
68
|
}
|
package/dist/lib/exec.js
CHANGED
|
@@ -1166,6 +1166,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
1166
1166
|
sessionId: passthroughSessionId,
|
|
1167
1167
|
actor: resolveActor().id,
|
|
1168
1168
|
initiatedBy: resolveActor().kind,
|
|
1169
|
+
phoenixId: resolveActor().phoenixId,
|
|
1169
1170
|
startedAtMs: Date.now(),
|
|
1170
1171
|
});
|
|
1171
1172
|
}
|
|
@@ -1564,6 +1565,7 @@ async function runInTmux(options, executable, args) {
|
|
|
1564
1565
|
sessionId: options.sessionId,
|
|
1565
1566
|
actor: resolveActor().id,
|
|
1566
1567
|
initiatedBy: resolveActor().kind,
|
|
1568
|
+
phoenixId: resolveActor().phoenixId,
|
|
1567
1569
|
harness: customHarnessName(options),
|
|
1568
1570
|
startedAtMs: Date.now(),
|
|
1569
1571
|
});
|
|
@@ -1899,6 +1901,7 @@ async function spawnAgent(options) {
|
|
|
1899
1901
|
sessionId: options.sessionId,
|
|
1900
1902
|
actor: resolveActor().id,
|
|
1901
1903
|
initiatedBy: resolveActor().kind,
|
|
1904
|
+
phoenixId: resolveActor().phoenixId,
|
|
1902
1905
|
harness: customHarnessName(options),
|
|
1903
1906
|
startedAtMs: Date.now(),
|
|
1904
1907
|
});
|