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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ src/ Node relay; source runs as .ts, npm ships emitted dist-
router-types.ts handler contract and NOT_HANDLED sentinel
input.ts JSON body decoding with a caller error for malformed JSON
services/
base.ts own DBs, Reads, SessionPoller, actuator, caches, role store, shared UI lease
base.ts own DBs, Reads, SessionPoller, actuator, caches, agent store, shared UI lease
auto-model.ts assembled routing config and durable Auto selection/delivery queue
delivery.ts prompt budgets/receipts, chat opening, first-prompt and parked queues
delegations.ts ordinary-child UI adapters, completion observation, DelegationQueue
workflow.ts coordinator adapters and managed effect dispatch
Expand All @@ -43,7 +44,7 @@ src/ Node relay; source runs as .ts, npm ships emitted dist-
files.ts static PWA, attachment and tool-image responses
responses.ts authentication, body limits, conditional JSON, redaction/compression
routes/ state, system, workspaces, create-workspace, sessions, prompts,
files, workflows, voice; injected services, no startup side effects
agents, auto-model, files, workflows, voice; injected services, no startup side effects
reads/
repository.ts Reads facade over the same read-only ConductorDb
workspaces.ts workspace/repo reads, search targets, archive metadata
Expand Down Expand Up @@ -104,9 +105,16 @@ src/ Node relay; source runs as .ts, npm ships emitted dist-
prompt.ts child assignment with the frozen parent chat reference
return.ts saved final-answer report and completion notice
types.ts queue adapter contracts
agents/ agent-config.ts: model receipt before effort/Fast; model-cache.ts:
observed picker labels; roles.ts: strict global roles.json;
conductor-settings.ts: surgical new-chat defaults in settings.toml
agents/
agent-config.ts model receipt before effort/Fast; model-cache.ts: observed picker labels
agent-file.ts flat frontmatter parse/patch, preserving unknown blocks and Markdown verbatim
agent-store.ts cached agents/*.md roster, legacy role/Auto facades and write-through semantics
agent-migration.ts copy-only roles.json + auto-model.json merge, publishing a complete directory
roles.ts role decoding/resolution and legacy JSON reader for migration
routing.ts routing.json globals, derived profiles and fallback validation
conductor-settings.ts surgical new-chat defaults in settings.toml
auto-model/ config.ts/types.ts: assembled v1 contract, validation and frozen job config;
decision.ts/provider.ts: isolated router; queue.ts: durable Auto ownership
delivery/ firstprompt.ts, parked.ts, sendonce.ts: durable ordinary prompt queues
and the in-process repeated-client-id send memo
dev-server/ controller.ts owns preview/forward state; proxy.ts tunnels requests;
Expand Down
150 changes: 150 additions & 0 deletions src/agents/agent-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/** Flat frontmatter only. Unknown blocks and Markdown are never parsed as YAML. */
import { z } from 'zod'
import { AGENT_EFFORTS, modelPickerLabel } from '../shared.ts'
import type { AgentDefinition, AgentsConfig } from '../wire.ts'

export const AGENT_NAME = /^[a-z][a-z0-9_-]{0,63}$/
export const MAX_AGENTS = 32
const FRONTMATTER_KEYS = ['description', 'model', 'effort', 'fast', 'routing'] as const
type FrontmatterKey = (typeof FRONTMATTER_KEYS)[number]
type Frontmatter = Pick<AgentDefinition, FrontmatterKey>

const agentSchema = z
.object({
name: z.string().regex(AGENT_NAME),
description: z.string().max(1000).optional(),
model: z.string().trim().min(1).max(256).transform(modelPickerLabel),
effort: z.enum(AGENT_EFFORTS).optional(),
fast: z.boolean().optional(),
routing: z.boolean().optional(),
preamble: z.string().max(50_000).optional()
})
.strict()
const agentsSchema = z.object({ version: z.literal(1), agents: z.array(agentSchema).max(MAX_AGENTS) }).strict()

export function decodeAgent(raw: unknown): AgentDefinition {
return agentSchema.parse(raw)
}

export function decodeAgents(raw: unknown): AgentsConfig {
const config = agentsSchema.parse(raw)
if (new Set(config.agents.map(agent => agent.name)).size !== config.agents.length)
throw new Error('Agent names must be unique.')
return config
}

interface RawBlock {
key?: string
lines: string[]
}

export interface AgentFile {
fields: Partial<Frontmatter>
body: string
/** Includes original line endings, comments and unrecognized syntax. */
blocks: RawBlock[]
opening: string
closing: string
newline: string
}

function lineText(line: string): string {
return line.replace(/\r?\n$/, '')
}

function knownKey(key: string | undefined): key is FrontmatterKey {
return FRONTMATTER_KEYS.some(known => known === key)
}

/** Quoted scalars accept YAML single-quote escaping and JSON double-quote escapes. */
function scalar(raw: string, key: string): string {
const value = raw.trim()
if (value.startsWith('"')) {
const match = /^("(?:[^"\\]|\\.)*")(?:\s+#.*)?$/.exec(value)
if (!match) throw new Error(`${key} has an invalid quoted scalar`)
try {
return JSON.parse(match[1]) as string
} catch {
throw new Error(`${key} has an invalid quoted scalar`)
}
}
if (value.startsWith("'")) {
const match = /^'((?:[^']|'')*)'(?:\s+#.*)?$/.exec(value)
if (!match) throw new Error(`${key} has an invalid quoted scalar`)
return match[1].replaceAll("''", "'")
}
const bare = value.replace(/\s+#.*$/, '').trimEnd()
if (/^(?:[|>&*!]|\[|\{)/.test(bare)) throw new Error(`${key} must be a flat scalar`)
return bare
}

/** Parsing does not require a model: imports and body-only files can be patched first. */
export function parseAgentFile(source: string): AgentFile {
const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? []
const newline = source.includes('\r\n') ? '\r\n' : '\n'
const file: AgentFile = { fields: {}, body: source, blocks: [], opening: '', closing: '', newline }
const first = lines[0] ?? ''
if (lineText(first) !== '---') return file
const end = lines.findIndex((line, index) => index > 0 && lineText(line) === '---')
if (end < 0) throw new Error('Frontmatter is missing its closing --- line')
file.opening = first
file.closing = lines[end]
file.body = lines.slice(end + 1).join('')
for (const line of lines.slice(1, end)) {
const key = /^([^\s:#][^:]*):/.exec(lineText(line))?.[1]
const previous = file.blocks.at(-1)
// Blank lines and comments do not end a key's block. In particular, a
// colon inside a comment must not detach the next indented continuation.
if (!key && previous) previous.lines.push(line)
else file.blocks.push({ key, lines: [line] })
}
const seen = new Set<string>()
for (const block of file.blocks) {
if (!knownKey(block.key)) continue
const key = block.key
if (seen.has(key)) throw new Error(`Duplicate ${key} frontmatter`)
seen.add(key)
if (block.lines.slice(1).some(line => /^[ \t]+\S/.test(line) && !line.trimStart().startsWith('#')))
throw new Error(`${key} must be a flat scalar`)
const value = scalar(lineText(block.lines[0]).slice(key.length + 1), key)
if (key === 'fast' || key === 'routing') {
if (value !== 'true' && value !== 'false') throw new Error(`${key} must be true or false`)
file.fields[key] = value === 'true'
} else if (key === 'effort') {
file.fields.effort = z.enum(AGENT_EFFORTS).parse(value)
} else file.fields[key] = value
}
return file
}

/** Only keys present in patch are touched; undefined removes one known key. */
export function serializeAgentFile(file: AgentFile, patch: Partial<Frontmatter> = {}, body = file.body): string {
const remaining = new Set(FRONTMATTER_KEYS.filter(key => Object.hasOwn(patch, key)))
const rendered: string[] = []
for (const block of file.blocks) {
const key = block.key
if (!knownKey(key) || !remaining.has(key)) {
rendered.push(...block.lines)
continue
}
remaining.delete(key)
if (patch[key] === file.fields[key]) {
rendered.push(...block.lines)
continue
}
if (patch[key] !== undefined) {
const newline = block.lines[0].endsWith('\r\n') ? '\r\n' : '\n'
rendered.push(`${key}: ${JSON.stringify(patch[key])}${newline}`)
}
// Keep comments, blank lines and other opaque syntax around the known line.
rendered.push(...block.lines.slice(1))
}
for (const key of remaining) {
if (patch[key] !== undefined) rendered.push(`${key}: ${JSON.stringify(patch[key])}${file.newline}`)
}
if (!file.opening && !rendered.length) return body
const opening = file.opening || `---${file.newline}`
let closing = file.closing || `---${file.newline}`
if (body && !closing.endsWith('\n')) closing += file.newline
return `${opening}${rendered.join('')}${closing}${body}`
}
95 changes: 95 additions & 0 deletions src/agents/agent-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { isUtf8 } from 'node:buffer'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { z } from 'zod'
import type { AgentImportScanResponse, ImportAgentsRequest } from '../wire.ts'
import { AGENT_NAME, decodeAgent, parseAgentFile } from './agent-file.ts'
import type { AgentStore } from './agent-store.ts'

export const MAX_IMPORT_FILES = 64
export const MAX_IMPORT_BYTES = 256 * 1024

const requestSchema = z
.object({
names: z.array(z.string().min(1).max(256)).min(1).max(MAX_IMPORT_FILES),
overwrite: z.boolean().optional()
})
.strict()

export function decodeImportAgents(raw: unknown): ImportAgentsRequest {
return requestSchema.parse(raw)
}

/** Resolve and open the same regular file, rejecting links and bounding even a growing file's read. */
function readCandidate(directory: string, filename: string): Buffer {
const file = path.join(directory, filename)
const resolved = fs.realpathSync(file)
if (path.dirname(resolved) !== directory) throw new Error('Symlinks outside the agents directory are not imported.')
const stat = fs.lstatSync(file)
if (!stat.isFile()) throw new Error('Agent definitions must be regular files, not symlinks or directories.')
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK)
try {
const opened = fs.fstatSync(fd)
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev)
throw new Error('The agent file changed while opening it. Refresh and try again.')
if (opened.size > MAX_IMPORT_BYTES) throw new Error(`Agent files must be at most ${MAX_IMPORT_BYTES / 1024} KiB.`)
const buffer = Buffer.alloc(MAX_IMPORT_BYTES + 1)
let size = 0
while (size < buffer.length) {
const count = fs.readSync(fd, buffer, size, buffer.length - size, null)
if (!count) break
size += count
}
if (size > MAX_IMPORT_BYTES) throw new Error(`Agent files must be at most ${MAX_IMPORT_BYTES / 1024} KiB.`)
return buffer.subarray(0, size)
} finally {
fs.closeSync(fd)
}
}

/** No recursive/repository discovery. POST re-scans so a changed or rejected file is never copied blind. */
export function scanClaudeAgents(
store: AgentStore,
directory = path.join(os.homedir(), '.claude', 'agents')
): { response: AgentImportScanResponse; sources: Map<string, Buffer> } {
const response: AgentImportScanResponse = { candidates: [], skipped: [], truncated: false, limit: MAX_IMPORT_FILES }
const sources = new Map<string, Buffer>()
let root: string
try {
root = fs.realpathSync(directory)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { response, sources }
throw error
}
const names = fs
.readdirSync(root)
.filter(name => name.endsWith('.md'))
.sort()
response.truncated = names.length > MAX_IMPORT_FILES
const existing = new Set(store.names())
for (const filename of names.slice(0, MAX_IMPORT_FILES)) {
const name = filename.slice(0, -3)
try {
if (!AGENT_NAME.test(name))
throw new Error(
'Use a filename starting with a letter and up to 64 lowercase letters, numbers, dashes or underscores.'
)
const bytes = readCandidate(root, filename)
if (!isUtf8(bytes)) throw new Error('Agent definitions must be valid UTF-8.')
const parsed = parseAgentFile(bytes.toString('utf8'))
const agent = decodeAgent({ name, ...parsed.fields, preamble: parsed.body })
response.candidates.push({
name,
description: agent.description,
model: parsed.fields.model!,
hasBody: !!parsed.body.trim(),
collision: existing.has(name)
})
sources.set(name, bytes)
} catch (error) {
response.skipped.push({ name, reason: error instanceof Error ? error.message : String(error) })
}
}
return { response, sources }
}
51 changes: 51 additions & 0 deletions src/agents/agent-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from 'node:fs'
import path from 'node:path'
import type { AgentDefinition } from '../wire.ts'
import { decodeAgents, parseAgentFile, serializeAgentFile } from './agent-file.ts'
import { AutoModelConfigStore } from './auto-model/config.ts'
import { RoleStore } from './roles.ts'
import { RoutingConfigStore, routingGlobals } from './routing.ts'

/** Copy once, publishing a complete roster. The two legacy files are never written. */
export function migrateAgents(directory: string): void {
try {
if (!fs.lstatSync(directory).isDirectory()) throw new Error('agents must be a directory')
return
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const root = path.dirname(directory)
const roles = new RoleStore(path.join(root, 'roles.json')).read()
// A malformed legacy file must stay repairable, not turn defaults into permanent state.
if (roles.warning) throw new Error(roles.warning)
const auto = new AutoModelConfigStore(path.join(root, 'auto-model.json')).read()
const agents = new Map<string, AgentDefinition>(
Object.entries(roles.config.roles).map(([name, role]) => [name, { name, ...role }])
)
for (const { id, description, ...tuple } of auto.profiles) {
const role = agents.get(id)
agents.set(id, { ...(role ?? { name: id, ...tuple }), description })
}
const config = decodeAgents({ version: 1, agents: [...agents.values()] })
fs.mkdirSync(root, { recursive: true })
const staging = fs.mkdtempSync(path.join(root, '.agents-migration-'))
try {
for (const { name, preamble = '', ...fields } of config.agents) {
fs.writeFileSync(path.join(staging, `${name}.md`), serializeAgentFile(parseAgentFile(''), fields, preamble), {
mode: 0o600
})
}
const routingFile = path.join(root, 'routing.json')
// A prior interrupted migration may have published the globals already.
if (!fs.existsSync(routingFile)) new RoutingConfigStore(routingFile).write(routingGlobals(auto))
try {
fs.renameSync(staging, directory)
} catch (error) {
if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error
// Another relay finished the same copy while this process prepared its files.
if (!fs.lstatSync(directory).isDirectory()) throw error
}
} finally {
fs.rmSync(staging, { recursive: true, force: true })
}
}
Loading
Loading