diff --git a/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/summary.md b/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/summary.md new file mode 100644 index 000000000..7a146c37e --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/summary.md @@ -0,0 +1,46 @@ +# Trajectory: Remove personas from @agent-relay/sdk + +> **Status:** ✅ Completed +> **Task:** GH-997 +> **Confidence:** 86% +> **Started:** May 27, 2026 at 05:22 AM +> **Completed:** May 27, 2026 at 05:33 AM + +--- + +## Summary + +Removed SDK persona loading, spawn, dry-run, exports, dependency, tests, and example; updated migration notes and ensured SDK builds remove stale persona dist outputs before packing. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Removed SDK-owned persona APIs instead of relocating them in this repo + +- **Chose:** Removed SDK-owned persona APIs instead of relocating them in this repo +- **Reasoning:** Issue 997 asks for @agent-relay/sdk to know nothing about personas; there is already a separate packages/personas pack, while persona execution side effects should be owned by AgentWorkforce CLI or a workforce package. + +### Removed stale persona dist artifacts during SDK builds + +- **Chose:** Removed stale persona dist artifacts during SDK builds +- **Reasoning:** Deleting the source and package export is not enough if an existing dist/personas.js survives a package-level build; the SDK build now deletes the persona outputs before compiling so npm pack cannot include them. + +--- + +## Chapters + +### 1. Work + +_Agent: default_ + +- Removed SDK-owned persona APIs instead of relocating them in this repo: Removed SDK-owned persona APIs instead of relocating them in this repo +- Removed stale persona dist artifacts during SDK builds: Removed stale persona dist artifacts during SDK builds + +--- + +## Artifacts + +**Commits:** 6a456b7f diff --git a/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/trajectory.json b/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/trajectory.json new file mode 100644 index 000000000..0db8517ef --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-05/traj_kbxzde45cjlw/trajectory.json @@ -0,0 +1,69 @@ +{ + "id": "traj_kbxzde45cjlw", + "version": 1, + "task": { + "title": "Remove personas from @agent-relay/sdk", + "source": { + "system": "plain", + "id": "GH-997" + } + }, + "status": "completed", + "startedAt": "2026-05-27T09:22:37.342Z", + "completedAt": "2026-05-27T09:33:18.299Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-05-27T09:27:46.189Z" + } + ], + "chapters": [ + { + "id": "chap_m2xsvvdw25ze", + "title": "Work", + "agentName": "default", + "startedAt": "2026-05-27T09:27:46.189Z", + "endedAt": "2026-05-27T09:33:18.299Z", + "events": [ + { + "ts": 1779874066190, + "type": "decision", + "content": "Removed SDK-owned persona APIs instead of relocating them in this repo: Removed SDK-owned persona APIs instead of relocating them in this repo", + "raw": { + "question": "Removed SDK-owned persona APIs instead of relocating them in this repo", + "chosen": "Removed SDK-owned persona APIs instead of relocating them in this repo", + "alternatives": [], + "reasoning": "Issue 997 asks for @agent-relay/sdk to know nothing about personas; there is already a separate packages/personas pack, while persona execution side effects should be owned by AgentWorkforce CLI or a workforce package." + }, + "significance": "high" + }, + { + "ts": 1779874377461, + "type": "decision", + "content": "Removed stale persona dist artifacts during SDK builds: Removed stale persona dist artifacts during SDK builds", + "raw": { + "question": "Removed stale persona dist artifacts during SDK builds", + "chosen": "Removed stale persona dist artifacts during SDK builds", + "alternatives": [], + "reasoning": "Deleting the source and package export is not enough if an existing dist/personas.js survives a package-level build; the SDK build now deletes the persona outputs before compiling so npm pack cannot include them." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Removed SDK persona loading, spawn, dry-run, exports, dependency, tests, and example; updated migration notes and ensured SDK builds remove stale persona dist outputs before packing.", + "approach": "Standard approach", + "confidence": 0.86 + }, + "commits": ["6a456b7f"], + "filesChanged": [], + "projectId": "/Users/will/Projects/AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "3f2a3f9cd27b5d978e39d07302cf1c2f34ab85de", + "endRef": "3f2a3f9cd27b5d978e39d07302cf1c2f34ab85de" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe10f9c6..4f6446b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes - `@agent-relay/sdk` swaps `@agentworkforce/harness-kit` + `@agentworkforce/workload-router` for `@agentworkforce/persona-kit@^3`. The persona tier system, the `tier` option on `spawnPersona`, the legacy relay-side `PersonaFile` / `PersonaTier` / `PersonaTierSpec` / `ResolvedPersona` / `PersonaSpawnSpec` / `MaterializedConfigFile` types, and the `buildPersonaSpawnSpec` / `materializePersonaConfigFiles` / `restorePersonaConfigFiles` helpers are removed. `loadPersona` now returns the canonical `PersonaSpec`, and `spawnPersona({ persona })` takes a `PersonaSpec` instead of a resolved persona. +- `@agent-relay/sdk` removes persona support from the SDK surface: the `./personas` subpath, persona helper/type exports, `AgentRelay.spawnPersona()`, `AgentRelay.getPersonaSpawnPlan()`, and `AgentRelayOptions.personaDirs` are gone. The SDK no longer depends on `@agentworkforce/persona-kit`. - `agent-relay-broker`'s public Rust protocol types now require typed ID newtypes (`WorkerName`, `DeliveryId`, `EventId`, `WorkspaceId`, `WorkspaceAlias`, `ThreadId`, `AgentId`, `RequestId`, `ChannelName`, `MessageTarget`) on every protocol struct and enum variant in `protocol.rs`, `types.rs`, and `listen_api.rs::ListenApiRequest`. The new wrappers live in `crates/broker/src/lib.rs` under `pub mod ids`. JSON wire format is unchanged because every wrapper is `#[serde(transparent)]`, so the broker ↔ SDK channel and on-disk persisted state remain byte-compatible. - `agent-relay spawn` and SDK spawn calls now return harness `sessionId` metadata for resumable Claude and Codex PTY sessions. - `sdk-swift`: renamed the broker client class `RelayCast` → `AgentRelayClient`. @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Personas relying on `tiers.*` need to be flattened to a single top-level `harness` / `model` / `systemPrompt`. The shape that persona-kit (and the `agentworkforce` CLI) consumes is now the only supported shape. - Callers that previously used `spawnPersona` to "just launch the harness" — without persona-kit's skill / mount / sidecar side effects — should use `AgentRelay.getPersonaSpawnPlan(id)` to inspect the plan and call `spawnPty` with the plan's `cli` + `args` themselves. +- Launch personas through the owning CLI or package and pass the resulting command to `relay.spawnPty(...)` or the SDK's headless provider APIs; for AgentWorkforce personas, use `npx agentworkforce persona run ` once available so persona side effects remain CLI-owned. - Downstream Rust callers must construct identifiers via `relay_broker::ids::{WorkerName, DeliveryId, EventId, MessageTarget, …}` instead of `String`. Each newtype impls `From` / `From<&str>` and `Deref`, so most string-handling code keeps compiling; only construction sites (`HashMap` keys, struct literals, channel sends) need updates. - Replace ad-hoc target discrimination (`target.starts_with('#')`, `target == "thread"`) with `MessageTarget::kind()` and match on `MessageTargetKind::{Channel, Thread, DirectMessage, Conversation, Worker}`. - `sdk-swift`: replace `RelayCast(apiKey:baseURL:)` with `AgentRelayClient(apiKey:baseURL:)`. The public API surface is otherwise unchanged. diff --git a/package-lock.json b/package-lock.json index 83082b6d8..0859c4979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17199,7 +17199,6 @@ "@agent-relay/github-primitive": "7.1.1", "@agent-relay/slack-primitive": "7.1.1", "@agent-relay/workflow-types": "7.1.1", - "@agentworkforce/persona-kit": "^3.0.20", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", @@ -17262,27 +17261,6 @@ } } }, - "packages/sdk/node_modules/@agentworkforce/persona-kit": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/@agentworkforce/persona-kit/-/persona-kit-3.0.20.tgz", - "integrity": "sha512-TDsLR0caEOroyj2w5koSUMzxnwWns1uXVsS0jbc5ae0LkAZge6kRDAhqy1CLzpNW5IJg/LrmTu198SVzv0Zo2A==", - "dependencies": { - "@relayfile/local-mount": "^0.7.24" - } - }, - "packages/sdk/node_modules/@relayfile/local-mount": { - "version": "0.7.38", - "resolved": "https://registry.npmjs.org/@relayfile/local-mount/-/local-mount-0.7.38.tgz", - "integrity": "sha512-eRQmKQwexfstqW21B3YCI4sKQOEHGL/lLqSsE2FRA8rl67qPomugWPpyG9gliYIMkGKYQr7CG6e9Mghx12T1nw==", - "license": "MIT", - "dependencies": { - "@parcel/watcher": "^2.5.6", - "ignore": "^7.0.5" - }, - "engines": { - "node": ">=18" - } - }, "packages/slack-primitive": { "name": "@agent-relay/slack-primitive", "version": "7.1.1", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 04393a101..b4e5fb5d5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -27,11 +27,6 @@ "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", @@ -131,7 +126,7 @@ }, "scripts": { "prebuild": "npm --prefix ../github-primitive run build && npm --prefix ../slack-primitive run build && npm --prefix ../config run build && npm --prefix ../cloud run build", - "build": "npx tsc -p tsconfig.build.json", + "build": "node -e \"const fs=require('fs');for(const f of ['dist/personas.js','dist/personas.js.map','dist/personas.d.ts','dist/personas.d.ts.map'])fs.rmSync(f,{force:true})\" && npx tsc -p tsconfig.build.json", "build:full": "tsc -p tsconfig.json && npm run bundle:binary", "bundle:binary": "node ./scripts/bundle-agent-relay.mjs", "check": "tsc -p tsconfig.json --noEmit", @@ -153,7 +148,6 @@ "@agent-relay/config": "7.1.1", "@agent-relay/github-primitive": "7.1.1", "@agent-relay/slack-primitive": "7.1.1", - "@agentworkforce/persona-kit": "^3.0.20", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": ">=0.1.2 <1", "@sinclair/typebox": "^0.34.48", diff --git a/packages/sdk/src/__tests__/personas.test.ts b/packages/sdk/src/__tests__/personas.test.ts deleted file mode 100644 index eae080600..000000000 --- a/packages/sdk/src/__tests__/personas.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * Persona loader + spawn-plan tests. - * - * Persona-kit owns the spawn-plan and execution surface; the tests here - * cover the relay-specific discovery cascade, the parsed PersonaSpec - * round-trip, and the AgentRelay.spawnPersona / getPersonaSpawnPlan - * methods. - */ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { assert, test } from 'vitest'; - -import { - composePersonaTask, - defaultPersonaSearchDirs, - findPersona, - getPersonaSpawnPlan, - listPersonas, - loadPersona, - resolvePersona, -} from '../personas.js'; -import { AgentRelay } from '../relay.js'; - -interface PersonaJsonOptions { - id: string; - intent?: string; - harness?: 'claude' | 'codex' | 'opencode'; - model?: string; - systemPrompt?: string; - description?: string; - extras?: Record; -} - -function personaJson(opts: PersonaJsonOptions): Record { - return { - id: opts.id, - intent: opts.intent ?? opts.id, - description: opts.description ?? `${opts.id} fixture`, - harness: opts.harness ?? 'claude', - model: opts.model ?? 'claude-opus-4-6', - systemPrompt: opts.systemPrompt ?? `You are ${opts.id}.`, - skills: [], - harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, - ...(opts.extras ?? {}), - }; -} - -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( - personaJson({ - id: 'frontend', - intent: 'implement-frontend', - harness: 'claude', - model: 'claude-opus-4-6', - systemPrompt: 'You are a senior frontend engineer.', - extras: { - permissions: { allow: ['Bash(npm test)'], mode: 'default' }, - }, - }) - ) - ); - - writeFileSync( - join(dir, 'codex-reviewer.json'), - JSON.stringify( - personaJson({ - id: 'codex-reviewer', - intent: 'review', - harness: 'codex', - model: 'openai-codex/gpt-5-codex', - systemPrompt: 'You are an efficient code reviewer.', - }) - ) - ); - - writeFileSync( - join(dir, 'opencode-nano.json'), - JSON.stringify( - personaJson({ - id: 'opencode-nano', - intent: 'review', - harness: 'opencode', - model: 'opencode/gpt-5-nano', - systemPrompt: 'You are a concise reviewer.', - }) - ) - ); - - 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, ['codex-reviewer', 'frontend', 'opencode-nano']); - } 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 returns the parsed PersonaSpec verbatim', () => { - const fix = makeFixture(); - try { - const spec = loadPersona('frontend', { cwd: fix.cwd }); - assert.equal(spec.id, 'frontend'); - assert.equal(spec.harness, 'claude'); - assert.equal(spec.model, 'claude-opus-4-6'); - assert.match(spec.systemPrompt, /senior frontend engineer/); - assert.deepEqual(spec.permissions?.allow, ['Bash(npm test)']); - } 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('loadPersona reports "not found" when no valid persona with that id exists', () => { - // A malformed file at the conventional name no longer blocks the cascade — - // it is treated the same as a malformed sibling file: skipped during the - // search, and "not found" is reported if no valid alternative exists. This - // is the cascade behavior that lets a higher-priority shadow file with bad - // JSON not break a valid lower-priority persona of the same id. - const fix = makeFixture(); - try { - const dir = join(fix.cwd, 'agentworkforce', 'personas'); - writeFileSync( - join(dir, 'bad.json'), - JSON.stringify({ - id: 'bad', - intent: 'review', - description: 'bad fixture', - harness: 'not-a-harness', - model: 'x', - systemPrompt: 'x', - skills: [], - harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, - }) - ); - assert.throws(() => loadPersona('bad', { cwd: fix.cwd }), /not found/); - } finally { - fix.cleanup(); - } -}); - -test('resolvePersona rejects handler-style personas missing harness/model/systemPrompt', () => { - // persona-kit ≥3.0.20 made these fields optional for onEvent-driven personas. - // Relay only spawns interactive personas, so the guard must fire with a - // clear error rather than producing a malformed ResolvedPersona. - assert.throws( - () => - resolvePersona({ - id: 'handler-only', - intent: 'review', - description: 'cloud handler persona', - skills: [], - harnessSettings: { reasoning: 'medium', timeoutSeconds: 900 }, - } as unknown as Parameters[0]), - /no harness/ - ); -}); - -test('resolvePersona projects PersonaSpec into a PersonaSelection-shaped ResolvedPersona', () => { - const fix = makeFixture(); - try { - const spec = loadPersona('frontend', { cwd: fix.cwd }); - const resolved = resolvePersona(spec); - assert.equal(resolved.personaId, 'frontend'); - assert.equal(resolved.harness, 'claude'); - assert.equal(resolved.model, 'claude-opus-4-6'); - assert.equal(resolved.rationale, ''); - assert.deepEqual(resolved.permissions?.allow, ['Bash(npm test)']); - } finally { - fix.cleanup(); - } -}); - -test('getPersonaSpawnPlan for claude includes system prompt and harness argv', () => { - const fix = makeFixture(); - try { - const plan = getPersonaSpawnPlan('frontend', { cwd: fix.cwd }); - assert.equal(plan.cli, 'claude'); - assert.equal(plan.persona.model, 'claude-opus-4-6'); - assert.ok(plan.args.includes('--append-system-prompt')); - const promptIdx = plan.args.indexOf('--append-system-prompt'); - assert.match(plan.args[promptIdx + 1] ?? '', /senior frontend engineer/); - assert.ok(plan.args.includes('--allowedTools')); - } finally { - fix.cleanup(); - } -}); - -test('getPersonaSpawnPlan for codex exposes initialPrompt and composePersonaTask folds it in', () => { - const fix = makeFixture(); - try { - const plan = getPersonaSpawnPlan('codex-reviewer', { cwd: fix.cwd }); - assert.equal(plan.cli, 'codex'); - assert.match(plan.initialPrompt ?? '', /efficient code reviewer/); - const taskWithPrompt = composePersonaTask(plan, 'Review the login PR.'); - assert.match(taskWithPrompt ?? '', /efficient code reviewer/); - assert.match(taskWithPrompt ?? '', /User task:\nReview the login PR\./); - } finally { - fix.cleanup(); - } -}); - -test('getPersonaSpawnPlan for opencode emits a config file with the persona prompt', () => { - const fix = makeFixture(); - try { - const plan = getPersonaSpawnPlan('opencode-nano', { cwd: fix.cwd }); - assert.equal(plan.cli, 'opencode'); - assert.ok(plan.configFiles.length > 0); - const opencodeConfig = plan.configFiles.find((f) => f.path === 'opencode.json'); - assert.ok(opencodeConfig, 'opencode.json config file should be emitted'); - const parsed = JSON.parse(opencodeConfig?.contents ?? '{}'); - assert.equal(parsed.agent['opencode-nano'].model, 'opencode/gpt-5-nano'); - assert.match(parsed.agent['opencode-nano'].prompt, /concise reviewer/); - } finally { - fix.cleanup(); - } -}); - -test('getPersonaSpawnPlan plan is JSON-serializable round-trip', () => { - const fix = makeFixture(); - try { - const plan = getPersonaSpawnPlan('frontend', { cwd: fix.cwd }); - const round = JSON.parse(JSON.stringify(plan)); - assert.deepEqual(round, plan); - } finally { - fix.cleanup(); - } -}); - -test('AgentRelay.getPersonaSpawnPlan honors personaDirs from the constructor', () => { - const fix = makeFixture(); - try { - const personaDir = join(fix.cwd, 'agentworkforce', 'personas'); - const relay = new AgentRelay({ personaDirs: [personaDir] }); - const plan = relay.getPersonaSpawnPlan('frontend'); - assert.equal(plan.cli, 'claude'); - assert.equal(plan.persona.personaId, 'frontend'); - } finally { - fix.cleanup(); - } -}); - -test('AgentRelay.spawnPersona honors constructor personaDirs and executes the plan', async () => { - const fix = makeFixture(); - try { - const personaDir = join(fix.cwd, 'agentworkforce', 'personas'); - const relay = new AgentRelay({ personaDirs: [personaDir] }); - - let captured: { cli?: string; cwd?: string; args?: string[]; model?: string } = {}; - (relay as unknown as { spawnPty: (input: unknown) => Promise }).spawnPty = async ( - input: unknown - ) => { - captured = input as typeof captured; - 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('codex-reviewer', { cwd: fix.cwd }); - - assert.equal(captured.cli, 'codex'); - assert.equal(captured.model, 'openai-codex/gpt-5-codex'); - } finally { - fix.cleanup(); - } -}); - -test('AgentRelay.getPersonaSpawnPlan honors options.persona, bypassing the search cascade', () => { - const fix = makeFixture(); - try { - const relay = new AgentRelay({ personaDirs: ['/nonexistent'] }); - const spec = loadPersona('frontend', { cwd: fix.cwd }); - const plan = relay.getPersonaSpawnPlan('frontend', { persona: spec }); - assert.equal(plan.cli, 'claude'); - assert.equal(plan.persona.personaId, 'frontend'); - } finally { - fix.cleanup(); - } -}); - -test('findPersona skips a malformed shadow file at the conventional path', () => { - const fix = makeFixture(); - const otherFix = makeFixture(); - try { - const shadowDir = join(otherFix.cwd, 'agentworkforce', 'personas'); - // Higher-priority shadow file with the conventional name but bad JSON. - writeFileSync(join(shadowDir, 'frontend.json'), '{ not valid json'); - const found = findPersona('frontend', { - cwd: fix.cwd, - searchDirs: [shadowDir, join(fix.cwd, 'agentworkforce', 'personas')], - }); - assert.ok(found, 'should fall through to the valid persona in the lower-priority dir'); - assert.match(found?.path ?? '', /frontend\.json$/); - assert.notEqual(found?.path, join(shadowDir, 'frontend.json')); - } finally { - otherFix.cleanup(); - 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', { - cwd: otherFix.cwd, - searchDirs: [join(otherFix.cwd, 'agentworkforce', 'personas')], - }); - - assert.equal(captured.cli, 'claude'); - } finally { - otherFix.cleanup(); - fix.cleanup(); - } -}); diff --git a/packages/sdk/src/examples/persona-spawn.ts b/packages/sdk/src/examples/persona-spawn.ts deleted file mode 100644 index 482f60436..000000000 --- a/packages/sdk/src/examples/persona-spawn.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 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.addListener('agentSpawned', (agent) => console.log(`spawned ${agent.name} (${agent.runtime})`)); -relay.addListener('agentExited', (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 829fb31eb..a4fa11d9a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -47,7 +47,6 @@ export * from './harness.js'; export * from './spawn-from-env.js'; export * from './cli-registry.js'; export * from './cli-resolver.js'; -export * from './personas.js'; export * as github from './github.js'; export { GitHubClient } from '@agent-relay/github-primitive'; export * as slack from './slack.js'; diff --git a/packages/sdk/src/personas.ts b/packages/sdk/src/personas.ts deleted file mode 100644 index 1007ef327..000000000 --- a/packages/sdk/src/personas.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Persona loading and spawn-plan construction. - * - * A persona is a JSON file that describes a pre-configured agent: harness, - * model, system prompt, skills, MCP servers, mount policy, sidecar - * markdown, inputs, and per-spawn env. Personas live in - * `/agentworkforce/personas`, the AgentWorkforce home directory, or - * any directory the caller passes explicitly. - * - * The persona schema is owned by `@agentworkforce/persona-kit` and is the - * same shape the `agentworkforce` CLI consumes. This module owns the - * relay-specific search-dir cascade and file discovery; it delegates - * everything else (parsing, spawn-plan construction, side-effect execution) - * to persona-kit so relay and the workforce CLI behave identically. - */ - -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { isAbsolute, join, resolve as resolvePath } from 'node:path'; - -import { - HARNESS_VALUES, - buildPersonaSpawnPlan, - executePersonaSpawnPlan, - isIntent, - parsePersonaSpec, - resolveSidecar, - sidecarSelectionFields, - type ExecuteOptions, - type ExecutionHandle, - type Harness, - type McpServerSpec, - type PersonaInputSpec, - type PersonaMount, - type PersonaPermissions, - type PersonaSkill, - type PersonaSpawnPlan, - type PersonaSpec, - type PlanOptions, - type ResolvedPersona, - type SkillMaterializationPlan, -} from '@agentworkforce/persona-kit'; - -// ── Re-exports for SDK consumers ─────────────────────────────────────────── - -export { HARNESS_VALUES, buildPersonaSpawnPlan, executePersonaSpawnPlan }; - -export type { - ExecuteOptions, - ExecutionHandle, - Harness, - McpServerSpec, - PersonaInputSpec, - PersonaMount, - PersonaPermissions, - PersonaSkill, - PersonaSpawnPlan, - PersonaSpec, - PlanOptions, - ResolvedPersona, - SkillMaterializationPlan, -}; - -// ── Discovery types ──────────────────────────────────────────────────────── - -export interface PersonaLoadOptions { - cwd?: string; - /** Override the default search-dir cascade. */ - searchDirs?: string[]; - /** Extra dirs appended after the default cascade. */ - extraDirs?: string[]; -} - -/** A persona file located on disk. */ -export interface DiscoveredPersona { - id: string; - path: string; - spec: PersonaSpec; -} - -// ── 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; -} - -// ── Parsing ──────────────────────────────────────────────────────────────── - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function parsePersonaJson(raw: unknown, source: string): PersonaSpec { - if (!isPlainObject(raw)) { - throw new Error(`${source}: persona must be a JSON object`); - } - const intent = raw.intent; - if (typeof intent !== 'string' || !intent.trim()) { - throw new Error(`${source}: persona.intent must be a non-empty string`); - } - if (!isIntent(intent)) { - throw new Error(`${source}: persona.intent "${intent}" is not a known PersonaIntent`); - } - // persona-kit's parser cross-checks the file's declared intent against an - // "expected" intent; relay loads personas by id so it has no expected intent - // — feed the file's own intent back in to make the mismatch check a no-op - // while keeping the rest of the schema validation. - return parsePersonaSpec(raw, intent); -} - -// ── 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); - let spec: PersonaSpec; - try { - // Single read avoids a TOCTOU between stat and readFileSync — if the - // entry is a directory, vanished, or unreadable we skip it. - spec = parsePersonaJson(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`); - let candidateBytes: string | undefined; - try { - candidateBytes = readFileSync(candidate, 'utf8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - } - if (candidateBytes !== undefined) { - try { - const spec = parsePersonaJson(JSON.parse(candidateBytes), candidate); - if (spec.id === id) return { id, path: candidate, spec }; - } catch { - // A bad shadow file at the conventional path shouldn't block a - // valid persona lower in the cascade. The directory-scan fallback - // below already tolerates parse failures the same way. - } - } - 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 = parsePersonaJson(JSON.parse(readFileSync(path, 'utf8')), path); - if (spec.id === id) return { id, path, spec }; - } catch { - continue; - } - } - } - return undefined; -} - -/** - * Load a persona by id from the search-dir cascade. Returns the parsed - * {@link PersonaSpec} verbatim — callers wanting a spawn plan should pass - * the result to {@link resolvePersona} + {@link buildPersonaSpawnPlan}, - * or use {@link getPersonaSpawnPlan} as a one-shot. - */ -export function loadPersona(id: string, options: PersonaLoadOptions = {}): PersonaSpec { - 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.' - ); - } - return discovered.spec; -} - -// ── Resolution ───────────────────────────────────────────────────────────── - -/** - * Project a {@link PersonaSpec} (the on-disk form) into a - * {@link ResolvedPersona} (persona-kit's spawn-input form). Used as glue - * between {@link loadPersona} and {@link buildPersonaSpawnPlan}. - * - * persona-kit ≥3.0.20 makes `harness` / `model` / `systemPrompt` optional - * on {@link PersonaSpec} for handler-style (`onEvent`-driven) personas - * that never spawn a harness directly. Relay only spawns interactive - * personas, so the missing fields are rejected with a clear error rather - * than letting the cast fail silently downstream. - * - * Relay has no routing/selection layer, so the `rationale` field is left - * empty. - */ -export function resolvePersona(spec: PersonaSpec): ResolvedPersona { - const { harness, model, systemPrompt } = spec; - if (!harness) { - throw new Error( - `Persona "${spec.id}" has no harness — relay only spawns interactive personas. ` + - 'Handler-style (onEvent-driven) personas should be deployed via the workforce CLI.' - ); - } - if (!model) { - throw new Error(`Persona "${spec.id}" has no model.`); - } - if (!systemPrompt) { - throw new Error(`Persona "${spec.id}" has no systemPrompt.`); - } - const sidecar = resolveSidecar(spec); - return { - personaId: spec.id, - harness, - model, - systemPrompt, - harnessSettings: spec.harnessSettings, - skills: spec.skills, - rationale: '', - ...(spec.inputs ? { inputs: spec.inputs } : {}), - ...(spec.env ? { env: spec.env } : {}), - ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), - ...(spec.permissions ? { permissions: spec.permissions } : {}), - ...(spec.mount ? { mount: spec.mount } : {}), - ...sidecarSelectionFields(sidecar), - }; -} - -export interface PersonaSpawnPlanOptions extends PersonaLoadOptions, PlanOptions {} - -/** - * One-shot helper: load a persona by id and build its spawn plan. The plan - * describes everything the persona would do at spawn (skills installs, - * mount policy, sidecar writes, harness argv, env) without executing any - * of it. Useful for authoring tools, validators, and dry-runs. - */ -export function getPersonaSpawnPlan( - personaId: string, - options: PersonaSpawnPlanOptions = {} -): PersonaSpawnPlan { - const spec = loadPersona(personaId, options); - const resolved = resolvePersona(spec); - const planOptions: PlanOptions = {}; - if (options.installRoot !== undefined) planOptions.installRoot = options.installRoot; - if (options.envOverrides !== undefined) planOptions.envOverrides = options.envOverrides; - if (options.inputValues !== undefined) planOptions.inputValues = options.inputValues; - if (options.processEnv !== undefined) planOptions.processEnv = options.processEnv; - if (options.includeProcessEnv !== undefined) planOptions.includeProcessEnv = options.includeProcessEnv; - return buildPersonaSpawnPlan(resolved, planOptions); -} - -// ── Codex initial-prompt composition ─────────────────────────────────────── - -/** - * Codex has no system-prompt flag, so the persona's instructions ride on - * the task. {@link buildPersonaSpawnPlan} exposes the persona's resolved - * prompt as `plan.initialPrompt` for that case; everything else returns - * `undefined` and the user task passes through unchanged. - */ -export function composePersonaTask( - plan: Pick, - userTask: string | undefined -): string | undefined { - if (!plan.initialPrompt) return userTask; - if (!userTask) return plan.initialPrompt; - return `${plan.initialPrompt}\n\nUser task:\n${userTask}`; -} diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index c7792a39a..da3d1c0e6 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -40,19 +40,6 @@ import { type ResolvedHarnessConfig, } from './harness.js'; import type { AgentRelayEvents, BeforeAgentSpawnHandler } from './lifecycle-hooks.js'; -import { - buildPersonaSpawnPlan, - composePersonaTask, - executePersonaSpawnPlan, - getPersonaSpawnPlan as getPersonaSpawnPlanImpl, - loadPersona, - resolvePersona, - type ExecuteOptions, - type PersonaLoadOptions, - type PersonaSpawnPlan, - type PersonaSpawnPlanOptions, - type PersonaSpec, -} from './personas.js'; import { AgentRelayProtocolError } from './transport.js'; import type { JsonSchema, SendMessageInput, SpawnAgentResult, SpawnPtyInput } from './types.js'; import type { @@ -316,49 +303,6 @@ export interface SpawnAndWaitOptions extends SpawnOption 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; - /** - * 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 parsed persona before spawn-plan construction. Useful for - * callers that want to load+adjust+spawn in one step (e.g. tweak - * permissions or skills). - */ - persona?: PersonaSpec; - /** - * Stage skills under this absolute directory instead of the repo's - * `.claude/skills/`. Claude harness only — persona-kit throws otherwise. - */ - skillsInstallRoot?: string; - /** Caller-supplied values for persona inputs (highest precedence). */ - inputValues?: Record; - /** Extra env bindings to merge into `plan.env` (persona env wins on conflict). */ - envOverrides?: Record; - /** - * Whether the executor should remove `.claude/skills/` etc. on dispose. - * Defaults to persona-kit's default (true). - */ - cleanupSkillsOnDispose?: boolean; - /** Mount-specific options forwarded to persona-kit's executor. */ - mount?: ExecuteOptions['mount']; -} - type AgentOutputPayload = { stream: string; chunk: string }; type AgentOutputCallback = ((chunk: string) => void) | ((data: AgentOutputPayload) => void); @@ -481,13 +425,6 @@ 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[]; /** * Named harness definitions. Definitions are SDK-side shortcuts that render * to broker-executable JSON configs before each spawn. @@ -614,7 +551,6 @@ export class AgentRelay { private readonly requestedWorkspaceId?: string; private readonly workspaceName?: string; private readonly relaycastBaseUrl?: string; - private readonly defaultPersonaDirs?: string[]; private readonly harnesses: Record; private relayApiKey?: string; private resolvedWorkspaceId?: string; @@ -657,7 +593,6 @@ export class AgentRelay { ); } this.relaycastBaseUrl = options.relaycastBaseUrl; - if (options.personaDirs) this.defaultPersonaDirs = [...options.personaDirs]; this.harnesses = { ...(options.harnesses ?? {}) }; this.clientOptions = { binaryPath: options.binaryPath, @@ -970,153 +905,6 @@ export class AgentRelay { return this.waitForAgentReady(name, timeoutMs ?? 60_000) as Promise>; } - /** - * 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`), - * then runs the full `@agentworkforce/persona-kit` spawn lifecycle: - * installs the persona's skills, applies its mount policy, writes its - * `CLAUDE.md` / `AGENTS.md` sidecars and any harness config files, and - * resolves its declared inputs. The agent is then launched with the - * harness argv and prompt the persona-kit plan produced. - * - * When the spawned agent exits, every side effect is reversed in LIFO - * order (skills uninstalled, sidecars restored, mount unmounted, etc.). - * A failure during the plan execution aborts before `spawnPty` runs and - * leaves no partial state. - * - * @param personaId — id of the persona to load - * @param options — overrides for search dirs, name, task, inputs, 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 } : {}), - }; - const spec = options.persona ?? loadPersona(personaId, loadOpts); - const resolved = resolvePersona(spec); - const planOptions = { - ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), - ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), - ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), - }; - const plan = buildPersonaSpawnPlan(resolved, planOptions); - - const spawnCwd = options.cwd ?? process.cwd(); - const executeOptions: ExecuteOptions = { - cwd: spawnCwd, - ...(options.cleanupSkillsOnDispose !== undefined - ? { cleanupSkillsOnDispose: options.cleanupSkillsOnDispose } - : {}), - ...(options.mount ? { mount: options.mount } : {}), - }; - const handle = await executePersonaSpawnPlan(plan, executeOptions); - - // Persona-kit's plan.env carries persona-author env + resolved inputs. - // The broker spawnPty API does not yet accept a per-spawn env; until - // that lands, surface any env beyond the broker's effective ambient - // env so callers know they're being dropped. - const brokerEnv = this.clientOptions.env ?? process.env; - const droppedEnv = Object.keys(plan.env ?? {}).filter((key) => brokerEnv[key] !== plan.env[key]); - if (droppedEnv.length > 0) { - console.warn( - `[AgentRelay] persona "${spec.id}" declares env vars not forwardable through ` + - `the broker today: ${droppedEnv.join(', ')}. Set them in the spawning ` + - 'process env or pass them via options.envOverrides + set them yourself.' - ); - } - - const baseArgs = options.args ?? []; - const mergedArgs = [...plan.args, ...baseArgs]; - const task = composePersonaTask(plan, options.task); - const spawnName = options.name ?? spec.id; - - let agent: Agent; - try { - agent = await this.spawnPty({ - name: spawnName, - cli: plan.cli, - args: mergedArgs, - ...(task !== undefined ? { task } : {}), - channels: options.channels, - model: options.model ?? plan.persona.model, - cwd: handle.cwd, - team: options.team, - agentToken: options.agentToken, - shadowOf: options.shadowOf, - shadowMode: options.shadowMode, - idleThresholdSecs: options.idleThresholdSecs, - restartPolicy: options.restartPolicy, - harnessConfig: options.harnessConfig, - skipRelayPrompt: options.skipRelayPrompt, - result: options.result, - onStart: options.onStart, - onSuccess: options.onSuccess, - onError: options.onError, - }); - } catch (err) { - // Reverse persona side effects, but never let a dispose failure mask - // the original spawn error — that's the actionable one for callers. - try { - await handle.dispose(); - } catch (disposeErr) { - const msg = (disposeErr as Error)?.message ?? String(disposeErr); - console.warn(`[AgentRelay] persona "${spec.id}" dispose after spawn failure failed: ${msg}`); - } - throw err; - } - - void agent.waitForExit().finally(() => { - handle.dispose().catch((e: unknown) => { - const msg = (e as Error)?.message ?? String(e); - console.warn(`[AgentRelay] persona "${spec.id}" dispose failed: ${msg}`); - }); - }); - - return agent; - } - - /** - * Build a {@link PersonaSpawnPlan} for the persona without executing it. - * Useful for authoring tools, validators, and dry-runs that want to - * inspect the persona's skill installs, mount policy, sidecars, and - * harness argv before committing to a spawn. - * - * Honors `options.persona` the same way {@link spawnPersona} does — a - * caller-supplied {@link PersonaSpec} short-circuits the search-dir - * cascade so dry-runs match what `spawnPersona` would actually execute. - * - * Performs no filesystem writes and spawns no subprocesses. - */ - getPersonaSpawnPlan(personaId: string, options: SpawnPersonaOptions = {}): PersonaSpawnPlan { - const planOptions = { - ...(options.skillsInstallRoot !== undefined ? { installRoot: options.skillsInstallRoot } : {}), - ...(options.envOverrides !== undefined ? { envOverrides: options.envOverrides } : {}), - ...(options.inputValues !== undefined ? { inputValues: options.inputValues } : {}), - }; - if (options.persona) { - return buildPersonaSpawnPlan(resolvePersona(options.persona), planOptions); - } - const personaCwd = options.personaCwd ?? options.cwd ?? process.cwd(); - const searchDirs = options.searchDirs ?? this.defaultPersonaDirs; - const callOptions: PersonaSpawnPlanOptions = { - cwd: personaCwd, - ...(searchDirs ? { searchDirs } : {}), - ...(options.extraDirs ? { extraDirs: options.extraDirs } : {}), - ...planOptions, - }; - return getPersonaSpawnPlanImpl(personaId, callOptions); - } - // ── Human source ──────────────────────────────────────────────────────── human(opts: { name: string }): HumanHandle { diff --git a/web/content/docs/event-handlers.mdx b/web/content/docs/event-handlers.mdx index ce64a5461..10520b975 100644 --- a/web/content/docs/event-handlers.mdx +++ b/web/content/docs/event-handlers.mdx @@ -227,7 +227,7 @@ Migration rules: - `relay.onXxx = null;` → either call the unsubscribe function returned from `addListener`, or use `relay.removeListener('xxx', handler)`. - `relay.onChannelSubscribed = (agent, channels) => ...` and `relay.onChannelUnsubscribed = ...` now receive a single `{ agent, channels }` object instead of positional args. -Per-call option callbacks like `spawnPersona({ onStart, onSuccess, onError })` are unchanged — those are scoped to a single invocation, not global hooks. +Per-call option callbacks like `spawnPty({ onStart, onSuccess, onError })` are unchanged — those are scoped to a single invocation, not global hooks. ## Good uses for listeners