From 97560489394c0ba90d2dee2b16b4a272bfd2401c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 8 May 2026 04:03:19 -0400 Subject: [PATCH 1/2] feat(sdk): spawn agents from named AgentWorkforce personas Adds `relay.spawnPersona(id, options?)` so callers can pick a persona JSON by id from `./agentworkforce/personas` (or any configured dir) and spawn it without hand-rolling cli/model/system-prompt/MCP wiring. - `src/personas.ts` loads, resolves a tier (best/best-value/minimum), applies one level of `extends`, and delegates harness translation to `@agentworkforce/harness-kit#buildInteractiveSpec` so launch args match the AgentWorkforce CLI exactly. - `AgentRelay` gains a `personaDirs` constructor option for the default search-dir cascade; per-call `searchDirs` / `extraDirs` / `tier` still override. - For opencode personas, `opencode.json` is materialized in the spawn cwd and restored on agent exit. - Subpath export `@agent-relay/sdk/personas`, plus a runnable example at `src/examples/persona-spawn.ts` and 14 unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 16 + packages/sdk/package.json | 9 +- packages/sdk/src/__tests__/personas.test.ts | 328 +++++++++++++ packages/sdk/src/examples/persona-spawn.ts | 51 ++ packages/sdk/src/index.ts | 1 + packages/sdk/src/personas.ts | 493 ++++++++++++++++++++ packages/sdk/src/relay.ts | 126 +++++ 7 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/__tests__/personas.test.ts create mode 100644 packages/sdk/src/examples/persona-spawn.ts create mode 100644 packages/sdk/src/personas.ts diff --git a/package-lock.json b/package-lock.json index 25068d56a..d9a2a346d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,19 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@agentworkforce/harness-kit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@agentworkforce/harness-kit/-/harness-kit-0.11.0.tgz", + "integrity": "sha512-CtW9P0pVm0j5R+kl7OaWMkPz7akYZqJNLmQ8k1m5Ony7NIfxJKuGiTBH9kcg+6vQ7fUtnfkoa34wt3y/pEh2QQ==", + "dependencies": { + "@agentworkforce/workload-router": "0.11.0" + } + }, + "node_modules/@agentworkforce/workload-router": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@agentworkforce/workload-router/-/workload-router-0.11.0.tgz", + "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1281,6 +1294,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -16382,6 +16396,8 @@ "@agent-relay/config": "6.0.9", "@agent-relay/github-primitive": "6.0.9", "@agent-relay/workflow-types": "6.0.9", + "@agentworkforce/harness-kit": "^0.11.0", + "@agentworkforce/workload-router": "^0.11.0", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b21d1612f..bc9c711ae 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -27,6 +27,11 @@ "import": "./dist/relay.js", "default": "./dist/relay.js" }, + "./personas": { + "types": "./dist/personas.d.ts", + "import": "./dist/personas.js", + "default": "./dist/personas.js" + }, "./logs": { "types": "./dist/logs.d.ts", "import": "./dist/logs.js", @@ -142,6 +147,8 @@ "@agent-relay/config": "6.0.9", "@agent-relay/github-primitive": "6.0.9", "@agent-relay/workflow-types": "6.0.9", + "@agentworkforce/harness-kit": "^0.11.0", + "@agentworkforce/workload-router": "^0.11.0", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", @@ -149,8 +156,8 @@ "chalk": "^4.1.2", "ignore": "^7.0.5", "listr2": "^10.2.1", - "ws": "^8.18.3", "tar": "^7.5.10", + "ws": "^8.18.3", "yaml": "^2.7.0" }, "optionalDependencies": { diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts new file mode 100644 index 000000000..d7eaedada --- /dev/null +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -0,0 +1,328 @@ +/** + * Persona loader + translator tests. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'vitest'; + +import { + buildPersonaSpawnSpec, + composePersonaTask, + defaultPersonaSearchDirs, + findPersona, + listPersonas, + loadPersona, + materializePersonaConfigFiles, + restorePersonaConfigFiles, +} from '../personas.js'; +import { AgentRelay } from '../relay.js'; + +function makeFixture(): { cwd: string; cleanup: () => void } { + const root = mkdtempSync(join(tmpdir(), 'relay-personas-')); + const dir = join(root, 'agentworkforce', 'personas'); + mkdirSync(dir, { recursive: true }); + + writeFileSync( + join(dir, 'frontend.json'), + JSON.stringify({ + id: 'frontend', + description: 'frontend implementer', + tiers: { + best: { + harness: 'claude', + model: 'claude-opus-4-6', + systemPrompt: 'You are a senior frontend engineer.', + }, + 'best-value': { + harness: 'codex', + model: 'openai-codex/gpt-5-codex', + systemPrompt: 'You are an efficient frontend engineer.', + }, + minimum: { + harness: 'opencode', + model: 'opencode/gpt-5-nano', + systemPrompt: 'You are a concise frontend engineer.', + }, + }, + permissions: { + allow: ['Bash(npm test)'], + mode: 'default', + }, + }), + ); + + // A second persona with extends to verify cascade lookup + writeFileSync( + join(dir, 'frontend-strict.json'), + JSON.stringify({ + id: 'frontend-strict', + extends: 'frontend', + permissions: { + deny: ['Bash(rm -rf *)'], + }, + }), + ); + + return { + cwd: root, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +test('defaultPersonaSearchDirs includes cwd-relative and home-relative dirs', () => { + const dirs = defaultPersonaSearchDirs('/work/proj'); + assert.equal(dirs[0], '/work/proj/agentworkforce/personas'); + assert.equal(dirs[1], '/work/proj/.agentworkforce/workforce/personas'); + assert.equal(dirs[2], '/work/proj/agentworkforce/workforce/personas'); + assert.match(dirs[3] ?? '', /agentworkforce[\\/]+(workforce[\\/]+)?personas$/); +}); + +test('listPersonas discovers JSON files under agentworkforce/personas', () => { + const fix = makeFixture(); + try { + const personas = listPersonas({ cwd: fix.cwd }); + const ids = personas.map((p) => p.id).sort(); + assert.deepEqual(ids, ['frontend', 'frontend-strict']); + } finally { + fix.cleanup(); + } +}); + +test('findPersona returns spec by id, regardless of filename', () => { + const fix = makeFixture(); + try { + const found = findPersona('frontend', { cwd: fix.cwd }); + assert.ok(found); + assert.equal(found?.id, 'frontend'); + assert.match(found?.path ?? '', /frontend\.json$/); + } finally { + fix.cleanup(); + } +}); + +test('loadPersona resolves the requested tier', () => { + const fix = makeFixture(); + try { + const best = loadPersona('frontend', { cwd: fix.cwd }); + assert.equal(best.tier, 'best'); + assert.equal(best.harness, 'claude'); + assert.equal(best.model, 'claude-opus-4-6'); + assert.match(best.systemPrompt, /senior frontend engineer/); + + const value = loadPersona('frontend', { cwd: fix.cwd, tier: 'best-value' }); + assert.equal(value.harness, 'codex'); + assert.equal(value.model, 'openai-codex/gpt-5-codex'); + + const min = loadPersona('frontend', { cwd: fix.cwd, tier: 'minimum' }); + assert.equal(min.harness, 'opencode'); + assert.equal(min.model, 'opencode/gpt-5-nano'); + } finally { + fix.cleanup(); + } +}); + +test('loadPersona applies extends and merges permissions', () => { + const fix = makeFixture(); + try { + const strict = loadPersona('frontend-strict', { cwd: fix.cwd }); + assert.equal(strict.harness, 'claude'); + assert.deepEqual(strict.permissions?.allow, ['Bash(npm test)']); + assert.deepEqual(strict.permissions?.deny, ['Bash(rm -rf *)']); + assert.equal(strict.permissions?.mode, 'default'); + } finally { + fix.cleanup(); + } +}); + +test('loadPersona throws when persona is missing', () => { + const fix = makeFixture(); + try { + assert.throws(() => loadPersona('does-not-exist', { cwd: fix.cwd }), /not found/); + } finally { + fix.cleanup(); + } +}); + +test('buildPersonaSpawnSpec for claude includes system prompt and MCP flags', () => { + const fix = makeFixture(); + try { + const persona = loadPersona('frontend', { cwd: fix.cwd }); + const spec = buildPersonaSpawnSpec(persona); + assert.equal(spec.cli, 'claude'); + assert.equal(spec.model, 'claude-opus-4-6'); + assert.equal(spec.initialPrompt, null); + assert.deepEqual(spec.configFiles, []); + assert.ok(spec.args.includes('--append-system-prompt')); + assert.ok(spec.args.includes('--strict-mcp-config')); + const promptIdx = spec.args.indexOf('--append-system-prompt'); + assert.match(spec.args[promptIdx + 1] ?? '', /senior frontend engineer/); + assert.ok(spec.args.includes('--allowedTools')); + } finally { + fix.cleanup(); + } +}); + +test('buildPersonaSpawnSpec for codex strips provider prefix and exposes initialPrompt', () => { + const fix = makeFixture(); + try { + const persona = loadPersona('frontend', { cwd: fix.cwd, tier: 'best-value' }); + const spec = buildPersonaSpawnSpec(persona); + assert.equal(spec.cli, 'codex'); + // codex receives the stripped provider/model form via -m + assert.deepEqual(spec.args, ['-m', 'gpt-5-codex']); + assert.match(spec.initialPrompt ?? '', /efficient frontend engineer/); + + const taskWithPrompt = composePersonaTask(spec, 'Refactor the login page.'); + assert.match(taskWithPrompt ?? '', /efficient frontend engineer/); + assert.match(taskWithPrompt ?? '', /User task:\nRefactor the login page\./); + } finally { + fix.cleanup(); + } +}); + +test('buildPersonaSpawnSpec for opencode emits an opencode.json config file', () => { + const fix = makeFixture(); + try { + const persona = loadPersona('frontend', { cwd: fix.cwd, tier: 'minimum' }); + const spec = buildPersonaSpawnSpec(persona); + assert.equal(spec.cli, 'opencode'); + assert.deepEqual(spec.args, ['--agent', 'frontend']); + assert.equal(spec.configFiles.length, 1); + assert.equal(spec.configFiles[0]?.path, 'opencode.json'); + const parsed = JSON.parse(spec.configFiles[0]?.contents ?? '{}'); + assert.equal(parsed.agent.frontend.model, 'opencode/gpt-5-nano'); + assert.match(parsed.agent.frontend.prompt, /concise frontend engineer/); + } finally { + fix.cleanup(); + } +}); + +test('materializePersonaConfigFiles writes and restores files', () => { + const fix = makeFixture(); + try { + const target = join(fix.cwd, 'opencode.json'); + writeFileSync(target, '{"original":true}\n', 'utf8'); + + const writes = materializePersonaConfigFiles(fix.cwd, [ + { path: 'opencode.json', contents: '{"replaced":true}\n' }, + ]); + assert.equal(readFileSync(target, 'utf8'), '{"replaced":true}\n'); + assert.equal(writes[0]?.existed, true); + + restorePersonaConfigFiles(writes); + assert.equal(readFileSync(target, 'utf8'), '{"original":true}\n'); + } finally { + fix.cleanup(); + } +}); + +test('materializePersonaConfigFiles removes files that did not previously exist', () => { + const fix = makeFixture(); + try { + const target = join(fix.cwd, 'opencode.json'); + const writes = materializePersonaConfigFiles(fix.cwd, [ + { path: 'opencode.json', contents: '{"new":true}\n' }, + ]); + assert.equal(existsSync(target), true); + restorePersonaConfigFiles(writes); + assert.equal(existsSync(target), false); + } finally { + fix.cleanup(); + } +}); + +test('AgentRelay personaDirs option supplies default search dirs to spawnPersona', async () => { + const fix = makeFixture(); + try { + const personaDir = join(fix.cwd, 'agentworkforce', 'personas'); + const relay = new AgentRelay({ personaDirs: [personaDir] }); + + let captured: { cli?: string; model?: string; args?: string[] } = {}; + // Stub out spawnPty so the test never touches the broker — we only care + // that the persona was discovered and translated using the constructor's + // personaDirs / personaTier defaults. + (relay as unknown as { spawnPty: (input: unknown) => Promise }).spawnPty = async ( + input: unknown, + ) => { + captured = input as { cli?: string; model?: string; args?: string[] }; + return { + name: (input as { name: string }).name, + runtime: 'pty', + channels: ['general'], + status: 'ready', + release: async () => {}, + waitForReady: async () => {}, + waitForExit: async () => 'exited', + waitForIdle: async () => 'idle', + sendMessage: async () => ({}), + subscribe: async () => {}, + unsubscribe: async () => {}, + onOutput: () => () => {}, + }; + }; + + await relay.spawnPersona('frontend', { + cwd: fix.cwd, // spawn cwd; persona lookup uses constructor defaults + tier: 'best-value', + }); + + assert.equal(captured.cli, 'codex'); + assert.deepEqual(captured.args, ['-m', 'gpt-5-codex']); + } finally { + fix.cleanup(); + } +}); + +test('per-call searchDirs on spawnPersona overrides constructor defaults', async () => { + const fix = makeFixture(); + const otherFix = makeFixture(); + try { + const relay = new AgentRelay({ + personaDirs: ['/nonexistent/should/not/be/used'], + }); + + let captured: { cli?: string } = {}; + (relay as unknown as { spawnPty: (input: unknown) => Promise }).spawnPty = async ( + input: unknown, + ) => { + captured = input as { cli?: string }; + return { + name: (input as { name: string }).name, + runtime: 'pty', + channels: [], + status: 'ready', + release: async () => {}, + waitForReady: async () => {}, + waitForExit: async () => 'exited', + waitForIdle: async () => 'idle', + sendMessage: async () => ({}), + subscribe: async () => {}, + unsubscribe: async () => {}, + onOutput: () => () => {}, + }; + }; + + await relay.spawnPersona('frontend', { + searchDirs: [join(otherFix.cwd, 'agentworkforce', 'personas')], + }); + + assert.equal(captured.cli, 'claude'); + } finally { + otherFix.cleanup(); + fix.cleanup(); + } +}); + +test('materializePersonaConfigFiles rejects paths that escape cwd', () => { + const fix = makeFixture(); + try { + assert.throws( + () => materializePersonaConfigFiles(fix.cwd, [{ path: '../escape.json', contents: '{}' }]), + /escapes cwd/, + ); + } finally { + fix.cleanup(); + } +}); diff --git a/packages/sdk/src/examples/persona-spawn.ts b/packages/sdk/src/examples/persona-spawn.ts new file mode 100644 index 000000000..72cf34884 --- /dev/null +++ b/packages/sdk/src/examples/persona-spawn.ts @@ -0,0 +1,51 @@ +/** + * persona-spawn — spawn an agent from an AgentWorkforce persona. + * + * Personas are JSON files describing a pre-configured agent (harness, model, + * system prompt, MCP servers, permissions). They live in + * ./agentworkforce/personas + * or any directory you pass via `searchDirs` / `extraDirs`. + * + * Run: + * npm run build && node dist/examples/persona-spawn.js frontend "Build a settings page" + * + * Environment: + * RELAY_API_KEY — Relaycast workspace key (required) + */ +import { AgentRelay } from "../relay.js"; +import { listPersonas } from "../personas.js"; + +const [, , personaId, ...taskParts] = process.argv; +const task = taskParts.join(" ").trim(); + +if (!personaId) { + const found = listPersonas(); + console.error("Usage: persona-spawn [task...]\n"); + if (found.length > 0) { + console.error("Personas discovered in the default cascade:"); + for (const p of found) { + console.error(` - ${p.id} (${p.path})`); + } + } else { + console.error( + "No personas found. Place JSON files under ./agentworkforce/personas " + + "or set AGENT_WORKFORCE_HOME.", + ); + } + process.exit(1); +} + +const relay = new AgentRelay(); + +relay.onAgentSpawned = (agent) => console.log(`spawned ${agent.name} (${agent.runtime})`); +relay.onAgentExited = (agent) => + console.log(`exited ${agent.name} code=${agent.exitCode ?? "none"}`); + +const agent = await relay.spawnPersona(personaId, { + ...(task ? { task } : {}), + channels: ["general"], +}); + +console.log(`agent ${agent.name} ready, waiting for exit...`); +await agent.waitForExit(); +await relay.shutdown(); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6c2634510..079d65db5 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -21,3 +21,4 @@ export * from './workflows/index.js'; export * from './spawn-from-env.js'; export * from './cli-registry.js'; export * from './cli-resolver.js'; +export * from './personas.js'; diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts new file mode 100644 index 000000000..bc1468a3a --- /dev/null +++ b/packages/sdk/src/personas.ts @@ -0,0 +1,493 @@ +/** + * Persona loading and translation. + * + * A persona is a JSON file that describes a pre-configured agent: which + * harness (CLI) to use, which model, what system prompt to inject, plus + * optional MCP servers and permission flags. Personas live in + * `/agentworkforce/personas`, the AgentWorkforce home directory, or + * any directory the caller passes explicitly. + * + * Translation from a resolved persona to `{bin, args}` delegates to + * `@agentworkforce/harness-kit#buildInteractiveSpec`, so relay always + * produces the same launch args the AgentWorkforce CLI does. + * + * The schema mirrors the AgentWorkforce persona format + * (see https://github.com/AgentWorkforce/workforce). Skills installation, + * mount policy, sidecar markdown, input rendering, and routing profiles + * are deliberately not handled here — callers needing those should use + * the `agentworkforce` CLI directly. + */ + +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path'; + +import { + buildInteractiveSpec, + type BuildInteractiveSpecInput, + type InteractiveConfigFile, + type InteractiveSpec, +} from '@agentworkforce/harness-kit'; +import { + HARNESS_VALUES, + PERSONA_TIERS, + type Harness, + type McpServerSpec, + type PersonaPermissions, + type PersonaTier, +} from '@agentworkforce/workload-router'; + +// ── Re-exports for callers ───────────────────────────────────────────────── + +export type { Harness, McpServerSpec, PersonaPermissions, PersonaTier }; +export { HARNESS_VALUES, PERSONA_TIERS }; + +// ── On-disk persona schema (permissive, like workforce's LocalPersonaOverride) ── + +export interface PersonaTierSpec { + harness?: Harness; + model?: string; + systemPrompt?: string; + /** Free-form harness settings (reasoning level, timeout) — not consumed by spawnPty today. */ + harnessSettings?: Record; +} + +/** Raw persona file shape. */ +export interface PersonaFile { + id: string; + intent?: string; + description?: string; + tags?: string[]; + /** Used for every tier when set without `tiers`. Ignored when `tiers` is set. */ + systemPrompt?: string; + /** Top-level harness/model — used when there are no tiers. */ + harness?: Harness; + model?: string; + permissions?: PersonaPermissions; + mcpServers?: Record; + /** Per-tier overrides. A tier set here takes precedence over the top-level fields. */ + tiers?: Partial>; + /** Inherits from another persona id (looked up in the same search dirs). One level deep. */ + extends?: string; +} + +/** A persona file located on disk. */ +export interface DiscoveredPersona { + id: string; + path: string; + spec: PersonaFile; +} + +/** A persona resolved against a tier — ready for {@link buildPersonaSpawnSpec}. */ +export interface ResolvedPersona { + id: string; + /** Absolute path to the JSON file the spec came from. */ + source: string; + tier: PersonaTier; + harness: Harness; + model: string; + systemPrompt: string; + description?: string; + permissions?: PersonaPermissions; + mcpServers?: Record; +} + +export interface PersonaLoadOptions { + cwd?: string; + /** Override the default search-dir cascade. */ + searchDirs?: string[]; + /** Extra dirs appended after the default cascade. */ + extraDirs?: string[]; + /** Tier to resolve. Defaults to 'best'. */ + tier?: PersonaTier; +} + +/** + * The shape `AgentRelay.spawnPersona` needs to drive `spawnPty`. Built by + * {@link buildPersonaSpawnSpec} from a {@link ResolvedPersona}. + */ +export interface PersonaSpawnSpec { + /** CLI to launch (matches relay's AgentCli union: 'claude' | 'codex' | 'opencode'). */ + cli: string; + model: string; + args: string[]; + /** + * If non-null, append this as the final positional arg to the CLI invocation. + * Codex uses this to carry the system prompt; claude / opencode return null. + */ + initialPrompt: string | null; + /** + * Files the caller must materialize (relative to spawn cwd) before launching + * the agent. Used by opencode to drop an `opencode.json` carrying the + * persona's agent definition. Empty for claude / codex. + */ + configFiles: InteractiveConfigFile[]; + /** Non-fatal warnings from the harness-kit translation step. */ + warnings: string[]; +} + +// ── Default search dirs ──────────────────────────────────────────────────── + +function expandHome(p: string): string { + if (p === '~') return homedir(); + if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2)); + return p; +} + +/** + * The default cascade. First match wins for a given persona id. + * + * 1. `/agentworkforce/personas` (the path most projects pick) + * 2. `/.agentworkforce/workforce/personas` (workforce CLI's project default) + * 3. `/agentworkforce/workforce/personas` (alt workforce layout) + * 4. `$AGENT_WORKFORCE_HOME/personas` if set, else `~/.agentworkforce/workforce/personas` + */ +export function defaultPersonaSearchDirs(cwd: string = process.cwd()): string[] { + const dirs: string[] = [ + join(cwd, 'agentworkforce', 'personas'), + join(cwd, '.agentworkforce', 'workforce', 'personas'), + join(cwd, 'agentworkforce', 'workforce', 'personas'), + ]; + + const home = process.env.AGENT_WORKFORCE_HOME?.trim(); + if (home) { + dirs.push(join(expandHome(home), 'personas')); + } else { + dirs.push(join(homedir(), '.agentworkforce', 'workforce', 'personas')); + } + + return dirs; +} + +function effectiveSearchDirs(options: PersonaLoadOptions): string[] { + const cwd = options.cwd ?? process.cwd(); + const base = options.searchDirs + ? options.searchDirs.map((d) => normalizeDir(d, cwd)) + : defaultPersonaSearchDirs(cwd); + const extras = (options.extraDirs ?? []).map((d) => normalizeDir(d, cwd)); + return dedupe([...base, ...extras]); +} + +function normalizeDir(input: string, cwd: string): string { + const expanded = expandHome(input.trim()); + return isAbsolute(expanded) ? resolvePath(expanded) : resolvePath(cwd, expanded); +} + +function dedupe(items: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const item of items) { + if (seen.has(item)) continue; + seen.add(item); + out.push(item); + } + return out; +} + +// ── Discovery ────────────────────────────────────────────────────────────── + +/** + * List every persona discoverable across the search-dir cascade. When the + * same id appears in multiple dirs, only the first match (by cascade order) + * is returned. + */ +export function listPersonas(options: PersonaLoadOptions = {}): DiscoveredPersona[] { + const dirs = effectiveSearchDirs(options); + const byId = new Map(); + for (const dir of dirs) { + if (!existsSync(dir)) continue; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const file of entries) { + if (!file.endsWith('.json')) continue; + const path = join(dir, file); + try { + if (!statSync(path).isFile()) continue; + } catch { + continue; + } + let spec: PersonaFile; + try { + spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path); + } catch { + continue; + } + if (!byId.has(spec.id)) { + byId.set(spec.id, { id: spec.id, path, spec }); + } + } + } + return [...byId.values()]; +} + +/** + * Find a persona file by id across the search-dir cascade. + * Returns undefined if not found. + */ +export function findPersona( + id: string, + options: PersonaLoadOptions = {}, +): DiscoveredPersona | undefined { + const dirs = effectiveSearchDirs(options); + for (const dir of dirs) { + if (!existsSync(dir)) continue; + const candidate = join(dir, `${id}.json`); + if (existsSync(candidate)) { + try { + const spec = parsePersonaFile(JSON.parse(readFileSync(candidate, 'utf8')), candidate); + if (spec.id === id) return { id, path: candidate, spec }; + } catch { + // fall through to scan dir for files with mismatched filenames + } + } + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const file of entries) { + if (!file.endsWith('.json')) continue; + const path = join(dir, file); + try { + const spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path); + if (spec.id === id) return { id, path, spec }; + } catch { + continue; + } + } + } + return undefined; +} + +// ── Resolution ───────────────────────────────────────────────────────────── + +/** + * Load and resolve a persona by id. Searches the cascade, applies the + * chosen tier, and resolves a single level of `extends` against the same + * cascade. Throws if the persona is missing required fields for the tier. + */ +export function loadPersona(id: string, options: PersonaLoadOptions = {}): ResolvedPersona { + const discovered = findPersona(id, options); + if (!discovered) { + const dirs = effectiveSearchDirs(options); + throw new Error( + `Persona "${id}" not found. Searched:\n ${dirs.join('\n ')}\n` + + 'Set searchDirs / extraDirs to include the directory containing the persona file.', + ); + } + + const tier: PersonaTier = options.tier ?? 'best'; + let spec = discovered.spec; + + if (spec.extends) { + const base = findPersona(spec.extends, options); + if (!base) { + throw new Error( + `Persona "${id}" extends "${spec.extends}" but the base could not be found in the search cascade.`, + ); + } + spec = mergeSpecs(base.spec, spec); + } + + const tierSpec = spec.tiers?.[tier]; + const harness = (tierSpec?.harness ?? spec.harness) as Harness | undefined; + const model = tierSpec?.model ?? spec.model; + const systemPrompt = tierSpec?.systemPrompt ?? spec.systemPrompt; + + if (!harness) { + throw new Error( + `Persona "${id}" tier "${tier}" has no harness; set tiers.${tier}.harness or top-level harness.`, + ); + } + if (!HARNESS_VALUES.includes(harness)) { + throw new Error( + `Persona "${id}" tier "${tier}" uses unsupported harness "${String(harness)}". ` + + `Supported: ${HARNESS_VALUES.join(', ')}.`, + ); + } + if (!model) { + throw new Error( + `Persona "${id}" tier "${tier}" has no model; set tiers.${tier}.model or top-level model.`, + ); + } + if (!systemPrompt) { + throw new Error( + `Persona "${id}" tier "${tier}" has no systemPrompt; set tiers.${tier}.systemPrompt or top-level systemPrompt.`, + ); + } + + return { + id: spec.id, + source: discovered.path, + tier, + harness, + model, + systemPrompt, + description: spec.description, + permissions: spec.permissions, + mcpServers: spec.mcpServers, + }; +} + +// ── Merge (extends) ──────────────────────────────────────────────────────── + +function mergeSpecs(base: PersonaFile, override: PersonaFile): PersonaFile { + const tiers: PersonaFile['tiers'] = {}; + for (const tier of PERSONA_TIERS) { + const baseTier = base.tiers?.[tier]; + const overrideTier = override.tiers?.[tier]; + if (overrideTier || baseTier) { + tiers[tier] = { ...(baseTier ?? {}), ...(overrideTier ?? {}) }; + } + } + + return { + id: override.id, + intent: override.intent ?? base.intent, + description: override.description ?? base.description, + tags: override.tags ?? base.tags, + systemPrompt: override.systemPrompt ?? base.systemPrompt, + harness: override.harness ?? base.harness, + model: override.model ?? base.model, + permissions: mergePermissions(base.permissions, override.permissions), + mcpServers: { ...(base.mcpServers ?? {}), ...(override.mcpServers ?? {}) }, + tiers: Object.keys(tiers).length > 0 ? tiers : undefined, + }; +} + +function mergePermissions( + base: PersonaPermissions | undefined, + override: PersonaPermissions | undefined, +): PersonaPermissions | undefined { + if (!base && !override) return undefined; + const allow = dedupe([...(base?.allow ?? []), ...(override?.allow ?? [])]); + const deny = dedupe([...(base?.deny ?? []), ...(override?.deny ?? [])]); + return { + ...(allow.length > 0 ? { allow } : {}), + ...(deny.length > 0 ? { deny } : {}), + ...(override?.mode ?? base?.mode ? { mode: override?.mode ?? base?.mode } : {}), + }; +} + +// ── Validation ───────────────────────────────────────────────────────────── + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parsePersonaFile(value: unknown, source: string): PersonaFile { + if (!isPlainObject(value)) { + throw new Error(`${source}: persona must be a JSON object`); + } + if (typeof value.id !== 'string' || !value.id.trim()) { + throw new Error(`${source}: persona.id must be a non-empty string`); + } + return value as unknown as PersonaFile; +} + +// ── Translation: persona → spawn args ────────────────────────────────────── + +/** + * Translate a resolved persona into the bin/args spawnPty needs. Delegates + * to {@link buildInteractiveSpec} from `@agentworkforce/harness-kit` so + * relay produces the same launch shape the AgentWorkforce CLI does. + */ +export function buildPersonaSpawnSpec(persona: ResolvedPersona): PersonaSpawnSpec { + const input: BuildInteractiveSpecInput = { + harness: persona.harness, + personaId: persona.id, + model: persona.model, + systemPrompt: persona.systemPrompt, + ...(persona.mcpServers ? { mcpServers: persona.mcpServers } : {}), + ...(persona.permissions ? { permissions: persona.permissions } : {}), + }; + const spec: InteractiveSpec = buildInteractiveSpec(input); + return { + cli: spec.bin, + model: persona.model, + args: [...spec.args], + initialPrompt: spec.initialPrompt, + configFiles: [...spec.configFiles], + warnings: [...spec.warnings], + }; +} + +/** + * Codex has no system-prompt flag, so the persona's instructions must ride + * on the task. Combines them in the same shape the agentworkforce + * harness-kit uses for non-interactive codex runs. + */ +export function composePersonaTask( + spec: Pick, + userTask: string | undefined, +): string | undefined { + if (!spec.initialPrompt) return userTask; + if (!userTask) return spec.initialPrompt; + return `${spec.initialPrompt}\n\nUser task:\n${userTask}`; +} + +// ── Config-file materialization helpers ──────────────────────────────────── + +/** Tracks a file we wrote, so the caller can restore the prior contents. */ +export interface MaterializedConfigFile { + /** Absolute path that was written. */ + path: string; + /** Whether a file existed at this path before we wrote. */ + existed: boolean; + /** Prior contents (only set when existed is true). */ + previous?: string; +} + +/** + * Write each persona config file into `cwd`. Refuses absolute paths or + * paths that escape `cwd`. Returns handles the caller can pass to + * {@link restorePersonaConfigFiles}. + */ +export function materializePersonaConfigFiles( + cwd: string, + files: readonly InteractiveConfigFile[], +): MaterializedConfigFile[] { + const out: MaterializedConfigFile[] = []; + const cwdAbs = resolvePath(cwd); + for (const file of files) { + if (!file.path) throw new Error('persona config file path must be non-empty'); + if (isAbsolute(file.path)) { + throw new Error(`persona config file path must be relative: ${file.path}`); + } + const target = resolvePath(cwd, file.path); + if (target !== cwdAbs && !target.startsWith(cwdAbs + '/')) { + throw new Error(`persona config file path escapes cwd: ${file.path}`); + } + const existed = existsSync(target); + const previous = existed ? readFileSync(target, 'utf8') : undefined; + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, file.contents, 'utf8'); + out.push({ path: target, existed, previous }); + } + return out; +} + +/** + * Restore the original state of files written by + * {@link materializePersonaConfigFiles}. Files that did not exist before + * are removed; files that did exist are written back to their prior + * contents. Errors are swallowed — restore is best-effort cleanup. + */ +export function restorePersonaConfigFiles(writes: readonly MaterializedConfigFile[]): void { + for (const write of [...writes].reverse()) { + try { + if (write.existed) { + writeFileSync(write.path, write.previous ?? '', 'utf8'); + } else { + rmSync(write.path, { force: true }); + } + } catch { + // best-effort + } + } +} diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index dc0a0b30e..ced7e3a6b 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -30,6 +30,16 @@ import path from 'node:path'; import { RelayCast } from '@relaycast/sdk'; import { AgentRelayClient, type AgentRelayBrokerInitArgs, type AgentRelaySpawnOptions } from './client.js'; +import { + buildPersonaSpawnSpec, + composePersonaTask, + loadPersona, + materializePersonaConfigFiles, + restorePersonaConfigFiles, + type PersonaLoadOptions, + type PersonaTier, + type ResolvedPersona, +} from './personas.js'; import { AgentRelayProtocolError } from './transport.js'; import type { SendMessageInput, SpawnPtyInput } from './types.js'; import type { @@ -248,6 +258,34 @@ export interface SpawnAndWaitOptions extends SpawnOptions { waitForMessage?: boolean; } +export interface SpawnPersonaOptions extends SpawnOptions { + /** Override the spawned agent's name. Defaults to the persona id. */ + name?: string; + /** Initial task / user prompt for the agent. */ + task?: string; + /** Persona tier to resolve. Defaults to 'best'. */ + tier?: PersonaTier; + /** + * Override the persona search-dir cascade. When set, the default + * directories (cwd/agentworkforce/personas, ~/.agentworkforce/...) are + * skipped and only `searchDirs` is consulted. + */ + searchDirs?: string[]; + /** Extra dirs appended after the default cascade (unioned with searchDirs override). */ + extraDirs?: string[]; + /** + * cwd to use when resolving relative search dirs. Defaults to the spawn + * cwd (`options.cwd`) when set, else `process.cwd()`. Independent of + * the spawn working directory passed to the broker. + */ + personaCwd?: string; + /** + * Override the resolved persona before translation. Useful for callers + * that want to load+adjust+spawn in one step (e.g. tweak permissions). + */ + persona?: ResolvedPersona; +} + type AgentOutputPayload = { stream: string; chunk: string }; type AgentOutputCallback = ((chunk: string) => void) | ((data: AgentOutputPayload) => void); @@ -354,6 +392,13 @@ export interface AgentRelayOptions { * Defaults to RELAYCAST_BASE_URL env var or https://api.relaycast.dev. */ relaycastBaseUrl?: string; + /** + * Default persona search-dir cascade for {@link AgentRelay.spawnPersona}. + * When set, replaces the built-in cascade + * (`/agentworkforce/personas`, `~/.agentworkforce/...`). Per-call + * `searchDirs` on `spawnPersona` still overrides this. + */ + personaDirs?: string[]; } type OutputListener = { @@ -413,6 +458,7 @@ export class AgentRelay { private readonly requestedWorkspaceId?: string; private readonly workspaceName?: string; private readonly relaycastBaseUrl?: string; + private readonly defaultPersonaDirs?: string[]; private relayApiKey?: string; private resolvedWorkspaceId?: string; private client?: AgentRelayClient; @@ -450,6 +496,7 @@ export class AgentRelay { ); } this.relaycastBaseUrl = options.relaycastBaseUrl; + if (options.personaDirs) this.defaultPersonaDirs = [...options.personaDirs]; this.clientOptions = { binaryPath: options.binaryPath, binaryArgs: options.binaryArgs, @@ -682,6 +729,85 @@ export class AgentRelay { return this.waitForAgentReady(name, timeoutMs ?? 60_000); } + /** + * Spawn an agent from a named AgentWorkforce persona. + * + * Looks up the persona JSON in the search-dir cascade + * (`/agentworkforce/personas`, `/.agentworkforce/workforce/personas`, + * `~/.agentworkforce/workforce/personas`, plus `AGENT_WORKFORCE_HOME`), + * resolves the requested tier, and translates it to spawnPty args via + * `@agentworkforce/harness-kit#buildInteractiveSpec`. + * + * For opencode, an `opencode.json` is materialized in the spawn cwd and + * automatically restored when the agent exits. For codex, the persona's + * systemPrompt is folded into the initial task (codex has no + * system-prompt flag). Translation warnings are surfaced via console.warn. + * + * @param personaId — id of the persona to load + * @param options — overrides for tier, search dirs, name, task, and the + * underlying spawn options + */ + async spawnPersona(personaId: string, options: SpawnPersonaOptions = {}): Promise { + const personaCwd = options.personaCwd ?? options.cwd ?? process.cwd(); + const searchDirs = options.searchDirs ?? this.defaultPersonaDirs; + const loadOpts: PersonaLoadOptions = { + cwd: personaCwd, + ...(searchDirs ? { searchDirs } : {}), + ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}), + ...(options.tier ? { tier: options.tier } : {}), + }; + const persona = options.persona ?? loadPersona(personaId, loadOpts); + const spec = buildPersonaSpawnSpec(persona); + + for (const warning of spec.warnings) { + console.warn(`[AgentRelay] ${warning}`); + } + + const spawnCwd = options.cwd ?? process.cwd(); + const writes = spec.configFiles.length > 0 + ? materializePersonaConfigFiles(spawnCwd, spec.configFiles) + : []; + + const baseArgs = options.args ?? []; + const mergedArgs = [...spec.args, ...baseArgs]; + const task = composePersonaTask(spec, options.task); + const spawnName = options.name ?? persona.id; + + let agent: Agent; + try { + agent = await this.spawnPty({ + name: spawnName, + cli: spec.cli, + args: mergedArgs, + ...(task !== undefined ? { task } : {}), + channels: options.channels, + model: spec.model, + cwd: spawnCwd, + team: options.team, + agentToken: options.agentToken, + shadowOf: options.shadowOf, + shadowMode: options.shadowMode, + idleThresholdSecs: options.idleThresholdSecs, + restartPolicy: options.restartPolicy, + skipRelayPrompt: options.skipRelayPrompt, + onStart: options.onStart, + onSuccess: options.onSuccess, + onError: options.onError, + }); + } catch (err) { + restorePersonaConfigFiles(writes); + throw err; + } + + if (writes.length > 0) { + void agent.waitForExit().finally(() => { + restorePersonaConfigFiles(writes); + }); + } + + return agent; + } + // ── Human source ──────────────────────────────────────────────────────── human(opts: { name: string }): HumanHandle { From 9b7a4c39e15d5bddb8365e310813eb612103094a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 8 May 2026 04:12:32 -0400 Subject: [PATCH 2/2] fix(sdk-personas): address PR review (Windows paths, TOCTOU, harness validation) - materializePersonaConfigFiles: replace hardcoded `cwdAbs + '/'` startsWith check with path.relative + path.sep so backslash-separated Windows paths aren't falsely rejected (P1 from codex / devin / coderabbit). - materializePersonaConfigFiles: collapse existsSync + readFileSync into a single readFileSync that interprets ENOENT as "did not exist", closing the CodeQL-flagged TOCTOU on line 469. - listPersonas / findPersona: drop statSync + existsSync prechecks for the same reason (CodeQL line 215). For convention-named persona files the parse error now propagates so a typo isn't silently reported as "not found". - parsePersonaFile: validate top-level and per-tier `harness` against HARNESS_VALUES so authoring errors surface at load time rather than spawn (per @barryollama review). - restorePersonaConfigFiles: log restore failures via console.warn rather than swallowing them (per @barryollama review). 3 new tests cover nested paths, invalid top-level harness, and invalid per-tier harness. 17 tests total, typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk/src/__tests__/personas.test.ts | 54 ++++++++++++ packages/sdk/src/personas.ts | 95 ++++++++++++++++----- 2 files changed, 129 insertions(+), 20 deletions(-) diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts index d7eaedada..6840e6500 100644 --- a/packages/sdk/src/__tests__/personas.test.ts +++ b/packages/sdk/src/__tests__/personas.test.ts @@ -326,3 +326,57 @@ test('materializePersonaConfigFiles rejects paths that escape cwd', () => { fix.cleanup(); } }); + +test('materializePersonaConfigFiles allows nested paths inside cwd', () => { + const fix = makeFixture(); + try { + const writes = materializePersonaConfigFiles(fix.cwd, [ + { path: 'sub/dir/opencode.json', contents: '{"nested":true}\n' }, + ]); + assert.equal(writes.length, 1); + assert.equal(readFileSync(writes[0]!.path, 'utf8'), '{"nested":true}\n'); + restorePersonaConfigFiles(writes); + assert.equal(existsSync(writes[0]!.path), false); + } finally { + fix.cleanup(); + } +}); + +test('parsePersonaFile rejects an invalid top-level harness at load time', () => { + const fix = makeFixture(); + try { + const dir = join(fix.cwd, 'agentworkforce', 'personas'); + writeFileSync( + join(dir, 'bad.json'), + JSON.stringify({ id: 'bad', harness: 'not-a-real-harness', model: 'x', systemPrompt: 'y' }), + ); + assert.throws( + () => loadPersona('bad', { cwd: fix.cwd }), + /persona\.harness must be one of/, + ); + } finally { + fix.cleanup(); + } +}); + +test('parsePersonaFile rejects an invalid harness inside a tier at load time', () => { + const fix = makeFixture(); + try { + const dir = join(fix.cwd, 'agentworkforce', 'personas'); + writeFileSync( + join(dir, 'bad-tier.json'), + JSON.stringify({ + id: 'bad-tier', + tiers: { + best: { harness: 'gpt-5', model: 'gpt-5', systemPrompt: 'x' }, + }, + }), + ); + assert.throws( + () => loadPersona('bad-tier', { cwd: fix.cwd }), + /persona\.tiers\.best\.harness must be one of/, + ); + } finally { + fix.cleanup(); + } +}); diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts index bc1468a3a..4be3072f1 100644 --- a/packages/sdk/src/personas.ts +++ b/packages/sdk/src/personas.ts @@ -18,9 +18,9 @@ * the `agentworkforce` CLI directly. */ -import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'; import { buildInteractiveSpec, @@ -205,13 +205,10 @@ export function listPersonas(options: PersonaLoadOptions = {}): DiscoveredPerson for (const file of entries) { if (!file.endsWith('.json')) continue; const path = join(dir, file); - try { - if (!statSync(path).isFile()) continue; - } catch { - continue; - } let spec: PersonaFile; try { + // Single read avoids a TOCTOU between stat and readFileSync — if the + // entry is a directory, vanished, or unreadable we skip it. spec = parsePersonaFile(JSON.parse(readFileSync(path, 'utf8')), path); } catch { continue; @@ -236,13 +233,20 @@ export function findPersona( for (const dir of dirs) { if (!existsSync(dir)) continue; const candidate = join(dir, `${id}.json`); - if (existsSync(candidate)) { - try { - const spec = parsePersonaFile(JSON.parse(readFileSync(candidate, 'utf8')), candidate); - if (spec.id === id) return { id, path: candidate, spec }; - } catch { - // fall through to scan dir for files with mismatched filenames - } + let candidateBytes: string | undefined; + try { + // Single read avoids a stat/read TOCTOU. ENOENT (file missing) falls + // through to a directory scan for personas with mismatched filenames; + // any other read failure or parse failure on a convention-named file + // surfaces directly so a typo in the JSON isn't silently treated as + // "persona not found". + candidateBytes = readFileSync(candidate, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + if (candidateBytes !== undefined) { + const spec = parsePersonaFile(JSON.parse(candidateBytes), candidate); + if (spec.id === id) return { id, path: candidate, spec }; } let entries: string[]; try { @@ -387,9 +391,38 @@ function parsePersonaFile(value: unknown, source: string): PersonaFile { if (typeof value.id !== 'string' || !value.id.trim()) { throw new Error(`${source}: persona.id must be a non-empty string`); } + // Validate harness values up front so a typo in the file fails at load time + // rather than at spawn — the runtime check in loadPersona stays as a + // defense-in-depth guard for callers that bypass parsing. + const topHarness = (value as { harness?: unknown }).harness; + if (topHarness !== undefined && !isValidHarness(topHarness)) { + throw new Error( + `${source}: persona.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(topHarness)})`, + ); + } + const tiers = (value as { tiers?: unknown }).tiers; + if (tiers !== undefined) { + if (!isPlainObject(tiers)) { + throw new Error(`${source}: persona.tiers must be an object if provided`); + } + for (const [tierName, tierSpec] of Object.entries(tiers)) { + if (!PERSONA_TIERS.includes(tierName as PersonaTier)) continue; // unknown tier names are ignored + if (!isPlainObject(tierSpec)) continue; + const harness = (tierSpec as { harness?: unknown }).harness; + if (harness !== undefined && !isValidHarness(harness)) { + throw new Error( + `${source}: persona.tiers.${tierName}.harness must be one of: ${HARNESS_VALUES.join(', ')} (got ${JSON.stringify(harness)})`, + ); + } + } + } return value as unknown as PersonaFile; } +function isValidHarness(value: unknown): value is Harness { + return typeof value === 'string' && (HARNESS_VALUES as readonly string[]).includes(value); +} + // ── Translation: persona → spawn args ────────────────────────────────────── /** @@ -460,14 +493,33 @@ export function materializePersonaConfigFiles( throw new Error(`persona config file path must be relative: ${file.path}`); } const target = resolvePath(cwd, file.path); - if (target !== cwdAbs && !target.startsWith(cwdAbs + '/')) { + // Use path.relative for separator-agnostic containment so Windows paths + // (`C:\proj\opencode.json`) aren't falsely rejected by a hardcoded '/' check. + const rel = relative(cwdAbs, target); + if (rel.startsWith('..') || (isAbsolute(rel) && rel !== '')) { + throw new Error(`persona config file path escapes cwd: ${file.path}`); + } + if (rel.split(sep).some((segment) => segment === '..')) { throw new Error(`persona config file path escapes cwd: ${file.path}`); } - const existed = existsSync(target); - const previous = existed ? readFileSync(target, 'utf8') : undefined; + + // Single read with ENOENT detection avoids a TOCTOU between `existsSync` + // and `readFileSync`. Any other read error (permissions, EISDIR) bubbles up + // — the caller can decide whether to retry or surface to the user. + let existed = true; + let previous: string | undefined; + try { + previous = readFileSync(target, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + existed = false; + } else { + throw err; + } + } mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, file.contents, 'utf8'); - out.push({ path: target, existed, previous }); + out.push({ path: target, existed, ...(previous !== undefined ? { previous } : {}) }); } return out; } @@ -486,8 +538,11 @@ export function restorePersonaConfigFiles(writes: readonly MaterializedConfigFil } else { rmSync(write.path, { force: true }); } - } catch { - // best-effort + } catch (err) { + // Best-effort: a failed restore shouldn't break the spawn lifecycle, but + // it can leave a stale opencode.json behind, so surface the failure. + const msg = (err as Error)?.message ?? String(err); + console.warn(`[personas] failed to restore ${write.path}: ${msg}`); } } }