Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions packages/persona-kit/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,93 @@
# @agentworkforce/persona-kit

Persona-kit owns the AgentWorkforce persona instantiation lifecycle. See tracking issue [#64](https://github.com/AgentWorkforce/workforce/issues/64).

## Top-level orchestration API

The package exposes a two-phase API: a **pure plan builder** that composes a
persona's runtime data into a single inspectable value, and a **side-effecting
executor** that runs the plan and returns a handle that reverses every side
effect.

### Persona JSON → running agent (end-to-end)

```ts
import {
buildPersonaSpawnPlan,
executePersonaSpawnPlan,
type ResolvedPersona
} from '@agentworkforce/persona-kit';
import { spawn } from 'node:child_process';

declare const persona: ResolvedPersona; // from loadPersonas() / personaCatalog

// 1. Compose a plan. Pure — no I/O, no subprocesses.
// PlanOptions: inputValues, envOverrides, processEnv, installRoot.
// cwd belongs to ExecuteOptions, not the plan builder.
const plan = buildPersonaSpawnPlan(persona, {
inputValues: { TASK: 'tidy up the README' }
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 2. Inspect or stamp the plan if you like — it's JSON-serializable.
console.log(plan.cli, plan.args, plan.env);

// 3. Run the side effects. Returns a handle whose dispose() reverses them.
const handle = await executePersonaSpawnPlan(plan, { cwd: process.cwd() });

try {
// 4. Spawn the harness at handle.cwd with plan.cli + plan.args + plan.env.
await new Promise<void>((resolve, reject) => {
const child = spawn(plan.cli, plan.args, {
cwd: handle.cwd,
env: plan.env,
stdio: 'inherit'
});
child.on('error', reject);
child.on('close', () => resolve());
});
} finally {
// 5. Tear down: removes installed skills, restores sidecar files,
// cleans up materialized config files, releases the mount.
await handle.dispose();
}
```

### Plan structure

`PersonaSpawnPlan` carries everything a caller needs:

- `persona` — the resolved persona (model, harness, skills, env, …).
- `cli`, `args`, `initialPrompt` — what to spawn.
- `env` — final environment with input bindings + persona env merged in.
- `skills` — pure `SkillMaterializationPlan` produced by `materializeSkills`.
- `mount`, `sidecars`, `configFiles`, `inputs` — resolved pieces ready for
the executor.

The plan is **JSON-serializable** — useful for stamping into launch metadata
or sending across a wire (e.g. relay's `getPersonaSpawnPlan`).

### Piecewise helpers

For callers who want their own orchestration:

- `applyPersonaMount(mount, options)` — opens an `@relayfile/local-mount`
sandbox when a mount policy is supplied, no-op otherwise. Returns a handle
exposing the harness `cwd`.
- `runSkillInstalls(plan, options)` — spawns the install commands produced
by `buildInstallArtifacts`. Aborts on the first non-zero exit and attaches
the buffered subprocess output to `SkillInstallError`.
- `writePersonaSidecars(sidecars, options)` — writes `CLAUDE.md` /
`AGENTS.md` with restore-on-dispose semantics.
- `materializePersonaConfigFiles(configFiles, options)` — writes the harness
config files (e.g. `opencode.json`) with restore-on-dispose semantics.

Each helper returns a handle of shape `{ dispose(): Promise<void> }`.
`executePersonaSpawnPlan` composes them in order: mount → skills → sidecars
→ config files. If any step throws, prior handles are disposed in LIFO
order before the original error propagates.

### Pure lower-level helpers

`buildInteractiveSpec`, `materializeSkills`, `parsePersonaFile`,
`resolvePersonaInputs`, etc. remain available for advanced callers who want
finer control than the plan builder offers.
3 changes: 3 additions & 0 deletions packages/persona-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsc -p tsconfig.json && node --test dist/*.test.js",
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@relayfile/local-mount": "^0.7.0"
}
}
95 changes: 95 additions & 0 deletions packages/persona-kit/src/config-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join } from 'node:path';
import type { InteractiveConfigFile } from './interactive-spec.js';

export interface PersonaConfigFilesHandle {
/** Reverse every config-file write. Idempotent. */
dispose(): Promise<void>;
}

interface RestoredFile {
path: string;
prior: string | null;
}

/**
* Reject paths that would escape the cwd or hit absolute targets — same
* policy as the CLI's existing `assertSafeRelativePath` so persona-kit's
* exec helpers can be plugged into the CLI later without weakening the
* sandbox guarantees.
*/
export function assertSafeRelativePath(relPath: string): void {
if (!relPath) {
throw new Error('configFile path must be a non-empty relative path');
}
if (isAbsolute(relPath)) {
throw new Error(
`configFile path must be relative; got absolute path ${JSON.stringify(relPath)}`
);
}
const segments = relPath.split(/[\\/]+/);
if (segments.some((s) => s === '..')) {
throw new Error(
`configFile path must not contain ".." segments; got ${JSON.stringify(relPath)}`
);
}
}

async function readIfExists(path: string): Promise<string | null> {
try {
return await readFile(path, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
}

/**
* Materialize each {@link InteractiveConfigFile} under `options.cwd`,
* creating any missing parent directories. The returned handle restores
* each touched path to its prior state on `dispose()`.
*/
export async function materializePersonaConfigFiles(
configFiles: readonly InteractiveConfigFile[],
options: { cwd: string }
): Promise<PersonaConfigFilesHandle> {
const restored: RestoredFile[] = [];
let disposed = false;
try {
for (const file of configFiles) {
assertSafeRelativePath(file.path);
const target = join(options.cwd, file.path);
const prior = await readIfExists(target);
restored.push({ path: target, prior });
await mkdir(dirname(target), { recursive: true });
await writeFile(target, file.contents, 'utf8');
}
} catch (err) {
await disposeRestored(restored);
throw err;
}
return {
async dispose(): Promise<void> {
if (disposed) return;
disposed = true;
await disposeRestored(restored);
}
};
}

async function disposeRestored(restored: readonly RestoredFile[]): Promise<void> {
for (let i = restored.length - 1; i >= 0; i -= 1) {
const entry = restored[i];
try {
if (entry.prior === null) {
await unlink(entry.path).catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
} else {
await writeFile(entry.path, entry.prior, 'utf8');
}
} catch {
// Best-effort.
}
}
}
Loading
Loading