diff --git a/packages/cli/README.md b/packages/cli/README.md
index 87ed4e0e..adf4bd69 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -379,7 +379,9 @@ agentworkforce sources remove
Two fixed project sources always lead the cascade:
`/.agentworkforce/workforce/personas/*.json`, then
-`/.agentworkforce/workforce/agents//persona.json`.
+`/.agentworkforce/workforce/agents//persona.json`. A third fixed
+source, `~/.agentworkforce/workforce/agents//persona.json`, carries
+personal agents and follows the personal personas dir wherever it ranks.
After that, the CLI reads an ordered list of configurable persona directories
from `~/.agentworkforce/workforce/config.json`. If no config exists, the list
@@ -477,13 +479,23 @@ wins):
2. `/.agentworkforce/workforce/agents//persona.json` — **cwd:agents**
3. Configurable persona source dirs, in order. Default:
`~/.agentworkforce/workforce/personas/*.json` — **user**
-4. Internal built-in system personas in `/personas/` — **library**
+4. `~/.agentworkforce/workforce/agents//persona.json` — **user:agents**,
+ the personal mirror of `cwd:agents`. It rides directly behind the personal
+ personas dir, so moving that dir in the cascade moves both. The tables
+ printed by `list` and `sources list` show it as **personal:agents**,
+ following `user` → `personal`; `user:agents` is the value `--json` emits.
+5. Internal built-in system personas in `/personas/` — **library**
Local files are **partial overlays**: only the fields you set replace the
inherited value. Everything else cascades through from below.
### Agents that ship their own handler
+The same directory layout works in a repo (`cwd:agents`) and in your personal
+config (`user:agents`), so an agent you want everywhere lives in
+`~/.agentworkforce/workforce/agents//` and a repo's own agents live in its
+working tree. A repo agent outranks a personal one of the same id.
+
An agent with code of its own keeps persona, handler, tests, and README in one
directory:
diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts
index 115defd3..a3506d70 100644
--- a/packages/cli/src/local-personas.test.ts
+++ b/packages/cli/src/local-personas.test.ts
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -1034,3 +1034,281 @@ test('a missing agents/ directory is not an error', () => {
assert.deepEqual(loaded.warnings, []);
});
});
+
+// --- handler agents ---------------------------------------------------------
+// An agent driven by its `onEvent` entry has no interactive launch to
+// configure, so harness/model/systemPrompt are optional. Requiring them kept
+// exactly these agents out of the cascade the agents/ dir was added for.
+
+test('a handler persona loads without interactive fields', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Weekly digest handler.',
+ cloud: true,
+ onEvent: './agent.ts',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ const spec = loaded.byId.get('digest');
+ assert.ok(spec);
+ assert.equal(spec.onEvent, './agent.ts');
+ assert.equal(spec.cloud, true);
+ assert.equal(spec.harness, undefined);
+ assert.equal(spec.model, undefined);
+ assert.equal(spec.systemPrompt, undefined);
+ });
+});
+
+test('a standalone persona with no handler still requires harness', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'interactive');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'interactive',
+ intent: 'documentation',
+ description: 'No handler, so an operator launches it.',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.equal(loaded.byId.has('interactive'), false);
+ assert.match(loaded.warnings[0] ?? '', /harness is required for standalone personas/);
+ });
+});
+
+test('an overlay tweaking env keeps the handler entry it inherits', () => {
+ withAgentLayer(({ cwd, homeDir, pwdDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Weekly digest handler.',
+ cloud: true,
+ onEvent: './agent.ts',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ writeJson(join(pwdDir, 'digest.json'), { id: 'digest', env: { TONE: 'terse' } });
+
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ const spec = loaded.byId.get('digest');
+ assert.equal(spec?.onEvent, './agent.ts');
+ assert.equal(spec?.cloud, true);
+ assert.equal(spec?.env?.TONE, 'terse');
+ });
+});
+
+test('onEvent may not escape the agent directory', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Escapes its directory.',
+ onEvent: '../../../elsewhere/agent.ts',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.equal(loaded.byId.has('digest'), false);
+ assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/);
+ });
+});
+
+test('a padded onEvent is normalized before it is stored', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Padded but legal handler path.',
+ onEvent: ' ./agent.ts',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ // Stored untrimmed this resolves against a directory named " .".
+ assert.equal(loaded.byId.get('digest')?.onEvent, './agent.ts');
+ });
+});
+
+test('onEvent must point at a handler source file', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Points at prose, not a handler.',
+ onEvent: 'README.md',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ // Without the extension rule this would read as a handler and skip the
+ // interactive fields it never declared.
+ assert.equal(loaded.byId.has('digest'), false);
+ assert.match(loaded.warnings[0] ?? '', /must point at a \.ts/);
+ });
+});
+
+test('a padded onEvent cannot smuggle a .. segment past the guard', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'digest');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'digest',
+ intent: 'documentation',
+ description: 'Escapes once trimmed.',
+ onEvent: ' ../outside/agent.ts ',
+ harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.equal(loaded.byId.has('digest'), false);
+ // Trimmed before validation, so the traversal guard sees the real path.
+ assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/);
+ });
+});
+
+test('a persona.json older than its authoring source warns but still loads', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'proposal-agent');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'proposal-agent',
+ extends: 'persona-maker',
+ env: { COMPILED: 'stale' }
+ });
+ writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
+ // Backdate the artifact rather than sleeping — same relation, no wall clock.
+ const past = new Date(Date.now() - 60_000);
+ utimesSync(join(agentDir, 'persona.json'), past, past);
+
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.equal(loaded.warnings.length, 1);
+ assert.match(loaded.warnings[0] ?? '', /persona\.json is older than persona\.ts/);
+ assert.match(loaded.warnings[0] ?? '', /agentworkforce persona compile/);
+ // Still served: a forgotten compile must not read as a missing persona.
+ assert.equal(loaded.byId.get('proposal-agent')?.env?.COMPILED, 'stale');
+ });
+});
+
+test('a persona.json newer than its authoring source is silent', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'proposal-agent');
+ mkdirSync(agentDir, { recursive: true });
+ writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'proposal-agent',
+ extends: 'persona-maker'
+ });
+ const past = new Date(Date.now() - 60_000);
+ utimesSync(join(agentDir, 'persona.ts'), past, past);
+
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ assert.ok(loaded.byId.has('proposal-agent'));
+ });
+});
+
+test('staleness is measured against the newest authoring file, not the first', () => {
+ withAgentLayer(({ cwd, homeDir, agentsDir }) => {
+ const agentDir = join(agentsDir, 'proposal-agent');
+ mkdirSync(agentDir, { recursive: true });
+ // An abandoned persona.ts predates the artifact; the live persona.js is
+ // newer. Measuring against the .ts alone would call this fresh.
+ writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
+ writeJson(join(agentDir, 'persona.json'), { id: 'proposal-agent', extends: 'persona-maker' });
+ writeFileSync(join(agentDir, 'persona.js'), 'export default {}\n');
+
+ const old = new Date(Date.now() - 120_000);
+ utimesSync(join(agentDir, 'persona.ts'), old, old);
+ const mid = new Date(Date.now() - 60_000);
+ utimesSync(join(agentDir, 'persona.json'), mid, mid);
+
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.equal(loaded.warnings.length, 1);
+ assert.match(loaded.warnings[0] ?? '', /persona\.json is older than persona\.js/);
+ });
+});
+
+// --- user:agents layer ------------------------------------------------------
+// The personal mirror of cwd:agents — an agent-with-handler a user keeps
+// across every repo, in the `agents/` sibling of their personal personas dir.
+
+test('the personal agents dir loads as user:agents', () => {
+ withLayers(({ cwd, home, homeDir }) => {
+ const userAgents = join(home, '.agentworkforce', 'workforce', 'agents');
+ mkdirSync(join(userAgents, 'note-taker'), { recursive: true });
+ writeJson(join(userAgents, 'note-taker', 'persona.json'), {
+ id: 'note-taker',
+ extends: 'persona-maker',
+ env: { SCOPE: 'personal' }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ assert.equal(loaded.sources.get('note-taker'), 'user:agents');
+ assert.equal(loaded.byId.get('note-taker')?.env?.SCOPE, 'personal');
+ });
+});
+
+test('a personal agent resolves its skills against its own directory', () => {
+ withLayers(({ cwd, home, homeDir }) => {
+ const workforceHome = join(home, '.agentworkforce', 'workforce');
+ mkdirSync(join(workforceHome, 'skills'), { recursive: true });
+ writeFileSync(join(workforceHome, 'skills', 'voice.md'), '# voice\n');
+ const agentDir = join(workforceHome, 'agents', 'note-taker');
+ mkdirSync(agentDir, { recursive: true });
+ writeJson(join(agentDir, 'persona.json'), {
+ id: 'note-taker',
+ extends: 'persona-maker',
+ skills: [{ id: 'local/voice', source: '../../skills/voice.md', description: 'voice' }]
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ assert.equal(
+ loaded.byId.get('note-taker')?.skills[0]?.source,
+ join(workforceHome, 'skills', 'voice.md')
+ );
+ });
+});
+
+test('a repo agent outranks a personal agent of the same id', () => {
+ withAgentLayer(({ cwd, home, homeDir, agentsDir }) => {
+ const userAgents = join(home, '.agentworkforce', 'workforce', 'agents');
+ mkdirSync(join(userAgents, 'note-taker'), { recursive: true });
+ writeJson(join(userAgents, 'note-taker', 'persona.json'), {
+ id: 'note-taker',
+ extends: 'persona-maker',
+ env: { SCOPE: 'personal', KEPT: 'yes' }
+ });
+ mkdirSync(join(agentsDir, 'note-taker'), { recursive: true });
+ writeJson(join(agentsDir, 'note-taker', 'persona.json'), {
+ id: 'note-taker',
+ env: { SCOPE: 'repo' }
+ });
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ assert.equal(loaded.sources.get('note-taker'), 'cwd:agents');
+ const spec = loaded.byId.get('note-taker');
+ assert.equal(spec?.env?.SCOPE, 'repo');
+ assert.equal(spec?.env?.KEPT, 'yes');
+ });
+});
+
+test('user:agents displays as personal:agents, following user -> personal', () => {
+ assert.equal(formatPersonaSourceLabel('user:agents'), 'personal:agents');
+ assert.equal(formatPersonaSourceLabel('cwd:agents'), 'cwd:agents');
+});
+
+test('a missing personal agents dir is not an error', () => {
+ withLayers(({ cwd, homeDir }) => {
+ const loaded = loadLocalPersonas({ cwd, homeDir });
+ assert.deepEqual(loaded.warnings, []);
+ });
+});
diff --git a/packages/persona-registry/README.md b/packages/persona-registry/README.md
index 70ee3ee0..32486cac 100644
--- a/packages/persona-registry/README.md
+++ b/packages/persona-registry/README.md
@@ -2,7 +2,7 @@
Programmatic resolution for AgentWorkforce personas. It owns the same source
cascade used by the CLI: cwd personas, cwd agents, configured directories
-(including the personal directory), then the built-in catalog.
+(including the personal directory), personal agents, then the built-in catalog.
```ts
import { resolvePersonaReference } from '@agentworkforce/persona-registry';
diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts
index d15274ca..89da5847 100644
--- a/packages/persona-registry/src/local-personas.ts
+++ b/packages/persona-registry/src/local-personas.ts
@@ -21,7 +21,8 @@ import {
type PersonaTag,
type SidecarMdMode,
parseHarnessSettings,
- parseInputs
+ parseInputs,
+ parseOnEvent
} from '@agentworkforce/persona-kit';
import { listBuiltInPersonas, personaCatalog } from '@agentworkforce/workload-router';
@@ -64,6 +65,14 @@ export interface LocalPersonaOverride {
permissions?: PersonaPermissions;
/** Replaces the inherited systemPrompt when set. */
systemPrompt?: string;
+ /**
+ * Handler entry, relative to this file's directory. Its presence marks the
+ * persona as a cloud agent: the handler drives the run, so the interactive
+ * fields an operator-launched persona must declare are optional here.
+ */
+ onEvent?: string;
+ /** Deployable as a managed cloud agent. */
+ cloud?: boolean;
/** Replaces the inherited harness when set. */
harness?: Harness;
/** Replaces the inherited model when set. */
@@ -103,6 +112,9 @@ export type PersonaSource = string;
* - `cwd:agents` → same — `/.agentworkforce/workforce/agents//persona.json`,
* agents that keep their persona next to their handler.
* Also a precise pointer, so also kept as-is.
+ * - `user:agents` → `personal:agents` — the `agents/` sibling of the personal
+ * personas dir; personal agents-with-handlers, available in
+ * any repo. Renamed to follow `user` → `personal`.
* - `dir:N` → `dir:N` — extra configurable persona dirs (passed
* through unchanged so position is still legible).
*
@@ -112,6 +124,7 @@ export type PersonaSource = string;
export function formatPersonaSourceLabel(source: PersonaSource): string {
if (source === 'library') return 'built-in';
if (source === 'user') return 'personal';
+ if (source === 'user:agents') return 'personal:agents';
return source;
}
@@ -212,6 +225,16 @@ export function defaultCwdAgentDir(cwd: string): string {
return join(cwd, '.agentworkforce', 'workforce', 'agents');
}
+/**
+ * Personal counterpart to {@link defaultCwdAgentDir}: agents-with-handlers a
+ * user keeps across every repo. Derived from wherever their personal personas
+ * live so the pair travels together, including under
+ * `AGENT_WORKFORCE_CONFIG_DIR` and test overrides.
+ */
+export function userAgentDirFor(userPersonaDir: string): string {
+ return join(dirname(userPersonaDir), 'agents');
+}
+
/** Persona filename read from each subdirectory of a nested source dir. */
export const NESTED_PERSONA_FILENAME = 'persona.json';
@@ -379,15 +402,37 @@ export function buildPersonaSourceDirectories(
configurable: false,
nested: true
},
- ...config.personaDirs.map((dir, idx) => ({
- source: sourceForPersonaDir(dir, idx, config.userPersonaDir),
- dir,
- configurable: true
- }))
+ ...config.personaDirs.flatMap((dir, idx) => {
+ const entry: PersonaSourceDirectory = {
+ source: sourceForPersonaDir(dir, idx, config.userPersonaDir),
+ dir,
+ configurable: true
+ };
+ // The personal agents dir rides directly behind the personal personas
+ // dir, the same way cwd:agents rides behind cwd, so the pair keeps its
+ // relative rank wherever the user has moved it in the cascade.
+ return dir === config.userPersonaDir
+ ? [entry, userAgentSourceDir(config.userPersonaDir)]
+ : [entry];
+ }),
+ // A user who dropped their personal personas dir from the cascade still
+ // gets their personal agents, ranked last among file layers.
+ ...(config.personaDirs.includes(config.userPersonaDir)
+ ? []
+ : [userAgentSourceDir(config.userPersonaDir)])
];
return { directories, config };
}
+function userAgentSourceDir(userPersonaDir: string): PersonaSourceDirectory {
+ return {
+ source: 'user:agents',
+ dir: userAgentDirFor(userPersonaDir),
+ configurable: false,
+ nested: true
+ };
+}
+
/** One persona file to read, with the directory its relative paths resolve against. */
interface LayerEntry {
/** Path relative to the layer dir, used in warnings. */
@@ -420,18 +465,38 @@ function readNestedLayerEntries(
for (const name of names) {
const sourceDir = join(dir, name);
const path = join(sourceDir, NESTED_PERSONA_FILENAME);
- if (isFile(path)) {
- entries.push({ label: `${name}/${NESTED_PERSONA_FILENAME}`, path, sourceDir });
+ // Compare against the NEWEST authoring file, not the first one that
+ // exists: a directory that still carries an old persona.ts after moving to
+ // persona.js would otherwise be measured against the file nobody edits.
+ const authoredCandidate = NESTED_PERSONA_SOURCE_FILENAMES.map((file) => ({
+ file: join(sourceDir, file),
+ mtimeMs: fileMtimeMs(join(sourceDir, file))
+ }))
+ .filter((candidate): candidate is { file: string; mtimeMs: number } => candidate.mtimeMs !== undefined)
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
+ const authored = authoredCandidate?.file;
+ const compiledAt = fileMtimeMs(path);
+
+ if (compiledAt === undefined) {
+ if (authored) {
+ warnings.push(
+ `[${layer.source}] ${name}: ${basename(authored)} has no compiled ${NESTED_PERSONA_FILENAME}; run \`agentworkforce persona compile ${authored}\` to make it loadable.`
+ );
+ }
continue;
}
- const authored = NESTED_PERSONA_SOURCE_FILENAMES.find((file) =>
- isFile(join(sourceDir, file))
- );
- if (authored) {
+
+ // A stale artifact is the quieter failure: the persona still loads, so
+ // nothing looks wrong while the edits sitting in the authoring file are
+ // simply absent. Say so, and keep serving the compiled spec — dropping it
+ // would turn a forgotten compile into a missing persona.
+ const authoredAt = authoredCandidate?.mtimeMs;
+ if (authored !== undefined && authoredAt !== undefined && authoredAt > compiledAt) {
warnings.push(
- `[${layer.source}] ${name}: ${authored} has no compiled ${NESTED_PERSONA_FILENAME}; run \`agentworkforce persona compile ${join(sourceDir, authored)}\` to make it loadable.`
+ `[${layer.source}] ${name}: ${NESTED_PERSONA_FILENAME} is older than ${basename(authored)}, so this persona is loading without the latest edits; re-run \`agentworkforce persona compile ${authored}\`.`
);
}
+ entries.push({ label: `${name}/${NESTED_PERSONA_FILENAME}`, path, sourceDir });
}
return entries;
}
@@ -588,6 +653,24 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride {
`${context}.defaultTier is no longer supported (tiers have been removed)`
);
}
+ // Normalize first, then delegate to persona-kit, which owns both the
+ // containment guard and the handler-extension check. Order matters in both
+ // directions: validating the raw string and storing a trimmed copy lets
+ // " ../x/agent.ts " clear the `..` check as the segment " .." and escape
+ // once trimmed, while validating without trimming stores " ./agent.ts",
+ // which passes every check and then resolves to a directory named " ."
+ // at deploy. Trimming up front makes the validated and stored value one
+ // and the same.
+ const onEventValue =
+ raw.onEvent === undefined
+ ? undefined
+ : parseOnEvent(
+ typeof raw.onEvent === 'string' ? raw.onEvent.trim() : raw.onEvent,
+ `${context}.onEvent`
+ );
+ if (raw.cloud !== undefined && typeof raw.cloud !== 'boolean') {
+ throw new Error(`${context}.cloud must be a boolean if provided`);
+ }
if (raw.harness !== undefined) {
if (typeof raw.harness !== 'string' || !HARNESS_VALUES.includes(raw.harness as Harness)) {
throw new Error(`${context}.harness must be one of: ${HARNESS_VALUES.join(', ')}`);
@@ -631,6 +714,8 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride {
mount: raw.mount as LocalPersonaOverride['mount'],
permissions: raw.permissions as LocalPersonaOverride['permissions'],
systemPrompt: raw.systemPrompt as string | undefined,
+ ...(onEventValue !== undefined ? { onEvent: onEventValue } : {}),
+ ...(raw.cloud !== undefined ? { cloud: raw.cloud as boolean } : {}),
...(raw.harness !== undefined ? { harness: raw.harness as Harness } : {}),
...(raw.model !== undefined ? { model: raw.model as string } : {}),
...(raw.harnessSettings !== undefined
@@ -819,12 +904,23 @@ function standaloneSpecFromOverride(
cwd = process.cwd()
): PersonaSpec {
const context = `standalone persona "${override.id}"`;
- const harness = requireStandaloneField(override.harness, `${context}.harness`);
- if (!HARNESS_VALUES.includes(harness)) {
+ // A handler agent is driven by its `onEvent` entry, not by an operator at a
+ // prompt, so the fields configuring an interactive launch are optional here.
+ // Requiring them made agents that ship a handler invisible to the cascade:
+ // the `agents/` directory added for exactly those agents could not load
+ // them, and the error read as though the persona were malformed.
+ const isHandler = typeof override.onEvent === 'string' && override.onEvent.trim() !== '';
+
+ const harness = isHandler
+ ? override.harness
+ : requireStandaloneField(override.harness, `${context}.harness`);
+ if (harness !== undefined && !HARNESS_VALUES.includes(harness)) {
throw new Error(`${context}.harness must be one of: ${HARNESS_VALUES.join(', ')}`);
}
- const model = requireStandaloneField(override.model, `${context}.model`);
- if (typeof model !== 'string' || !model.trim()) {
+ const model = isHandler
+ ? override.model
+ : requireStandaloneField(override.model, `${context}.model`);
+ if (model !== undefined && (typeof model !== 'string' || !model.trim())) {
throw new Error(`${context}.model must be a non-empty string`);
}
const fallbackSystemPrompt = override.claudeMdContent ?? override.agentsMdContent;
@@ -832,9 +928,12 @@ function standaloneSpecFromOverride(
typeof override.systemPrompt === 'string' && override.systemPrompt.trim()
? override.systemPrompt
: fallbackSystemPrompt;
- if (typeof systemPrompt !== 'string' || !systemPrompt.trim()) {
+ if (!isHandler && (typeof systemPrompt !== 'string' || !systemPrompt.trim())) {
throw new Error(`${context}.systemPrompt must be a non-empty string`);
}
+ // `harnessSettings` stays required even for a handler: `PersonaSpec` types it
+ // non-optional, and `reasoning`/`timeoutSeconds` have no defensible default to
+ // invent on the persona's behalf. Every shipped handler example declares it.
const settingsRaw = override.harnessSettings;
if (!settingsRaw || !isPlainObject(settingsRaw)) {
throw new Error(`${context}.harnessSettings must be an object`);
@@ -887,9 +986,11 @@ function standaloneSpecFromOverride(
cwd
),
...(inputs ? { inputs } : {}),
- harness,
- model,
- systemPrompt,
+ ...(override.onEvent !== undefined ? { onEvent: override.onEvent } : {}),
+ ...(override.cloud !== undefined ? { cloud: override.cloud } : {}),
+ ...(harness !== undefined ? { harness } : {}),
+ ...(model !== undefined ? { model } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
harnessSettings,
...(env ? { env } : {}),
...(mcpServers ? { mcpServers } : {}),
@@ -1012,6 +1113,16 @@ function isFile(path: string): boolean {
}
}
+/** Modification time of a regular file, or undefined if it is not one. */
+function fileMtimeMs(path: string): number | undefined {
+ try {
+ const st = statSync(path);
+ return st.isFile() ? st.mtimeMs : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
/**
* Resolve relative local skill sources (`./skills/foo.md`) declared by an
* override against the directory of the JSON file that declared them, the
@@ -1110,6 +1221,10 @@ function mergeOverride(
const harness = override.harness ?? base.harness;
const model = override.model ?? base.model;
const systemPrompt = override.systemPrompt ?? base.systemPrompt;
+ // An overlay that only tweaks env must not strip the handler entry that
+ // makes the base a deployable agent.
+ const onEvent = override.onEvent ?? base.onEvent;
+ const cloud = override.cloud ?? base.cloud;
const harnessSettings: HarnessSettings = parseHarnessSettings({
...base.harnessSettings,
...(override.harnessSettings ?? {})
@@ -1188,9 +1303,11 @@ function mergeOverride(
description: override.description ?? base.description,
skills,
...(inputs ? { inputs } : {}),
- harness,
- model,
- systemPrompt,
+ ...(onEvent !== undefined ? { onEvent } : {}),
+ ...(cloud !== undefined ? { cloud } : {}),
+ ...(harness !== undefined ? { harness } : {}),
+ ...(model !== undefined ? { model } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
harnessSettings,
...(env ? { env } : {}),
...(mcpServers ? { mcpServers } : {}),