@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.js +6 -5
  12. package/dist/lib/actor.d.ts +36 -4
  13. package/dist/lib/actor.js +73 -9
  14. package/dist/lib/agent-spec/index.d.ts +4 -0
  15. package/dist/lib/agent-spec/index.js +5 -0
  16. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  17. package/dist/lib/agent-spec/materialize.js +414 -0
  18. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  19. package/dist/lib/agent-spec/package-resolve.js +274 -0
  20. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  21. package/dist/lib/agent-spec/package-schema.js +147 -0
  22. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  23. package/dist/lib/agent-spec/package-types.js +11 -0
  24. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  25. package/dist/lib/daemon/usage-sync-service.js +27 -5
  26. package/dist/lib/exec.js +3 -0
  27. package/dist/lib/fleet-shared-state.d.ts +30 -0
  28. package/dist/lib/fleet-shared-state.js +5 -0
  29. package/dist/lib/hooks/install.d.ts +31 -1
  30. package/dist/lib/hooks/install.js +44 -2
  31. package/dist/lib/mcp.d.ts +14 -2
  32. package/dist/lib/mcp.js +12 -2
  33. package/dist/lib/packages/output-home.d.ts +39 -0
  34. package/dist/lib/packages/output-home.js +203 -0
  35. package/dist/lib/paths.d.ts +9 -0
  36. package/dist/lib/paths.js +26 -0
  37. package/dist/lib/project-resources.js +14 -5
  38. package/dist/lib/session/active.d.ts +12 -0
  39. package/dist/lib/session/active.js +5 -1
  40. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  41. package/dist/lib/session/actor-sidecar.js +2 -0
  42. package/dist/lib/session/db.d.ts +60 -1
  43. package/dist/lib/session/db.js +191 -6
  44. package/dist/lib/session/mirror.d.ts +67 -0
  45. package/dist/lib/session/mirror.js +158 -0
  46. package/dist/lib/session/types.d.ts +18 -0
  47. package/dist/lib/spinner.d.ts +39 -0
  48. package/dist/lib/spinner.js +41 -0
  49. package/dist/lib/startup/command-registry.js +1 -1
  50. package/dist/lib/types.d.ts +6 -0
  51. 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 usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
2
+ * Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
3
+ * PHNX-3792 session mirror).
3
4
  *
4
- * A headed box publishes its local identity-keyed Claude usage rows into its
5
- * owned file in the fleet-synced user repo. The tick then runs one serialized,
6
- * timeout-bounded git exchange; a worker reads the delivered peer snapshots
7
- * and merges newest-wins. No tick opens a device-to-device SSH mesh.
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 usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
2
+ * Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
3
+ * PHNX-3792 session mirror).
3
4
  *
4
- * A headed box publishes its local identity-keyed Claude usage rows into its
5
- * owned file in the fleet-synced user repo. The tick then runs one serialized,
6
- * timeout-bounded git exchange; a worker reads the delivered peer snapshots
7
- * and merges newest-wins. No tick opens a device-to-device SSH mesh.
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
  });